From fad5e46dfb18f087e4374e2995902438746ba502 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Fri, 16 Jan 2026 01:05:11 +0100 Subject: [PATCH] Adds fuzzy search and enhances services UI interactions 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 --- PlanTempus.Application/package-lock.json | 12 ++ PlanTempus.Application/package.json | 3 + .../wwwroot/css/services.css | 6 + .../wwwroot/ts/modules/services.ts | 123 +++++++++++++++++- 4 files changed, 141 insertions(+), 3 deletions(-) diff --git a/PlanTempus.Application/package-lock.json b/PlanTempus.Application/package-lock.json index e66fc28..0ad315d 100644 --- a/PlanTempus.Application/package-lock.json +++ b/PlanTempus.Application/package-lock.json @@ -4,6 +4,9 @@ "requires": true, "packages": { "": { + "dependencies": { + "fuse.js": "^7.1.0" + }, "devDependencies": { "esbuild": "^0.27.2", "purgecss": "^6.0.0" @@ -654,6 +657,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", diff --git a/PlanTempus.Application/package.json b/PlanTempus.Application/package.json index 20ba044..d415797 100644 --- a/PlanTempus.Application/package.json +++ b/PlanTempus.Application/package.json @@ -6,5 +6,8 @@ }, "scripts": { "analyze-css": "node analyze-css.js" + }, + "dependencies": { + "fuse.js": "^7.1.0" } } diff --git a/PlanTempus.Application/wwwroot/css/services.css b/PlanTempus.Application/wwwroot/css/services.css index 5f8f472..c7f37ee 100644 --- a/PlanTempus.Application/wwwroot/css/services.css +++ b/PlanTempus.Application/wwwroot/css/services.css @@ -37,10 +37,16 @@ swp-search-input { border: none; background: transparent; outline: none; + box-shadow: none; font-size: var(--font-size-base); color: var(--color-text); width: 100%; + &:focus { + outline: none; + box-shadow: none; + } + &::placeholder { color: var(--color-text-tertiary); } diff --git a/PlanTempus.Application/wwwroot/ts/modules/services.ts b/PlanTempus.Application/wwwroot/ts/modules/services.ts index 2ef5e6c..57048f0 100644 --- a/PlanTempus.Application/wwwroot/ts/modules/services.ts +++ b/PlanTempus.Application/wwwroot/ts/modules/services.ts @@ -1,15 +1,133 @@ /** * Services Controller - * Handles category collapse/expand animations + * 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 | 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(); + + // 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 { @@ -18,7 +136,6 @@ export class ServicesController { if (!categoryRow) return; const isExpanded = categoryRow.getAttribute('data-expanded') !== 'false'; - const categoryId = categoryRow.getAttribute('data-category'); // Find all service rows belonging to this category const serviceRows = this.getServiceRowsForCategory(categoryRow); @@ -77,7 +194,7 @@ export class ServicesController { } private expandRows(rows: HTMLElement[]): void { - rows.forEach((row, index) => { + rows.forEach((row) => { // First make visible but with 0 height row.style.display = 'grid'; row.style.height = '0';