From f797d70a9e4c2aa145771665b9f29d06cab7a2b3 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 1 Apr 2026 22:29:51 +0200 Subject: [PATCH] feat(shared-ui): add content search support to GlobalSpotlight Add ContentSearcher interface, debounced cross-app search with AbortController, loading indicators, and content result rendering to the spotlight component. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared-ui/src/index.ts | 3 + .../src/navigation/GlobalSpotlight.svelte | 184 +++++++++++++++++- .../src/navigation/PillNavigation.svelte | 9 +- packages/shared-ui/src/navigation/index.ts | 7 +- 4 files changed, 197 insertions(+), 6 deletions(-) diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index cbc10d2f0..8721987ed 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -121,6 +121,9 @@ export type { ExpandableToolbarProps, RecentAppEntry, SpotlightAction, + ContentSearcher, + ContentSearchResult, + ContentSearchGroup, } from './navigation'; // Settings diff --git a/packages/shared-ui/src/navigation/GlobalSpotlight.svelte b/packages/shared-ui/src/navigation/GlobalSpotlight.svelte index 03ce59bd3..005ae84b0 100644 --- a/packages/shared-ui/src/navigation/GlobalSpotlight.svelte +++ b/packages/shared-ui/src/navigation/GlobalSpotlight.svelte @@ -12,12 +12,40 @@ onExecute: () => void; } + export interface ContentSearchResult { + id: string; + type: string; + appId: string; + title: string; + subtitle?: string; + appIcon?: string; + appColor?: string; + href: string; + score: number; + matchedField?: string; + } + + export interface ContentSearchGroup { + appId: string; + appName: string; + appIcon?: string; + appColor?: string; + results: ContentSearchResult[]; + } + + export type ContentSearcher = ( + query: string, + signal: AbortSignal + ) => Promise; + interface Props { open: boolean; onClose: () => void; apps: PillAppItem[]; quickActions?: SpotlightAction[]; placeholder?: string; + /** Content searcher for cross-app IndexedDB search */ + contentSearcher?: ContentSearcher; } let { @@ -26,6 +54,7 @@ apps, quickActions = [], placeholder = 'Was möchtest du tun?', + contentSearcher, }: Props = $props(); const store = createAppNavigationStore(); @@ -33,10 +62,14 @@ let searchQuery = $state(''); let selectedIndex = $state(0); let inputEl = $state(undefined); + let contentResults = $state([]); + let contentLoading = $state(false); + let debounceTimer: ReturnType | undefined; + let abortController: AbortController | undefined; - // Build combined results list + // Build combined results list (apps + actions) interface SpotlightResult { - type: 'app' | 'action'; + type: 'app' | 'action' | 'content'; id: string; label: string; description?: string; @@ -44,6 +77,8 @@ imageUrl?: string; shortcut?: string; category?: string; + href?: string; + appColor?: string; } const results = $derived.by(() => { @@ -106,11 +141,58 @@ category: action.category || 'Aktionen', }); } + + // Append content search results + for (const group of contentResults) { + for (const result of group.results) { + items.push({ + type: 'content', + id: result.id, + label: result.title, + description: result.subtitle, + imageUrl: group.appIcon, + category: group.appName, + href: result.href, + appColor: group.appColor, + }); + } + } } return items; }); + // Trigger content search on query change + $effect(() => { + const q = searchQuery.trim(); + if (!q || !contentSearcher) { + contentResults = []; + contentLoading = false; + return; + } + + contentLoading = true; + clearTimeout(debounceTimer); + abortController?.abort(); + + debounceTimer = setTimeout(async () => { + abortController = new AbortController(); + const { signal } = abortController; + try { + const groups = await contentSearcher!(q, signal); + if (!signal.aborted) { + contentResults = groups; + contentLoading = false; + } + } catch { + if (!signal.aborted) { + contentResults = []; + contentLoading = false; + } + } + }, 150); + }); + // Group results by category for display const groupedResults = $derived.by(() => { const groups: { category: string; items: SpotlightResult[] }[] = []; @@ -140,11 +222,19 @@ if (open) { searchQuery = ''; selectedIndex = 0; + contentResults = []; + contentLoading = false; requestAnimationFrame(() => inputEl?.focus()); } }); function handleSelect(item: SpotlightResult) { + if (item.type === 'content' && item.href) { + window.location.href = item.href; + onClose(); + return; + } + if (item.type === 'app') { const app = apps.find((a) => a.id === item.id); if (app) { @@ -258,6 +348,8 @@ d={iconPaths[item.icon]} /> + {:else if item.type === 'content' && item.appColor} + {:else} {item.type === 'app' ? '\u{1F4F1}' : '\u{26A1}'} @@ -272,13 +364,40 @@ {#if item.shortcut} {item.shortcut} + {:else if item.type === 'content'} + + + {/if} {/each} {/each} + {#if contentLoading} +
+ + + +
+ {/if} - {:else if searchQuery} + {:else if searchQuery && !contentLoading}
Keine Ergebnisse
+ {:else if searchQuery && contentLoading} +
+ + + +
{/if} @@ -402,7 +521,7 @@ /* Results */ .spotlight-results { - max-height: 360px; + max-height: 50vh; overflow-y: auto; padding: 0.5rem; } @@ -549,6 +668,63 @@ border-color: rgba(255, 255, 255, 0.1); } + /* Content search dot (colored by app) */ + .spotlight-content-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + flex-shrink: 0; + margin: 0 0.25rem; + } + + .spotlight-item-arrow { + width: 1rem; + height: 1rem; + opacity: 0.3; + flex-shrink: 0; + } + + /* Loading indicator */ + .spotlight-loading, + .spotlight-loading-full { + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + padding: 0.75rem; + } + + .spotlight-loading-full { + padding: 2rem; + } + + .spotlight-loading-dot { + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: currentColor; + opacity: 0.3; + animation: spotlightPulse 1s ease-in-out infinite; + } + + .spotlight-loading-dot:nth-child(2) { + animation-delay: 0.15s; + } + + .spotlight-loading-dot:nth-child(3) { + animation-delay: 0.3s; + } + + @keyframes spotlightPulse { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 0.8; + } + } + /* Mobile */ @media (max-width: 640px) { .spotlight-overlay { diff --git a/packages/shared-ui/src/navigation/PillNavigation.svelte b/packages/shared-ui/src/navigation/PillNavigation.svelte index f82863189..24ee26aa5 100644 --- a/packages/shared-ui/src/navigation/PillNavigation.svelte +++ b/packages/shared-ui/src/navigation/PillNavigation.svelte @@ -12,7 +12,10 @@ import PillTabGroup from './PillTabGroup.svelte'; import PillTagSelector from './PillTagSelector.svelte'; import AppDrawer from './AppDrawer.svelte'; - import GlobalSpotlight, { type SpotlightAction } from './GlobalSpotlight.svelte'; + import GlobalSpotlight, { + type SpotlightAction, + type ContentSearcher, + } from './GlobalSpotlight.svelte'; import { createGlobalSpotlightState } from './useGlobalSpotlight.svelte'; // Phosphor Icons (via shared-icons) import { @@ -290,6 +293,8 @@ spotlightActions?: SpotlightAction[]; /** Placeholder text for spotlight search */ spotlightPlaceholder?: string; + /** Content searcher for cross-app search in spotlight */ + contentSearcher?: ContentSearcher; /** Accessible label for the nav element */ ariaLabel?: string; /** Feedback page href (shown in user dropdown). Set to empty string to hide. */ @@ -345,6 +350,7 @@ onOpenInPanel, spotlightActions, spotlightPlaceholder, + contentSearcher, ariaLabel, feedbackHref = '/feedback', themesHref, @@ -804,6 +810,7 @@ apps={appItems} quickActions={spotlightActions} placeholder={spotlightPlaceholder} + {contentSearcher} /> {/if} diff --git a/packages/shared-ui/src/navigation/index.ts b/packages/shared-ui/src/navigation/index.ts index ff6ae3942..a0b211806 100644 --- a/packages/shared-ui/src/navigation/index.ts +++ b/packages/shared-ui/src/navigation/index.ts @@ -6,7 +6,12 @@ export { default as PillNavigation } from './PillNavigation.svelte'; export { default as PillDropdown } from './PillDropdown.svelte'; export { default as AppDrawer } from './AppDrawer.svelte'; export { default as GlobalSpotlight } from './GlobalSpotlight.svelte'; -export type { SpotlightAction } from './GlobalSpotlight.svelte'; +export type { + SpotlightAction, + ContentSearcher, + ContentSearchResult, + ContentSearchGroup, +} from './GlobalSpotlight.svelte'; export { createGlobalSpotlightState } from './useGlobalSpotlight.svelte'; export { createAppNavigationStore,