Skip to content

Commit 3d50cea

Browse files
feat: add docs search index and modal
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 8b83dcb commit 3d50cea

File tree

3 files changed

+416
-2
lines changed

3 files changed

+416
-2
lines changed
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
<script lang="ts">
2+
import Fuse from 'fuse.js';
3+
import { tick } from 'svelte';
4+
import type { SearchEntry } from '../../routes/docs/docs-data';
5+
6+
interface Props {
7+
open: boolean;
8+
searchIndex: SearchEntry[];
9+
onclose: () => void;
10+
onnavigate: (slug: string) => void;
11+
}
12+
13+
let { open, searchIndex, onclose, onnavigate }: Props = $props();
14+
15+
let query = $state('');
16+
let selectedIndex = $state(-1);
17+
let inputRef = $state<HTMLInputElement | null>(null);
18+
let resultsRef = $state<HTMLUListElement | null>(null);
19+
20+
interface SearchResult extends SearchEntry {
21+
snippet?: string;
22+
score?: number;
23+
}
24+
25+
let fuse = $derived(
26+
new Fuse(searchIndex, {
27+
keys: [
28+
{ name: 'title', weight: 3 },
29+
{ name: 'headings', weight: 2 },
30+
{ name: 'content', weight: 1 }
31+
],
32+
threshold: 0.3,
33+
includeScore: true,
34+
includeMatches: true,
35+
minMatchCharLength: 2
36+
})
37+
);
38+
39+
let results = $derived.by<SearchResult[]>(() => {
40+
if (query.trim().length === 0) return [];
41+
const fuseResults = fuse.search(query).slice(0, 8);
42+
return fuseResults.map((r) => {
43+
const snippet = r.matches?.[0]?.value
44+
? r.matches[0].value.substring(0, 100) + '...'
45+
: undefined;
46+
return {
47+
...r.item,
48+
snippet,
49+
score: r.score
50+
};
51+
});
52+
});
53+
54+
$effect(() => {
55+
// Reset selectedIndex when results change
56+
if (query.trim().length > 0) {
57+
selectedIndex = results.length > 0 ? 0 : -1;
58+
} else {
59+
selectedIndex = -1;
60+
}
61+
});
62+
63+
$effect(() => {
64+
if (open) {
65+
tick().then(() => {
66+
inputRef?.focus();
67+
});
68+
}
69+
});
70+
71+
$effect(() => {
72+
if (!open) {
73+
query = '';
74+
}
75+
});
76+
77+
$effect(() => {
78+
if (selectedIndex >= 0 && resultsRef) {
79+
const selectedElement = resultsRef.children[selectedIndex] as HTMLElement;
80+
selectedElement?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
81+
}
82+
});
83+
84+
function handleKeydown(e: KeyboardEvent) {
85+
const itemCount = query.trim().length > 0 ? results.length : searchIndex.length;
86+
87+
if (e.key === 'ArrowDown') {
88+
e.preventDefault();
89+
selectedIndex = selectedIndex < itemCount - 1 ? selectedIndex + 1 : 0;
90+
} else if (e.key === 'ArrowUp') {
91+
e.preventDefault();
92+
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : itemCount - 1;
93+
} else if (e.key === 'Enter') {
94+
e.preventDefault();
95+
if (selectedIndex >= 0) {
96+
const item = query.trim().length > 0 ? results[selectedIndex] : searchIndex[selectedIndex];
97+
if (item) select(item);
98+
}
99+
} else if (e.key === 'Escape') {
100+
e.preventDefault();
101+
onclose();
102+
}
103+
}
104+
105+
function select(entry: SearchEntry) {
106+
onnavigate(entry.slug);
107+
}
108+
109+
function handleBackdropClick(e: MouseEvent) {
110+
if (e.target === e.currentTarget) {
111+
onclose();
112+
}
113+
}
114+
</script>
115+
116+
{#if open}
117+
<div class="search-backdrop" role="presentation" onclick={handleBackdropClick}>
118+
<div class="search-modal" role="dialog" aria-modal="true" aria-label="Search documentation">
119+
<div class="search-input-wrapper">
120+
<svg
121+
class="search-icon"
122+
xmlns="http://www.w3.org/2000/svg"
123+
viewBox="0 0 24 24"
124+
fill="none"
125+
stroke="currentColor"
126+
stroke-width="2"
127+
stroke-linecap="round"
128+
stroke-linejoin="round"
129+
>
130+
<circle cx="11" cy="11" r="8"></circle>
131+
<path d="m21 21-4.35-4.35"></path>
132+
</svg>
133+
<input
134+
type="text"
135+
placeholder="Search docs..."
136+
bind:value={query}
137+
bind:this={inputRef}
138+
onkeydown={handleKeydown}
139+
/>
140+
<kbd class="search-kbd">ESC</kbd>
141+
</div>
142+
{#if results.length > 0}
143+
<ul class="search-results" role="listbox" bind:this={resultsRef}>
144+
{#each results as result, i}
145+
<li role="option" aria-selected={i === selectedIndex} class:selected={i === selectedIndex}>
146+
<button onclick={() => select(result)}>
147+
<span class="result-group">{result.group}</span>
148+
<span class="result-title">{result.title}</span>
149+
{#if result.snippet}
150+
<span class="result-snippet">{result.snippet}</span>
151+
{/if}
152+
</button>
153+
</li>
154+
{/each}
155+
</ul>
156+
{:else if query.length > 0}
157+
<div class="search-empty">No results for "{query}"</div>
158+
{:else}
159+
<ul class="search-results" role="listbox" bind:this={resultsRef}>
160+
{#each searchIndex as entry, i}
161+
<li role="option" aria-selected={i === selectedIndex} class:selected={i === selectedIndex}>
162+
<button onclick={() => select(entry)}>
163+
<span class="result-group">{entry.group}</span>
164+
<span class="result-title">{entry.title}</span>
165+
</button>
166+
</li>
167+
{/each}
168+
</ul>
169+
{/if}
170+
<div class="search-footer">
171+
<span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
172+
<span><kbd>↵</kbd> open</span>
173+
<span><kbd>esc</kbd> close</span>
174+
</div>
175+
</div>
176+
</div>
177+
{/if}
178+
179+
<style>
180+
.search-backdrop {
181+
position: fixed;
182+
inset: 0;
183+
background: rgba(0, 0, 0, 0.6);
184+
backdrop-filter: blur(4px);
185+
z-index: 200;
186+
display: flex;
187+
align-items: flex-start;
188+
justify-content: center;
189+
padding-top: 15vh;
190+
animation: fadeIn 0.15s ease-out;
191+
}
192+
193+
@keyframes fadeIn {
194+
from {
195+
opacity: 0;
196+
}
197+
to {
198+
opacity: 1;
199+
}
200+
}
201+
202+
.search-modal {
203+
width: 100%;
204+
max-width: 560px;
205+
background: var(--bg-secondary);
206+
border: 1px solid var(--border);
207+
border-radius: 12px;
208+
box-shadow:
209+
0 20px 60px rgba(0, 0, 0, 0.5),
210+
0 0 0 1px rgba(255, 255, 255, 0.05);
211+
overflow: hidden;
212+
max-height: 70vh;
213+
display: flex;
214+
flex-direction: column;
215+
animation: slideIn 0.2s ease-out;
216+
}
217+
218+
@keyframes slideIn {
219+
from {
220+
opacity: 0;
221+
transform: translateY(-20px) scale(0.96);
222+
}
223+
to {
224+
opacity: 1;
225+
transform: translateY(0) scale(1);
226+
}
227+
}
228+
229+
.search-input-wrapper {
230+
display: flex;
231+
align-items: center;
232+
padding: 14px 16px;
233+
border-bottom: 1px solid var(--border);
234+
gap: 10px;
235+
}
236+
237+
input {
238+
flex: 1;
239+
background: none;
240+
border: none;
241+
outline: none;
242+
font-size: 0.95rem;
243+
font-family: inherit;
244+
color: var(--text-primary);
245+
}
246+
247+
input::placeholder {
248+
color: var(--text-muted);
249+
}
250+
251+
.search-icon {
252+
color: var(--text-muted);
253+
width: 18px;
254+
height: 18px;
255+
flex-shrink: 0;
256+
}
257+
258+
.search-kbd {
259+
font-family: 'JetBrains Mono', monospace;
260+
font-size: 0.7rem;
261+
padding: 2px 6px;
262+
border: 1px solid var(--border);
263+
border-radius: 4px;
264+
color: var(--text-muted);
265+
background: var(--bg-tertiary);
266+
}
267+
268+
.search-results {
269+
overflow-y: auto;
270+
list-style: none;
271+
padding: 8px;
272+
margin: 0;
273+
flex: 1;
274+
}
275+
276+
.search-results li button {
277+
width: 100%;
278+
display: flex;
279+
flex-direction: column;
280+
gap: 2px;
281+
padding: 10px 12px;
282+
border: none;
283+
background: none;
284+
cursor: pointer;
285+
border-radius: 8px;
286+
text-align: left;
287+
font-family: inherit;
288+
color: var(--text-secondary);
289+
transition: all 0.1s;
290+
}
291+
292+
.search-results li.selected button,
293+
.search-results li button:hover {
294+
background: var(--bg-tertiary);
295+
color: var(--text-primary);
296+
}
297+
298+
.result-group {
299+
font-size: 0.68rem;
300+
font-weight: 600;
301+
text-transform: uppercase;
302+
letter-spacing: 0.06em;
303+
color: var(--text-muted);
304+
}
305+
306+
.result-title {
307+
font-size: 0.9rem;
308+
font-weight: 500;
309+
color: inherit;
310+
}
311+
312+
.result-snippet {
313+
font-size: 0.78rem;
314+
color: var(--text-muted);
315+
overflow: hidden;
316+
text-overflow: ellipsis;
317+
white-space: nowrap;
318+
}
319+
320+
.search-empty {
321+
padding: 32px 16px;
322+
text-align: center;
323+
color: var(--text-muted);
324+
font-size: 0.88rem;
325+
}
326+
327+
.search-footer {
328+
display: flex;
329+
gap: 16px;
330+
padding: 10px 16px;
331+
border-top: 1px solid var(--border);
332+
font-size: 0.72rem;
333+
color: var(--text-muted);
334+
}
335+
336+
.search-footer kbd {
337+
font-family: 'JetBrains Mono', monospace;
338+
font-size: 0.65rem;
339+
padding: 1px 5px;
340+
border: 1px solid var(--border);
341+
border-radius: 3px;
342+
background: var(--bg-tertiary);
343+
margin-right: 2px;
344+
}
345+
346+
@media (max-width: 600px) {
347+
.search-backdrop {
348+
padding-top: 10vh;
349+
}
350+
351+
.search-modal {
352+
max-width: calc(100% - 24px);
353+
margin: 0 12px;
354+
}
355+
356+
.search-footer {
357+
display: none;
358+
}
359+
}
360+
</style>

src/routes/docs/+layout.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { DOC_STRUCTURE, allDocs } from './docs-data';
1+
import { DOC_STRUCTURE, allDocs, searchIndex } from './docs-data';
22

33
export function load() {
44
return {
55
groups: DOC_STRUCTURE,
6-
allDocs
6+
allDocs,
7+
searchIndex
78
};
89
}

0 commit comments

Comments
 (0)