@specglass/theme-default 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/__tests__/code-tabs.test.d.ts +2 -0
  2. package/dist/__tests__/code-tabs.test.d.ts.map +1 -0
  3. package/dist/__tests__/code-tabs.test.js +219 -0
  4. package/dist/__tests__/code-tabs.test.js.map +1 -0
  5. package/dist/__tests__/copy-button.test.d.ts +2 -0
  6. package/dist/__tests__/copy-button.test.d.ts.map +1 -0
  7. package/dist/__tests__/copy-button.test.js +116 -0
  8. package/dist/__tests__/copy-button.test.js.map +1 -0
  9. package/dist/__tests__/search-palette.test.d.ts +2 -0
  10. package/dist/__tests__/search-palette.test.d.ts.map +1 -0
  11. package/dist/__tests__/search-palette.test.js +71 -0
  12. package/dist/__tests__/search-palette.test.js.map +1 -0
  13. package/dist/__tests__/shiki.test.d.ts +2 -0
  14. package/dist/__tests__/shiki.test.d.ts.map +1 -0
  15. package/dist/__tests__/shiki.test.js +37 -0
  16. package/dist/__tests__/shiki.test.js.map +1 -0
  17. package/dist/__tests__/theme-css.test.d.ts +2 -0
  18. package/dist/__tests__/theme-css.test.d.ts.map +1 -0
  19. package/dist/__tests__/theme-css.test.js +124 -0
  20. package/dist/__tests__/theme-css.test.js.map +1 -0
  21. package/dist/__tests__/theme-helpers.test.d.ts +2 -0
  22. package/dist/__tests__/theme-helpers.test.d.ts.map +1 -0
  23. package/dist/__tests__/theme-helpers.test.js +81 -0
  24. package/dist/__tests__/theme-helpers.test.js.map +1 -0
  25. package/dist/index.d.ts +63 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +13 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/islands/CodeTabs.d.ts +21 -0
  30. package/dist/islands/CodeTabs.d.ts.map +1 -0
  31. package/dist/islands/CodeTabs.js +125 -0
  32. package/dist/islands/CodeTabs.js.map +1 -0
  33. package/dist/islands/CopyButton.d.ts +16 -0
  34. package/dist/islands/CopyButton.d.ts.map +1 -0
  35. package/dist/islands/CopyButton.js +54 -0
  36. package/dist/islands/CopyButton.js.map +1 -0
  37. package/dist/islands/SearchPalette.d.ts +2 -0
  38. package/dist/islands/SearchPalette.d.ts.map +1 -0
  39. package/dist/islands/SearchPalette.js +109 -0
  40. package/dist/islands/SearchPalette.js.map +1 -0
  41. package/dist/islands/SearchResults.d.ts +2 -0
  42. package/dist/islands/SearchResults.d.ts.map +1 -0
  43. package/dist/islands/SearchResults.js +130 -0
  44. package/dist/islands/SearchResults.js.map +1 -0
  45. package/dist/islands/ThemeToggle.d.ts +12 -0
  46. package/dist/islands/ThemeToggle.d.ts.map +1 -0
  47. package/dist/islands/ThemeToggle.js +43 -0
  48. package/dist/islands/ThemeToggle.js.map +1 -0
  49. package/dist/layouts/DocPage.test.d.ts +2 -0
  50. package/dist/layouts/DocPage.test.d.ts.map +1 -0
  51. package/dist/layouts/DocPage.test.js +165 -0
  52. package/dist/layouts/DocPage.test.js.map +1 -0
  53. package/dist/lib/utils.d.ts +10 -0
  54. package/dist/lib/utils.d.ts.map +1 -0
  55. package/dist/lib/utils.js +13 -0
  56. package/dist/lib/utils.js.map +1 -0
  57. package/dist/scripts/code-block-enhancer.d.ts +16 -0
  58. package/dist/scripts/code-block-enhancer.d.ts.map +1 -0
  59. package/dist/scripts/code-block-enhancer.js +55 -0
  60. package/dist/scripts/code-block-enhancer.js.map +1 -0
  61. package/dist/ui/command.d.ts +87 -0
  62. package/dist/ui/command.d.ts.map +1 -0
  63. package/dist/ui/command.js +28 -0
  64. package/dist/ui/command.js.map +1 -0
  65. package/dist/ui/dialog.d.ts +20 -0
  66. package/dist/ui/dialog.d.ts.map +1 -0
  67. package/dist/ui/dialog.js +22 -0
  68. package/dist/ui/dialog.js.map +1 -0
  69. package/dist/utils/parse-highlight-range.d.ts +12 -0
  70. package/dist/utils/parse-highlight-range.d.ts.map +1 -0
  71. package/dist/utils/parse-highlight-range.js +40 -0
  72. package/dist/utils/parse-highlight-range.js.map +1 -0
  73. package/dist/utils/parse-highlight-range.test.d.ts +2 -0
  74. package/dist/utils/parse-highlight-range.test.d.ts.map +1 -0
  75. package/dist/utils/parse-highlight-range.test.js +32 -0
  76. package/dist/utils/parse-highlight-range.test.js.map +1 -0
  77. package/dist/utils/schema-renderer.d.ts +38 -0
  78. package/dist/utils/schema-renderer.d.ts.map +1 -0
  79. package/dist/utils/schema-renderer.js +115 -0
  80. package/dist/utils/schema-renderer.js.map +1 -0
  81. package/dist/utils/schema-renderer.test.d.ts +2 -0
  82. package/dist/utils/schema-renderer.test.d.ts.map +1 -0
  83. package/dist/utils/schema-renderer.test.js +219 -0
  84. package/dist/utils/schema-renderer.test.js.map +1 -0
  85. package/dist/utils/shiki.d.ts +20 -0
  86. package/dist/utils/shiki.d.ts.map +1 -0
  87. package/dist/utils/shiki.js +84 -0
  88. package/dist/utils/shiki.js.map +1 -0
  89. package/dist/utils/sidebar-helpers.d.ts +10 -0
  90. package/dist/utils/sidebar-helpers.d.ts.map +1 -0
  91. package/dist/utils/sidebar-helpers.js +14 -0
  92. package/dist/utils/sidebar-helpers.js.map +1 -0
  93. package/dist/utils/theme-css.d.ts +21 -0
  94. package/dist/utils/theme-css.d.ts.map +1 -0
  95. package/dist/utils/theme-css.js +77 -0
  96. package/dist/utils/theme-css.js.map +1 -0
  97. package/dist/utils/theme-helpers.d.ts +28 -0
  98. package/dist/utils/theme-helpers.d.ts.map +1 -0
  99. package/dist/utils/theme-helpers.js +55 -0
  100. package/dist/utils/theme-helpers.js.map +1 -0
  101. package/dist/utils/toc-helpers.d.ts +12 -0
  102. package/dist/utils/toc-helpers.d.ts.map +1 -0
  103. package/dist/utils/toc-helpers.js +9 -0
  104. package/dist/utils/toc-helpers.js.map +1 -0
  105. package/package.json +68 -0
  106. package/src/components/ApiAuth.astro +116 -0
  107. package/src/components/ApiEndpoint.astro +75 -0
  108. package/src/components/ApiNavigation.astro +110 -0
  109. package/src/components/ApiParameters.astro +204 -0
  110. package/src/components/ApiResponse.astro +144 -0
  111. package/src/components/Callout.astro +54 -0
  112. package/src/components/Card.astro +46 -0
  113. package/src/components/CodeBlock.astro +142 -0
  114. package/src/components/CodeBlockGroup.astro +196 -0
  115. package/src/components/CodeTabs.astro +53 -0
  116. package/src/components/Footer.astro +41 -0
  117. package/src/components/Header.astro +80 -0
  118. package/src/components/Sidebar.astro +117 -0
  119. package/src/components/TabItem.astro +24 -0
  120. package/src/components/TableOfContents.astro +111 -0
  121. package/src/components/Tabs.astro +185 -0
  122. package/src/islands/CodeTabs.tsx +212 -0
  123. package/src/islands/CopyButton.tsx +101 -0
  124. package/src/islands/SearchPalette.tsx +307 -0
  125. package/src/islands/SearchResults.tsx +301 -0
  126. package/src/islands/ThemeToggle.tsx +107 -0
  127. package/src/layouts/ApiReferencePage.astro +239 -0
  128. package/src/layouts/DocPage.astro +199 -0
  129. package/src/layouts/DocPage.test.ts +183 -0
  130. package/src/layouts/LandingPage.astro +143 -0
  131. package/src/lib/utils.ts +13 -0
  132. package/src/styles/global.css +241 -0
  133. package/src/utils/parse-highlight-range.test.ts +40 -0
  134. package/src/utils/parse-highlight-range.ts +41 -0
  135. package/src/utils/schema-renderer.test.ts +269 -0
  136. package/src/utils/schema-renderer.ts +152 -0
  137. package/src/utils/shiki.ts +99 -0
  138. package/src/utils/sidebar-helpers.ts +24 -0
  139. package/src/utils/theme-css.ts +101 -0
  140. package/src/utils/theme-helpers.ts +59 -0
  141. package/src/utils/toc-helpers.ts +11 -0
