Implements advanced service search using Fuse.js Improves category expand/collapse animations Adds interactive search functionality for service list Enhances user experience by enabling quick service filtering and smooth UI interactions
228 lines
6.3 KiB
TypeScript
228 lines
6.3 KiB
TypeScript
/**
|
|
* Services Controller
|
|
* Handles category collapse/expand animations and fuzzy search
|
|
*/
|
|
|
|
import Fuse from 'fuse.js';
|
|
|
|
interface ServiceItem {
|
|
id: string;
|
|
name: string;
|
|
element: HTMLElement;
|
|
categoryRow: HTMLElement;
|
|
}
|
|
|
|
export class ServicesController {
|
|
private fuse: Fuse<ServiceItem> | null = null;
|
|
private services: ServiceItem[] = [];
|
|
|
|
constructor() {
|
|
this.init();
|
|
}
|
|
|
|
private init(): void {
|
|
this.setupCategoryToggle();
|
|
this.setupSearch();
|
|
}
|
|
|
|
private setupSearch(): void {
|
|
const searchInput = document.querySelector('swp-search-input input') as HTMLInputElement;
|
|
if (!searchInput) return;
|
|
|
|
// Build service index from DOM
|
|
this.buildServiceIndex();
|
|
|
|
// Initialize Fuse.js
|
|
this.fuse = new Fuse(this.services, {
|
|
keys: ['name'],
|
|
threshold: 0.3,
|
|
minMatchCharLength: 2
|
|
});
|
|
|
|
// Listen for input
|
|
searchInput.addEventListener('input', (e) => {
|
|
const query = (e.target as HTMLInputElement).value.trim();
|
|
this.filterServices(query);
|
|
});
|
|
}
|
|
|
|
private buildServiceIndex(): void {
|
|
const serviceRows = document.querySelectorAll('swp-card.services-list swp-data-table-row');
|
|
|
|
serviceRows.forEach((row) => {
|
|
const element = row as HTMLElement;
|
|
const id = element.getAttribute('data-service-detail') || '';
|
|
const nameCell = element.querySelector('swp-data-table-cell:first-child');
|
|
const name = nameCell?.textContent?.trim() || '';
|
|
|
|
// Find the category row for this service
|
|
let categoryRow: HTMLElement | null = null;
|
|
let sibling = element.previousElementSibling;
|
|
while (sibling) {
|
|
if (sibling.tagName.toLowerCase() === 'swp-category-row') {
|
|
categoryRow = sibling as HTMLElement;
|
|
break;
|
|
}
|
|
sibling = sibling.previousElementSibling;
|
|
}
|
|
|
|
if (categoryRow) {
|
|
this.services.push({
|
|
id,
|
|
name,
|
|
element,
|
|
categoryRow
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private filterServices(query: string): void {
|
|
if (!query || query.length < 2) {
|
|
// Show all services and categories
|
|
this.showAll();
|
|
return;
|
|
}
|
|
|
|
if (!this.fuse) return;
|
|
|
|
// Get matching services
|
|
const results = this.fuse.search(query);
|
|
const matchingIds = new Set(results.map(r => r.item.id));
|
|
|
|
// Track which categories have visible services
|
|
const visibleCategories = new Set<HTMLElement>();
|
|
|
|
// Show/hide services
|
|
this.services.forEach(service => {
|
|
if (matchingIds.has(service.id)) {
|
|
service.element.style.display = 'grid';
|
|
visibleCategories.add(service.categoryRow);
|
|
} else {
|
|
service.element.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Show/hide categories based on whether they have visible services
|
|
const allCategoryRows = document.querySelectorAll('swp-card.services-list swp-category-row');
|
|
allCategoryRows.forEach((row) => {
|
|
const categoryRow = row as HTMLElement;
|
|
if (visibleCategories.has(categoryRow)) {
|
|
categoryRow.style.display = 'grid';
|
|
// Ensure category is expanded when filtering
|
|
categoryRow.setAttribute('data-expanded', 'true');
|
|
} else {
|
|
categoryRow.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
private showAll(): void {
|
|
// Show all services
|
|
this.services.forEach(service => {
|
|
service.element.style.display = 'grid';
|
|
});
|
|
|
|
// Show all categories
|
|
const allCategoryRows = document.querySelectorAll('swp-card.services-list swp-category-row');
|
|
allCategoryRows.forEach((row) => {
|
|
(row as HTMLElement).style.display = 'grid';
|
|
});
|
|
}
|
|
|
|
private setupCategoryToggle(): void {
|
|
document.addEventListener('click', (e) => {
|
|
const categoryRow = (e.target as HTMLElement).closest('swp-category-row');
|
|
if (!categoryRow) return;
|
|
|
|
const isExpanded = categoryRow.getAttribute('data-expanded') !== 'false';
|
|
|
|
// Find all service rows belonging to this category
|
|
const serviceRows = this.getServiceRowsForCategory(categoryRow);
|
|
|
|
if (isExpanded) {
|
|
// Collapse - set attribute immediately so chevron animates with rows
|
|
categoryRow.setAttribute('data-expanded', 'false');
|
|
this.collapseRows(serviceRows);
|
|
} else {
|
|
// Expand - set attribute immediately so chevron animates with rows
|
|
categoryRow.setAttribute('data-expanded', 'true');
|
|
this.expandRows(serviceRows);
|
|
}
|
|
});
|
|
}
|
|
|
|
private getServiceRowsForCategory(categoryRow: Element): HTMLElement[] {
|
|
const rows: HTMLElement[] = [];
|
|
let sibling = categoryRow.nextElementSibling;
|
|
|
|
while (sibling && sibling.tagName.toLowerCase() === 'swp-data-table-row') {
|
|
rows.push(sibling as HTMLElement);
|
|
sibling = sibling.nextElementSibling;
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
private collapseRows(rows: HTMLElement[]): void {
|
|
if (rows.length === 0) return;
|
|
|
|
// Animate each row
|
|
rows.forEach((row) => {
|
|
const height = row.offsetHeight;
|
|
row.style.height = `${height}px`;
|
|
row.style.overflow = 'hidden';
|
|
|
|
// Force reflow
|
|
row.offsetHeight;
|
|
|
|
row.style.transition = 'height 0.2s ease, opacity 0.2s ease';
|
|
row.style.height = '0';
|
|
row.style.opacity = '0';
|
|
});
|
|
|
|
// After animation completes
|
|
setTimeout(() => {
|
|
rows.forEach(row => {
|
|
row.style.display = 'none';
|
|
row.style.height = '';
|
|
row.style.opacity = '';
|
|
row.style.overflow = '';
|
|
row.style.transition = '';
|
|
});
|
|
}, 200);
|
|
}
|
|
|
|
private expandRows(rows: HTMLElement[]): void {
|
|
rows.forEach((row) => {
|
|
// First make visible but with 0 height
|
|
row.style.display = 'grid';
|
|
row.style.height = '0';
|
|
row.style.opacity = '0';
|
|
row.style.overflow = 'hidden';
|
|
|
|
// Measure natural height
|
|
row.style.height = 'auto';
|
|
const naturalHeight = row.offsetHeight;
|
|
row.style.height = '0';
|
|
|
|
// Force reflow
|
|
row.offsetHeight;
|
|
|
|
// Animate to natural height
|
|
row.style.transition = 'height 0.2s ease, opacity 0.2s ease';
|
|
row.style.height = `${naturalHeight}px`;
|
|
row.style.opacity = '1';
|
|
});
|
|
|
|
// Cleanup after animation
|
|
setTimeout(() => {
|
|
rows.forEach(row => {
|
|
row.style.height = '';
|
|
row.style.opacity = '';
|
|
row.style.overflow = '';
|
|
row.style.transition = '';
|
|
});
|
|
}, 200);
|
|
}
|
|
}
|