Adds comprehensive service detail view with multiple tabs and dynamic interactions Implements client-side navigation between service list and detail views Introduces mock service data catalog for flexible component rendering Extends localization support for new service detail screens Improves user experience by adding edit capabilities and smooth view transitions
423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
/**
|
|
* 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<ServiceItem> | 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<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);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
}
|