2026-01-15 23:29:26 +01:00
|
|
|
/**
|
|
|
|
|
* Services Controller
|
2026-01-16 22:03:22 +01:00
|
|
|
*
|
|
|
|
|
* 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
|
2026-01-15 23:29:26 +01:00
|
|
|
*/
|
|
|
|
|
|
2026-01-16 01:05:11 +01:00
|
|
|
import Fuse from 'fuse.js';
|
|
|
|
|
|
|
|
|
|
interface ServiceItem {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
element: HTMLElement;
|
|
|
|
|
categoryRow: HTMLElement;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 23:29:26 +01:00
|
|
|
export class ServicesController {
|
2026-01-16 01:05:11 +01:00
|
|
|
private fuse: Fuse<ServiceItem> | null = null;
|
|
|
|
|
private services: ServiceItem[] = [];
|
2026-01-16 22:03:22 +01:00
|
|
|
private listView: HTMLElement | null = null;
|
|
|
|
|
private detailView: HTMLElement | null = null;
|
2026-01-16 01:05:11 +01:00
|
|
|
|
2026-01-15 23:29:26 +01:00
|
|
|
constructor() {
|
2026-01-16 22:03:22 +01:00
|
|
|
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;
|
|
|
|
|
|
2026-01-15 23:29:26 +01:00
|
|
|
this.init();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private init(): void {
|
|
|
|
|
this.setupCategoryToggle();
|
2026-01-16 01:05:11 +01:00
|
|
|
this.setupSearch();
|
2026-01-16 22:03:22 +01:00
|
|
|
this.setupDetailTabs();
|
|
|
|
|
this.setupChevronNavigation();
|
|
|
|
|
this.setupBackNavigation();
|
|
|
|
|
this.setupHistoryNavigation();
|
|
|
|
|
this.restoreStateFromUrl();
|
2026-01-16 01:05:11 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
});
|
2026-01-15 23:29:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-01-16 01:05:11 +01:00
|
|
|
rows.forEach((row) => {
|
2026-01-15 23:29:26 +01:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-01-16 22:03:22 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<HTMLElement>('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<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
|
|
|
|
|
const contents = container.querySelectorAll<HTMLElement>('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<HTMLElement>('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<HTMLElement>('[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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-15 23:29:26 +01:00
|
|
|
}
|