PlanTempusApp/PlanTempus.Application/wwwroot/ts/modules/services.ts
Janus C. H. Knudsen 120367acbb Enhances Services module with detail view and interactions
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
2026-01-16 22:03:22 +01:00

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);
}
}
}