main
svelte 250 lines 7.37 KB
Raw
1 <script lang="ts">
2 import { goto } from '$app/navigation';
3 import { base } from '$app/paths';
4 import { tick } from 'svelte';
5
6 let { open = $bindable(false) }: { open: boolean } = $props();
7
8 interface SubResult {
9 url: string;
10 title: string;
11 excerpt: string;
12 }
13
14 interface GroupedResult {
15 pageTitle: string;
16 pageUrl: string;
17 subResults: SubResult[];
18 }
19
20 let query = $state('');
21 let groups = $state<GroupedResult[]>([]);
22 let totalCount = $state(0);
23 let activeIndex = $state(0);
24 let loading = $state(false);
25 let searchUnavailable = $state(false);
26 let pagefind: any = null;
27 let inputEl: HTMLInputElement | undefined = $state();
28 let debounceTimer: ReturnType<typeof setTimeout> | undefined;
29
30 const flatResults = $derived(
31 groups.flatMap((g) => g.subResults.map((sr) => sr.url))
32 );
33
34 async function initPagefind() {
35 if (pagefind) return;
36 try {
37 const path = '/pagefind/pagefind.js';
38 pagefind = await import(/* @vite-ignore */ path);
39 } catch {
40 searchUnavailable = true;
41 }
42 }
43
44 async function search(q: string) {
45 if (!pagefind || !q.trim()) {
46 groups = [];
47 totalCount = 0;
48 return;
49 }
50 loading = true;
51 try {
52 const response = await pagefind.search(q);
53 totalCount = response.results.length;
54 const items = await Promise.all(
55 response.results.slice(0, 6).map(async (r: any) => {
56 const data = await r.data();
57 const pageTitle = data.meta?.title || data.url;
58 const pageUrl = data.url;
59 const subs: SubResult[] = (data.sub_results || []).slice(0, 3).map((sr: any) => ({
60 url: sr.url,
61 title: sr.title || pageTitle,
62 excerpt: sr.excerpt || ''
63 }));
64 if (subs.length === 0) {
65 subs.push({
66 url: pageUrl,
67 title: pageTitle,
68 excerpt: data.excerpt || ''
69 });
70 }
71 return { pageTitle, pageUrl, subResults: subs } as GroupedResult;
72 })
73 );
74 groups = items;
75 activeIndex = 0;
76 } catch {
77 groups = [];
78 totalCount = 0;
79 } finally {
80 loading = false;
81 }
82 }
83
84 function handleInput(e: Event) {
85 const value = (e.target as HTMLInputElement).value;
86 query = value;
87 clearTimeout(debounceTimer);
88 debounceTimer = setTimeout(() => search(value), 200);
89 }
90
91 function clearQuery() {
92 query = '';
93 groups = [];
94 totalCount = 0;
95 inputEl?.focus();
96 }
97
98 function navigate(url: string) {
99 const path = url.replace(/\.html$/, '').replace(/index$/, '');
100 goto(`${base}${path}`);
101 close();
102 }
103
104 function close() {
105 open = false;
106 query = '';
107 groups = [];
108 totalCount = 0;
109 activeIndex = 0;
110 }
111
112 function handleKeydown(e: KeyboardEvent) {
113 if (e.key === 'ArrowDown') {
114 e.preventDefault();
115 activeIndex = Math.min(activeIndex + 1, flatResults.length - 1);
116 } else if (e.key === 'ArrowUp') {
117 e.preventDefault();
118 activeIndex = Math.max(activeIndex - 1, 0);
119 } else if (e.key === 'Enter' && flatResults[activeIndex]) {
120 e.preventDefault();
121 navigate(flatResults[activeIndex]);
122 } else if (e.key === 'Escape') {
123 e.preventDefault();
124 close();
125 }
126 }
127
128 $effect(() => {
129 if (open) {
130 initPagefind();
131 tick().then(() => inputEl?.focus());
132 }
133 });
134 </script>
135
136 {#if open}
137 <!-- svelte-ignore a11y_no_static_element_interactions -->
138 <div
139 class="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]"
140 onkeydown={handleKeydown}
141 >
142 <!-- Backdrop -->
143 <button
144 class="absolute inset-0 bg-foreground/20 backdrop-blur-sm"
145 onclick={close}
146 aria-label="Close search"
147 tabindex="-1"
148 ></button>
149
150 <!-- Modal -->
151 <div
152 role="dialog"
153 aria-modal="true"
154 aria-label="Search documentation"
155 class="relative z-10 mx-4 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-border/80 bg-background/95 shadow-2xl backdrop-blur-xl"
156 >
157 <!-- Search input -->
158 <div class="flex items-center gap-3 px-5 py-4">
159 <svg class="h-5 w-5 shrink-0 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
160 <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
161 </svg>
162 <input
163 bind:this={inputEl}
164 type="text"
165 placeholder="Search documentation..."
166 value={query}
167 oninput={handleInput}
168 class="flex-1 bg-transparent text-lg text-foreground outline-none placeholder:text-text-muted/60"
169 />
170 {#if query}
171 <button onclick={clearQuery} class="rounded-md p-1 text-text-muted transition-colors hover:text-foreground" aria-label="Clear search">
172 <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
173 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
174 </svg>
175 </button>
176 {/if}
177 </div>
178
179 <!-- Results count -->
180 {#if query && !loading && !searchUnavailable}
181 <div class="border-t border-border/40 px-5 py-2 text-xs text-text-muted">
182 {totalCount} result{totalCount !== 1 ? 's' : ''} for <span class="font-medium text-foreground">{query}</span>
183 </div>
184 {/if}
185
186 <!-- Results -->
187 <div class="max-h-[55vh] overflow-y-auto border-t border-border/40 px-3 py-3">
188 {#if searchUnavailable}
189 <div class="px-4 py-8 text-center text-sm text-text-muted">
190 Search is available after building.<br />
191 Run <code class="rounded bg-secondary px-1.5 py-0.5 text-xs">bun run build</code> to enable.
192 </div>
193 {:else if loading}
194 <div class="px-4 py-8 text-center text-sm text-text-muted">Searching...</div>
195 {:else if query && groups.length === 0}
196 <div class="px-4 py-8 text-center text-sm text-text-muted">
197 No results for "<span class="font-medium text-foreground">{query}</span>"
198 </div>
199 {:else if groups.length > 0}
200 <div class="space-y-3">
201 {#each groups as group}
202 <div class="overflow-hidden rounded-xl border border-border/50 bg-secondary/30">
203 <!-- Page title -->
204 <div class="px-4 py-2.5">
205 <span class="font-display text-sm font-bold text-foreground">{group.pageTitle}</span>
206 </div>
207 <!-- Sub-results -->
208 <div class="divide-y divide-border/30">
209 {#each group.subResults as sub}
210 {@const flatIdx = flatResults.indexOf(sub.url)}
211 {@const subPath = sub.url.replace(/\.html$/, '').replace(/index$/, '')}
212 <a
213 href="{base}{subPath}"
214 class="block px-4 py-2.5 no-underline transition-colors {flatIdx === activeIndex
215 ? 'bg-primary/10'
216 : 'hover:bg-secondary/60'}"
217 onclick={(e) => { e.preventDefault(); navigate(sub.url); }}
218 onmouseenter={() => (activeIndex = flatIdx)}
219 >
220 <div class="text-sm font-semibold text-foreground/90">{sub.title}</div>
221 {#if sub.excerpt}
222 <div class="mt-0.5 line-clamp-1 text-xs text-text-muted">
223 {@html sub.excerpt}
224 </div>
225 {/if}
226 </a>
227 {/each}
228 </div>
229 </div>
230 {/each}
231 </div>
232 {:else}
233 <div class="px-4 py-8 text-center text-sm text-text-muted">
234 Type to search documentation
235 </div>
236 {/if}
237 </div>
238
239 <!-- Footer -->
240 <div class="flex items-center justify-between border-t border-border/40 px-5 py-2.5 text-[11px] text-text-muted">
241 <div class="flex items-center gap-3">
242 <span><kbd class="rounded border border-border/60 px-1 py-0.5">↑↓</kbd> navigate</span>
243 <span><kbd class="rounded border border-border/60 px-1 py-0.5"></kbd> open</span>
244 <span><kbd class="rounded border border-border/60 px-1 py-0.5">esc</kbd> close</span>
245 </div>
246 <span>Powered by Pagefind</span>
247 </div>
248 </div>
249 </div>
250 {/if}