@@ -0,0 +1,307 @@
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+
3
+ import { cn } from "../lib/utils";
4
+ import {
5
+ CommandDialog,
6
+ CommandInput,
7
+ CommandList,
8
+ CommandEmpty,
9
+ CommandItem,
10
+ } from "../ui/command";
11
+
12
+ /**
13
+ * SearchPalette — Cmd+K command palette using shadcn/ui CommandDialog.
14
+ *
15
+ * Uses the proper shadcn/ui Command + Dialog composition:
16
+ * - CommandDialog wraps Radix Dialog for overlay, portal, focus trap, ARIA
17
+ * - CommandInput, CommandList, CommandItem from shadcn command component
18
+ * - Pagefind provides search (cmdk's shouldFilter disabled)
19
+ *
20
+ * Designed for `client:idle` hydration.
21
+ */
22
+
23
+ /** Shape of a Pagefind search result */
24
+ interface PagefindResult {
25
+ id: string;
26
+ url: string;
27
+ excerpt: string;
28
+ meta: {
29
+ title?: string;
30
+ image?: string;
31
+ };
32
+ sub_results?: Array<{
33
+ title: string;
34
+ url: string;
35
+ excerpt: string;
36
+ }>;
37
+ }
38
+
39
+ interface PagefindResponse {
40
+ results: Array<{
41
+ id: string;
42
+ data: () => Promise<PagefindResult>;
43
+ }>;
44
+ }
45
+
46
+ interface PagefindAPI {
47
+ init: () => Promise<void>;
48
+ search: (query: string) => Promise<PagefindResponse>;
49
+ }
50
+
51
+ const DEBOUNCE_MS = 150;
52
+ const MAX_RESULTS = 8;
53
+
54
+ export function SearchPalette() {
55
+ const [open, setOpen] = useState(false);
56
+ const [query, setQuery] = useState("");
57
+ const [results, setResults] = useState<PagefindResult[]>([]);
58
+ const [isLoading, setIsLoading] = useState(false);
59
+ const [isUnavailable, setIsUnavailable] = useState(false);
60
+ const pagefindRef = useRef<PagefindAPI | null>(null);
61
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
62
+
63
+ // Load Pagefind JS lazily on palette open
64
+ const loadPagefind = useCallback(async () => {
65
+ if (pagefindRef.current) return;
66
+
67
+ try {
68
+ const pagefindPath = `/pagefind/pagefind.js`;
69
+ const pf = await import(/* @vite-ignore */ pagefindPath);
70
+ await pf.init();
71
+ pagefindRef.current = pf as PagefindAPI;
72
+ } catch {
73
+ setIsUnavailable(true);
74
+ }
75
+ }, []);
76
+
77
+ // Perform search
78
+ const performSearch = useCallback(
79
+ async (searchQuery: string) => {
80
+ if (!searchQuery.trim()) {
81
+ setResults([]);
82
+ return;
83
+ }
84
+
85
+ if (!pagefindRef.current) {
86
+ await loadPagefind();
87
+ if (!pagefindRef.current) return;
88
+ }
89
+
90
+ setIsLoading(true);
91
+ try {
92
+ const response = await pagefindRef.current.search(searchQuery);
93
+ const loaded = await Promise.all(
94
+ response.results.slice(0, MAX_RESULTS).map((r) => r.data()),
95
+ );
96
+ setResults(loaded);
97
+ } catch {
98
+ setResults([]);
99
+ } finally {
100
+ setIsLoading(false);
101
+ }
102
+ },
103
+ [loadPagefind],
104
+ );
105
+
106
+ // Handle input changes with debounce
107
+ const handleValueChange = useCallback(
108
+ (value: string) => {
109
+ setQuery(value);
110
+ if (debounceRef.current) {
111
+ clearTimeout(debounceRef.current);
112
+ }
113
+ debounceRef.current = setTimeout(() => {
114
+ performSearch(value);
115
+ }, DEBOUNCE_MS);
116
+ },
117
+ [performSearch],
118
+ );
119
+
120
+ // Global Cmd+K / Ctrl+K keyboard shortcut
121
+ useEffect(() => {
122
+ const handleKeyDown = (e: KeyboardEvent) => {
123
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
124
+ e.preventDefault();
125
+ setOpen((prev) => !prev);
126
+ }
127
+ };
128
+
129
+ document.addEventListener("keydown", handleKeyDown);
130
+ return () => document.removeEventListener("keydown", handleKeyDown);
131
+ }, []);
132
+
133
+ // Load Pagefind when palette opens
134
+ useEffect(() => {
135
+ if (open) {
136
+ loadPagefind();
137
+ }
138
+ }, [open, loadPagefind]);
139
+
140
+ // Reset state when closing
141
+ const handleOpenChange = useCallback((isOpen: boolean) => {
142
+ setOpen(isOpen);
143
+ if (!isOpen) {
144
+ setTimeout(() => {
145
+ setQuery("");
146
+ setResults([]);
147
+ }, 150);
148
+ }
149
+ }, []);
150
+
151
+ // Navigate to a search result
152
+ const handleSelect = useCallback(
153
+ (url: string) => {
154
+ handleOpenChange(false);
155
+ window.location.href = url;
156
+ },
157
+ [handleOpenChange],
158
+ );
159
+
160
+ // Cleanup debounce on unmount
161
+ useEffect(() => {
162
+ return () => {
163
+ if (debounceRef.current) {
164
+ clearTimeout(debounceRef.current);
165
+ }
166
+ };
167
+ }, []);
168
+
169
+ return (
170
+ <div data-pagefind-ignore="">
171
+ {/* Trigger button */}
172
+ <button
173
+ type="button"
174
+ onClick={() => setOpen(true)}
175
+ className={cn(
176
+ "inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md",
177
+ "border border-border bg-surface text-text-muted",
178
+ "hover:bg-hover-bg hover:text-text transition-colors",
179
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
180
+ )}
181
+ aria-label="Search documentation (⌘K)"
182
+ >
183
+ <svg
184
+ xmlns="http://www.w3.org/2000/svg"
185
+ width="14"
186
+ height="14"
187
+ viewBox="0 0 24 24"
188
+ fill="none"
189
+ stroke="currentColor"
190
+ strokeWidth="2"
191
+ strokeLinecap="round"
192
+ strokeLinejoin="round"
193
+ aria-hidden="true"
194
+ className="shrink-0"
195
+ >
196
+ <circle cx="11" cy="11" r="8" />
197
+ <path d="m21 21-4.3-4.3" />
198
+ </svg>
199
+ <span className="hidden sm:inline">Search…</span>
200
+ <kbd
201
+ className={cn(
202
+ "hidden sm:inline-flex items-center gap-0.5",
203
+ "rounded border border-border bg-surface-raised px-1.5 py-0.5",
204
+ "font-mono text-[10px] text-text-muted",
205
+ )}
206
+ >
207
+ ⌘K
208
+ </kbd>
209
+ </button>
210
+
211
+ {/* shadcn/ui CommandDialog */}
212
+ <CommandDialog open={open} onOpenChange={handleOpenChange} shouldFilter={false} dialogTitle="Search documentation">
213
+ <CommandInput
214
+ value={query}
215
+ onValueChange={handleValueChange}
216
+ placeholder="Search documentation…"
217
+ />
218
+
219
+ <CommandList>
220
+ {/* Dev-mode unavailable state */}
221
+ {isUnavailable && (
222
+ <div className="px-4 py-6 text-center text-sm text-text-muted">
223
+ <p className="mb-1">Search available after build</p>
224
+ <p className="text-xs opacity-70">
225
+ Run <code className="bg-surface-raised px-1 rounded">npx turbo build</code> to generate the search index
226
+ </p>
227
+ </div>
228
+ )}
229
+
230
+ {/* Loading state */}
231
+ {isLoading && !isUnavailable && (
232
+ <div className="px-4 py-6 text-center text-sm text-text-muted">
233
+ Searching…
234
+ </div>
235
+ )}
236
+
237
+ {/* Empty state */}
238
+ {!isLoading && !isUnavailable && query.trim().length > 0 && (
239
+ <CommandEmpty>
240
+ No results found for &ldquo;{query}&rdquo;
241
+ </CommandEmpty>
242
+ )}
243
+
244
+ {/* Prompt state */}
245
+ {!isUnavailable && !isLoading && query.trim().length === 0 && results.length === 0 && (
246
+ <div className="px-4 py-6 text-center text-sm text-text-muted">
247
+ Type to search documentation…
248
+ </div>
249
+ )}
250
+
251
+ {/* Results */}
252
+ {!isUnavailable &&
253
+ results.map((result) => (
254
+ <CommandItem
255
+ key={result.id}
256
+ value={result.id}
257
+ onSelect={() => handleSelect(result.url)}
258
+ className="flex flex-col items-start gap-1 px-3 py-2.5"
259
+ >
260
+ {/* Page title */}
261
+ <div className="font-medium text-text">
262
+ {result.meta?.title ?? "Untitled"}
263
+ </div>
264
+
265
+ {/* Sub-result heading */}
266
+ {result.sub_results?.[0]?.title &&
267
+ result.sub_results[0].title !== result.meta?.title && (
268
+ <div className="text-xs text-text-muted">
269
+ {result.sub_results[0].title}
270
+ </div>
271
+ )}
272
+
273
+ {/* Content snippet */}
274
+ <div
275
+ className="text-xs text-text-muted line-clamp-2 [&_mark]:bg-primary/20 [&_mark]:text-text [&_mark]:rounded-sm [&_mark]:px-0.5"
276
+ dangerouslySetInnerHTML={{
277
+ __html:
278
+ result.sub_results?.[0]?.excerpt ?? result.excerpt ?? "",
279
+ }}
280
+ />
281
+ </CommandItem>
282
+ ))}
283
+ </CommandList>
284
+
285
+ {/* Footer with keyboard hints */}
286
+ {(results.length > 0 || query.trim().length > 0) && !isUnavailable && (
287
+ <div className="flex items-center justify-between border-t border-border px-3 py-2 text-xs text-text-muted">
288
+ <div className="flex items-center gap-3">
289
+ <span className="inline-flex items-center gap-1">
290
+ <kbd className="rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px]">↑↓</kbd>
291
+ navigate
292
+ </span>
293
+ <span className="inline-flex items-center gap-1">
294
+ <kbd className="rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px]">↵</kbd>
295
+ open
296
+ </span>
297
+ <span className="inline-flex items-center gap-1">
298
+ <kbd className="rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px]">esc</kbd>
299
+ close
300
+ </span>
301
+ </div>
302
+ </div>
303
+ )}
304
+ </CommandDialog>
305
+ </div>
306
+ );
307
+ }
@@ -0,0 +1,301 @@
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * SearchResults — React island for Pagefind-powered documentation search.
5
+ *
6
+ * Provides a search input with debounced querying against the Pagefind index.
7
+ * Results display page title, section heading, and highlighted content snippets.
8
+ * Designed for `client:idle` hydration — search isn't needed immediately on load.
9
+ *
10
+ * Pagefind JS is loaded dynamically at runtime from /pagefind/pagefind.js
11
+ * (generated at build time by the Pagefind integration in @specglass/core).
12
+ *
13
+ * During dev server, the Pagefind index doesn't exist — handled gracefully
14
+ * with a "Search available after build" message.
15
+ */
16
+
17
+ /** Shape of a Pagefind search result (subset of the Pagefind API) */
18
+ interface PagefindResult {
19
+ id: string;
20
+ url: string;
21
+ excerpt: string;
22
+ meta: {
23
+ title?: string;
24
+ image?: string;
25
+ };
26
+ sub_results?: Array<{
27
+ title: string;
28
+ url: string;
29
+ excerpt: string;
30
+ }>;
31
+ }
32
+
33
+ interface PagefindResponse {
34
+ results: Array<{
35
+ id: string;
36
+ data: () => Promise<PagefindResult>;
37
+ }>;
38
+ }
39
+
40
+ interface PagefindAPI {
41
+ init: () => Promise<void>;
42
+ search: (query: string) => Promise<PagefindResponse>;
43
+ }
44
+
45
+ const DEBOUNCE_MS = 150;
46
+ const MAX_RESULTS = 8;
47
+
48
+ export function SearchResults() {
49
+ const [query, setQuery] = useState("");
50
+ const [results, setResults] = useState<PagefindResult[]>([]);
51
+ const [isLoading, setIsLoading] = useState(false);
52
+ const [isUnavailable, setIsUnavailable] = useState(false);
53
+ const [isFocused, setIsFocused] = useState(false);
54
+ const pagefindRef = useRef<PagefindAPI | null>(null);
55
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
56
+ const inputRef = useRef<HTMLInputElement>(null);
57
+ const resultsRef = useRef<HTMLDivElement>(null);
58
+ const [selectedIndex, setSelectedIndex] = useState(-1);
59
+
60
+ // Load Pagefind JS lazily on first focus
61
+ const loadPagefind = useCallback(async () => {
62
+ if (pagefindRef.current) return;
63
+
64
+ try {
65
+ // Pagefind generates this file at build time in the output directory.
66
+ // Dynamic concatenation prevents Vite from statically analyzing the path
67
+ // (the index only exists after `astro build`, not during dev).
68
+ const pagefindPath = `/pagefind/pagefind.js`;
69
+ const pf = await import(/* @vite-ignore */ pagefindPath);
70
+ await pf.init();
71
+ pagefindRef.current = pf as PagefindAPI;
72
+ } catch {
73
+ // During dev server, the index doesn't exist
74
+ setIsUnavailable(true);
75
+ }
76
+ }, []);
77
+
78
+ // Perform search with debouncing
79
+ const performSearch = useCallback(
80
+ async (searchQuery: string) => {
81
+ if (!searchQuery.trim()) {
82
+ setResults([]);
83
+ setSelectedIndex(-1);
84
+ return;
85
+ }
86
+
87
+ if (!pagefindRef.current) {
88
+ await loadPagefind();
89
+ if (!pagefindRef.current) return;
90
+ }
91
+
92
+ setIsLoading(true);
93
+ try {
94
+ const response = await pagefindRef.current.search(searchQuery);
95
+
96
+ // Load full data for top results
97
+ const loaded = await Promise.all(
98
+ response.results.slice(0, MAX_RESULTS).map((r) => r.data()),
99
+ );
100
+
101
+ setResults(loaded);
102
+ setSelectedIndex(-1);
103
+ } catch {
104
+ setResults([]);
105
+ } finally {
106
+ setIsLoading(false);
107
+ }
108
+ },
109
+ [loadPagefind],
110
+ );
111
+
112
+ // Handle input changes with debounce
113
+ const handleInputChange = useCallback(
114
+ (value: string) => {
115
+ setQuery(value);
116
+
117
+ if (debounceRef.current) {
118
+ clearTimeout(debounceRef.current);
119
+ }
120
+
121
+ debounceRef.current = setTimeout(() => {
122
+ performSearch(value);
123
+ }, DEBOUNCE_MS);
124
+ },
125
+ [performSearch],
126
+ );
127
+
128
+ // Keyboard navigation
129
+ const handleKeyDown = useCallback(
130
+ (e: React.KeyboardEvent) => {
131
+ if (e.key === "ArrowDown") {
132
+ e.preventDefault();
133
+ setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
134
+ } else if (e.key === "ArrowUp") {
135
+ e.preventDefault();
136
+ setSelectedIndex((prev) => Math.max(prev - 1, -1));
137
+ } else if (e.key === "Enter" && selectedIndex >= 0) {
138
+ e.preventDefault();
139
+ const result = results[selectedIndex];
140
+ if (result) {
141
+ window.location.href = result.url;
142
+ }
143
+ } else if (e.key === "Escape") {
144
+ setQuery("");
145
+ setResults([]);
146
+ setSelectedIndex(-1);
147
+ inputRef.current?.blur();
148
+ }
149
+ },
150
+ [results, selectedIndex],
151
+ );
152
+
153
+ // Click outside to close
154
+ useEffect(() => {
155
+ const handleClickOutside = (e: MouseEvent) => {
156
+ if (
157
+ resultsRef.current &&
158
+ !resultsRef.current.contains(e.target as Node) &&
159
+ inputRef.current &&
160
+ !inputRef.current.contains(e.target as Node)
161
+ ) {
162
+ setIsFocused(false);
163
+ }
164
+ };
165
+
166
+ document.addEventListener("mousedown", handleClickOutside);
167
+ return () => document.removeEventListener("mousedown", handleClickOutside);
168
+ }, []);
169
+
170
+ // Cleanup debounce on unmount
171
+ useEffect(() => {
172
+ return () => {
173
+ if (debounceRef.current) {
174
+ clearTimeout(debounceRef.current);
175
+ }
176
+ };
177
+ }, []);
178
+
179
+ const showResults = isFocused && query.trim().length > 0;
180
+
181
+ return (
182
+ <div className="relative" data-pagefind-ignore="">
183
+ {/* Search input */}
184
+ <div className="relative">
185
+ <svg
186
+ xmlns="http://www.w3.org/2000/svg"
187
+ width="16"
188
+ height="16"
189
+ viewBox="0 0 24 24"
190
+ fill="none"
191
+ stroke="currentColor"
192
+ strokeWidth="2"
193
+ strokeLinecap="round"
194
+ strokeLinejoin="round"
195
+ aria-hidden="true"
196
+ className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
197
+ >
198
+ <circle cx="11" cy="11" r="8" />
199
+ <path d="m21 21-4.3-4.3" />
200
+ </svg>
201
+ <input
202
+ ref={inputRef}
203
+ type="search"
204
+ value={query}
205
+ onChange={(e) => handleInputChange(e.target.value)}
206
+ onFocus={() => {
207
+ setIsFocused(true);
208
+ loadPagefind();
209
+ }}
210
+ onKeyDown={handleKeyDown}
211
+ placeholder="Search docs…"
212
+ aria-label="Search documentation"
213
+ aria-expanded={showResults}
214
+ aria-controls="search-results"
215
+ aria-autocomplete="list"
216
+ aria-activedescendant={
217
+ selectedIndex >= 0
218
+ ? `search-result-${results[selectedIndex]?.id}`
219
+ : undefined
220
+ }
221
+ role="combobox"
222
+ className="w-full pl-9 pr-3 py-1.5 text-sm rounded-md border border-border bg-surface text-text placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors"
223
+ />
224
+ </div>
225
+
226
+ {/* Results dropdown */}
227
+ {showResults && (
228
+ <div
229
+ ref={resultsRef}
230
+ id="search-results"
231
+ role="listbox"
232
+ aria-label="Search results"
233
+ className="absolute top-full left-0 right-0 mt-1 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface shadow-lg z-50"
234
+ style={{ minWidth: "320px" }}
235
+ >
236
+ {/* Unavailable state (dev server) */}
237
+ {isUnavailable && (
238
+ <div className="px-4 py-3 text-sm text-text-muted">
239
+ Search available after build
240
+ </div>
241
+ )}
242
+
243
+ {/* Loading state */}
244
+ {isLoading && !isUnavailable && (
245
+ <div className="px-4 py-3 text-sm text-text-muted">
246
+ Searching…
247
+ </div>
248
+ )}
249
+
250
+ {/* No results */}
251
+ {!isLoading && !isUnavailable && results.length === 0 && (
252
+ <div className="px-4 py-3 text-sm text-text-muted">
253
+ No results found
254
+ </div>
255
+ )}
256
+
257
+ {/* Results list */}
258
+ {!isUnavailable &&
259
+ results.map((result, index) => (
260
+ <a
261
+ key={result.id}
262
+ id={`search-result-${result.id}`}
263
+ href={result.url}
264
+ role="option"
265
+ aria-selected={index === selectedIndex}
266
+ className={`block px-4 py-3 text-sm no-underline border-b border-border last:border-b-0 transition-colors ${
267
+ index === selectedIndex
268
+ ? "bg-hover-bg"
269
+ : "hover:bg-hover-bg"
270
+ }`}
271
+ >
272
+ {/* Page title */}
273
+ <div className="font-medium text-text mb-0.5">
274
+ {result.meta?.title ?? "Untitled"}
275
+ </div>
276
+
277
+ {/* Sub-result heading (section) */}
278
+ {result.sub_results?.[0]?.title &&
279
+ result.sub_results[0].title !== result.meta?.title && (
280
+ <div className="text-xs text-text-muted mb-1">
281
+ {result.sub_results[0].title}
282
+ </div>
283
+ )}
284
+
285
+ {/* Content snippet with highlighted terms.
286
+ Pagefind generates sanitized HTML with <mark> tags for term highlighting.
287
+ ACCEPTED RISK: trusting Pagefind's build-time sanitization. */}
288
+ <div
289
+ className="text-xs text-text-muted line-clamp-2 [&_mark]:bg-primary/20 [&_mark]:text-text [&_mark]:rounded-sm [&_mark]:px-0.5"
290
+ dangerouslySetInnerHTML={{
291
+ __html:
292
+ result.sub_results?.[0]?.excerpt ?? result.excerpt ?? "",
293
+ }}
294
+ />
295
+ </a>
296
+ ))}
297
+ </div>
298
+ )}
299
+ </div>
300
+ );
301
+ }