/** * Services Controller * * Handles: * - Category collapse/expand animations * - Fuzzy search * - Content swap between list view and detail view * - Tab switching within detail view * - History API for browser back/forward navigation */ import Fuse from 'fuse.js'; interface ServiceItem { id: string; name: string; element: HTMLElement; categoryRow: HTMLElement; } export class ServicesController { private fuse: Fuse | null = null; private services: ServiceItem[] = []; private listView: HTMLElement | null = null; private detailView: HTMLElement | null = null; constructor() { this.listView = document.getElementById('services-list-view'); this.detailView = document.getElementById('service-detail-view'); // Only initialize if we're on the services page if (!this.listView) return; this.init(); } private init(): void { this.setupCategoryToggle(); this.setupSearch(); this.setupDetailTabs(); this.setupChevronNavigation(); this.setupBackNavigation(); this.setupHistoryNavigation(); this.restoreStateFromUrl(); } 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(); // 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); } /** * Setup popstate listener for browser back/forward */ private setupHistoryNavigation(): void { window.addEventListener('popstate', (e: PopStateEvent) => { if (e.state?.serviceKey) { this.showDetailViewInternal(e.state.serviceKey); } else { this.showListViewInternal(); } }); } /** * Restore view state from URL on page load */ private restoreStateFromUrl(): void { const hash = window.location.hash; if (hash.startsWith('#service-')) { const serviceKey = hash.substring(1); // Remove # this.showDetailViewInternal(serviceKey); } } /** * Setup tab switching for the detail view */ private setupDetailTabs(): void { if (!this.detailView) return; const tabs = this.detailView.querySelectorAll('swp-tab-bar > swp-tab[data-tab]'); tabs.forEach(tab => { tab.addEventListener('click', () => { const targetTab = tab.dataset.tab; if (targetTab) { this.switchTab(this.detailView!, targetTab); } }); }); } /** * Switch to a specific tab within a container */ private switchTab(container: HTMLElement, targetTab: string): void { const tabs = container.querySelectorAll('swp-tab-bar > swp-tab[data-tab]'); const contents = container.querySelectorAll('swp-tab-content[data-tab]'); tabs.forEach(t => { t.classList.toggle('active', t.dataset.tab === targetTab); }); contents.forEach(content => { content.classList.toggle('active', content.dataset.tab === targetTab); }); } /** * Setup row click to show detail view * Ignores clicks on category rows */ private setupChevronNavigation(): void { document.addEventListener('click', (e: Event) => { const target = e.target as HTMLElement; // Ignore clicks on category rows if (target.closest('swp-category-row')) { return; } const row = target.closest('swp-data-table-row[data-service-detail]'); if (row) { const serviceKey = row.dataset.serviceDetail; if (serviceKey) { this.showDetailView(serviceKey); } } }); } /** * Setup back button to return to list view */ private setupBackNavigation(): void { document.addEventListener('click', (e: Event) => { const target = e.target as HTMLElement; const backLink = target.closest('[data-service-back]'); if (backLink) { this.showListView(); } }); } /** * Show the detail view and hide list view (with history push) */ private showDetailView(serviceKey: string): void { // Push state to history history.pushState( { serviceKey }, '', `#${serviceKey}` ); this.showDetailViewInternal(serviceKey); } /** * Show detail view without modifying history (for popstate) */ private showDetailViewInternal(serviceKey: string): void { if (this.listView && this.detailView) { // Fade out list view this.listView.classList.add('view-fade-out'); // After fade, switch views setTimeout(() => { this.listView!.style.display = 'none'; this.listView!.classList.remove('view-fade-out'); // Show detail view with fade in this.detailView!.style.display = 'block'; this.detailView!.classList.add('view-fade-out'); this.detailView!.dataset.service = serviceKey; // Reset to first tab this.switchTab(this.detailView!, 'general'); // Trigger fade in requestAnimationFrame(() => { this.detailView!.classList.remove('view-fade-out'); }); }, 100); } } /** * Show the list view and hide detail view (with history push) */ private showListView(): void { // Push state to history (clear hash) history.pushState( {}, '', window.location.pathname ); this.showListViewInternal(); } /** * Show list view without modifying history (for popstate) */ private showListViewInternal(): void { if (this.listView && this.detailView) { // Fade out detail view this.detailView.classList.add('view-fade-out'); // After fade, switch views setTimeout(() => { this.detailView!.style.display = 'none'; this.detailView!.classList.remove('view-fade-out'); // Show list view with fade in this.listView!.style.display = 'block'; this.listView!.classList.add('view-fade-out'); // Trigger fade in requestAnimationFrame(() => { this.listView!.classList.remove('view-fade-out'); }); }, 100); } } }