@hypoth-ui/docs-renderer-next 0.1.0

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.
@@ -0,0 +1,508 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Search Input Component
5
+ *
6
+ * Full-featured search component that connects to the search API.
7
+ * Features:
8
+ * - Debounced search as you type
9
+ * - Keyboard navigation
10
+ * - Result grouping by type
11
+ * - Keyboard shortcut (/)
12
+ */
13
+
14
+ import { useCallback, useEffect, useRef, useState } from "react";
15
+ import { useRouter } from "next/navigation";
16
+
17
+ interface SearchResult {
18
+ id: string;
19
+ type: "component" | "guide";
20
+ title: string;
21
+ description?: string;
22
+ excerpt?: string;
23
+ url: string;
24
+ tags?: string[];
25
+ category?: string;
26
+ status?: string;
27
+ }
28
+
29
+ interface SearchResponse {
30
+ results: SearchResult[];
31
+ query: string;
32
+ total: number;
33
+ facets?: {
34
+ categories: string[];
35
+ types: string[];
36
+ tags: string[];
37
+ };
38
+ }
39
+
40
+ export interface SearchInputProps {
41
+ /** Placeholder text */
42
+ placeholder?: string;
43
+ /** Custom class name */
44
+ className?: string;
45
+ /** Whether search is enabled */
46
+ enabled?: boolean;
47
+ }
48
+
49
+ export function SearchInput({
50
+ placeholder = "Search documentation...",
51
+ className = "",
52
+ enabled = true,
53
+ }: SearchInputProps) {
54
+ const [query, setQuery] = useState("");
55
+ const [results, setResults] = useState<SearchResult[]>([]);
56
+ const [isOpen, setIsOpen] = useState(false);
57
+ const [isLoading, setIsLoading] = useState(false);
58
+ const [selectedIndex, setSelectedIndex] = useState(-1);
59
+ const inputRef = useRef<HTMLInputElement>(null);
60
+ const containerRef = useRef<HTMLDivElement>(null);
61
+ const router = useRouter();
62
+
63
+ // Debounced search
64
+ useEffect(() => {
65
+ if (!query.trim()) {
66
+ setResults([]);
67
+ return;
68
+ }
69
+
70
+ const abortController = new AbortController();
71
+ const timeoutId = setTimeout(async () => {
72
+ setIsLoading(true);
73
+ try {
74
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=10`, {
75
+ signal: abortController.signal,
76
+ });
77
+ if (response.ok) {
78
+ const data: SearchResponse = await response.json();
79
+ setResults(data.results);
80
+ }
81
+ } catch (error) {
82
+ if ((error as Error).name !== "AbortError") {
83
+ console.error("Search error:", error);
84
+ }
85
+ } finally {
86
+ setIsLoading(false);
87
+ }
88
+ }, 200);
89
+
90
+ return () => {
91
+ clearTimeout(timeoutId);
92
+ abortController.abort();
93
+ };
94
+ }, [query]);
95
+
96
+ // Keyboard shortcut to focus search
97
+ useEffect(() => {
98
+ const handleKeyDown = (e: KeyboardEvent) => {
99
+ if (e.key === "/" && !isInputFocused()) {
100
+ e.preventDefault();
101
+ inputRef.current?.focus();
102
+ }
103
+ };
104
+
105
+ function isInputFocused() {
106
+ const active = document.activeElement;
107
+ return (
108
+ active instanceof HTMLInputElement ||
109
+ active instanceof HTMLTextAreaElement ||
110
+ active?.getAttribute("contenteditable") === "true"
111
+ );
112
+ }
113
+
114
+ document.addEventListener("keydown", handleKeyDown);
115
+ return () => document.removeEventListener("keydown", handleKeyDown);
116
+ }, []);
117
+
118
+ // Close on click outside
119
+ useEffect(() => {
120
+ const handleClickOutside = (e: MouseEvent) => {
121
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
122
+ setIsOpen(false);
123
+ }
124
+ };
125
+
126
+ document.addEventListener("mousedown", handleClickOutside);
127
+ return () => document.removeEventListener("mousedown", handleClickOutside);
128
+ }, []);
129
+
130
+ const handleKeyDown = useCallback(
131
+ (e: React.KeyboardEvent) => {
132
+ switch (e.key) {
133
+ case "Escape":
134
+ setIsOpen(false);
135
+ setQuery("");
136
+ inputRef.current?.blur();
137
+ break;
138
+ case "ArrowDown":
139
+ e.preventDefault();
140
+ setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
141
+ break;
142
+ case "ArrowUp":
143
+ e.preventDefault();
144
+ setSelectedIndex((prev) => Math.max(prev - 1, -1));
145
+ break;
146
+ case "Enter":
147
+ if (selectedIndex >= 0 && results[selectedIndex]) {
148
+ e.preventDefault();
149
+ navigateToResult(results[selectedIndex]);
150
+ }
151
+ break;
152
+ }
153
+ },
154
+ [results, selectedIndex]
155
+ );
156
+
157
+ const navigateToResult = useCallback(
158
+ (result: SearchResult) => {
159
+ setIsOpen(false);
160
+ setQuery("");
161
+ router.push(result.url);
162
+ },
163
+ [router]
164
+ );
165
+
166
+ // Group results by type
167
+ const componentResults = results.filter((r) => r.type === "component");
168
+ const guideResults = results.filter((r) => r.type === "guide");
169
+
170
+ if (!enabled) {
171
+ return null;
172
+ }
173
+
174
+ return (
175
+ <div ref={containerRef} className={`search-input ${className}`}>
176
+ <div className="search-input__wrapper">
177
+ <span className="search-input__icon" aria-hidden="true">
178
+ {isLoading ? (
179
+ <svg
180
+ width="16"
181
+ height="16"
182
+ viewBox="0 0 16 16"
183
+ fill="none"
184
+ className="search-input__spinner"
185
+ aria-hidden="true"
186
+ >
187
+ <circle
188
+ cx="8"
189
+ cy="8"
190
+ r="6"
191
+ stroke="currentColor"
192
+ strokeWidth="2"
193
+ strokeDasharray="32"
194
+ strokeDashoffset="8"
195
+ />
196
+ </svg>
197
+ ) : (
198
+ <svg
199
+ width="16"
200
+ height="16"
201
+ viewBox="0 0 16 16"
202
+ fill="none"
203
+ stroke="currentColor"
204
+ strokeWidth="2"
205
+ aria-hidden="true"
206
+ >
207
+ <circle cx="6.5" cy="6.5" r="5.5" />
208
+ <path d="M10.5 10.5L15 15" />
209
+ </svg>
210
+ )}
211
+ </span>
212
+ <input
213
+ ref={inputRef}
214
+ type="search"
215
+ className="search-input__field"
216
+ placeholder={placeholder}
217
+ value={query}
218
+ onChange={(e) => {
219
+ setQuery(e.target.value);
220
+ setSelectedIndex(-1);
221
+ }}
222
+ onFocus={() => setIsOpen(true)}
223
+ onKeyDown={handleKeyDown}
224
+ aria-label="Search documentation"
225
+ aria-expanded={isOpen && results.length > 0}
226
+ aria-controls="search-results"
227
+ aria-activedescendant={selectedIndex >= 0 ? `search-result-${selectedIndex}` : undefined}
228
+ role="combobox"
229
+ autoComplete="off"
230
+ />
231
+ <span className="search-input__shortcut" aria-hidden="true">
232
+ /
233
+ </span>
234
+ </div>
235
+
236
+ {isOpen && query.length > 0 && (
237
+ <div
238
+ id="search-results"
239
+ className="search-input__dropdown"
240
+ role="listbox"
241
+ aria-label="Search results"
242
+ tabIndex={-1}
243
+ >
244
+ {results.length === 0 && !isLoading && (
245
+ <div className="search-input__empty">
246
+ No results found for &ldquo;{query}&rdquo;
247
+ </div>
248
+ )}
249
+
250
+ {componentResults.length > 0 && (
251
+ <div className="search-input__group">
252
+ <div className="search-input__group-label">Components</div>
253
+ {componentResults.map((result, idx) => {
254
+ const globalIndex = idx;
255
+ return (
256
+ <button
257
+ type="button"
258
+ key={result.id}
259
+ id={`search-result-${globalIndex}`}
260
+ className={`search-input__result ${selectedIndex === globalIndex ? "search-input__result--selected" : ""}`}
261
+ onClick={() => navigateToResult(result)}
262
+ onMouseEnter={() => setSelectedIndex(globalIndex)}
263
+ role="option"
264
+ aria-selected={selectedIndex === globalIndex}
265
+ >
266
+ <span className="search-input__result-icon">
267
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
268
+ <rect x="1" y="1" width="5" height="5" rx="1" />
269
+ <rect x="8" y="1" width="5" height="5" rx="1" />
270
+ <rect x="1" y="8" width="5" height="5" rx="1" />
271
+ <rect x="8" y="8" width="5" height="5" rx="1" />
272
+ </svg>
273
+ </span>
274
+ <span className="search-input__result-content">
275
+ <span className="search-input__result-title">{result.title}</span>
276
+ {result.description && (
277
+ <span className="search-input__result-description">
278
+ {result.description}
279
+ </span>
280
+ )}
281
+ </span>
282
+ {result.status && (
283
+ <span className={`search-input__result-status search-input__result-status--${result.status}`}>
284
+ {result.status}
285
+ </span>
286
+ )}
287
+ </button>
288
+ );
289
+ })}
290
+ </div>
291
+ )}
292
+
293
+ {guideResults.length > 0 && (
294
+ <div className="search-input__group">
295
+ <div className="search-input__group-label">Guides</div>
296
+ {guideResults.map((result, idx) => {
297
+ const globalIndex = componentResults.length + idx;
298
+ return (
299
+ <button
300
+ type="button"
301
+ key={result.id}
302
+ id={`search-result-${globalIndex}`}
303
+ className={`search-input__result ${selectedIndex === globalIndex ? "search-input__result--selected" : ""}`}
304
+ onClick={() => navigateToResult(result)}
305
+ onMouseEnter={() => setSelectedIndex(globalIndex)}
306
+ role="option"
307
+ aria-selected={selectedIndex === globalIndex}
308
+ >
309
+ <span className="search-input__result-icon">
310
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
311
+ <path d="M2 2h10v10H2V2zm1 1v8h8V3H3z" />
312
+ <path d="M4 5h6M4 7h6M4 9h4" stroke="currentColor" strokeWidth="1" />
313
+ </svg>
314
+ </span>
315
+ <span className="search-input__result-content">
316
+ <span className="search-input__result-title">{result.title}</span>
317
+ {result.description && (
318
+ <span className="search-input__result-description">
319
+ {result.description}
320
+ </span>
321
+ )}
322
+ </span>
323
+ </button>
324
+ );
325
+ })}
326
+ </div>
327
+ )}
328
+ </div>
329
+ )}
330
+
331
+ <style jsx>{`
332
+ .search-input {
333
+ position: relative;
334
+ }
335
+
336
+ .search-input__wrapper {
337
+ display: flex;
338
+ align-items: center;
339
+ gap: 0.5rem;
340
+ padding: 0.5rem 0.75rem;
341
+ background: var(--ds-color-background-subtle, #f5f5f5);
342
+ border: 1px solid var(--ds-color-border-default, #e5e5e5);
343
+ border-radius: 6px;
344
+ transition: border-color 0.15s, box-shadow 0.15s;
345
+ }
346
+
347
+ .search-input__wrapper:focus-within {
348
+ border-color: var(--ds-brand-primary, #0066cc);
349
+ box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
350
+ }
351
+
352
+ .search-input__icon {
353
+ color: var(--ds-color-foreground-muted, #666);
354
+ flex-shrink: 0;
355
+ }
356
+
357
+ .search-input__spinner {
358
+ animation: spin 1s linear infinite;
359
+ }
360
+
361
+ @keyframes spin {
362
+ from { transform: rotate(0deg); }
363
+ to { transform: rotate(360deg); }
364
+ }
365
+
366
+ .search-input__field {
367
+ flex: 1;
368
+ border: none;
369
+ background: transparent;
370
+ font-size: 0.875rem;
371
+ color: var(--ds-color-foreground-default, #1a1a1a);
372
+ outline: none;
373
+ min-width: 150px;
374
+ }
375
+
376
+ .search-input__field::placeholder {
377
+ color: var(--ds-color-foreground-muted, #666);
378
+ }
379
+
380
+ .search-input__shortcut {
381
+ padding: 0.125rem 0.375rem;
382
+ font-size: 0.75rem;
383
+ font-family: monospace;
384
+ background: var(--ds-color-background-surface, #fff);
385
+ border: 1px solid var(--ds-color-border-default, #e5e5e5);
386
+ border-radius: 4px;
387
+ color: var(--ds-color-foreground-muted, #666);
388
+ }
389
+
390
+ .search-input__dropdown {
391
+ position: absolute;
392
+ top: 100%;
393
+ left: 0;
394
+ right: 0;
395
+ margin-top: 0.5rem;
396
+ background: var(--ds-color-background-surface, #fff);
397
+ border: 1px solid var(--ds-color-border-default, #e5e5e5);
398
+ border-radius: 8px;
399
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
400
+ z-index: 1000;
401
+ max-height: 400px;
402
+ overflow-y: auto;
403
+ }
404
+
405
+ .search-input__empty {
406
+ padding: 1rem;
407
+ color: var(--ds-color-foreground-muted, #666);
408
+ font-size: 0.875rem;
409
+ text-align: center;
410
+ }
411
+
412
+ .search-input__group {
413
+ padding: 0.5rem 0;
414
+ }
415
+
416
+ .search-input__group:not(:last-child) {
417
+ border-bottom: 1px solid var(--ds-color-border-default, #e5e5e5);
418
+ }
419
+
420
+ .search-input__group-label {
421
+ padding: 0.25rem 0.75rem;
422
+ font-size: 0.75rem;
423
+ font-weight: 600;
424
+ text-transform: uppercase;
425
+ letter-spacing: 0.05em;
426
+ color: var(--ds-color-foreground-muted, #666);
427
+ }
428
+
429
+ .search-input__result {
430
+ display: flex;
431
+ align-items: flex-start;
432
+ gap: 0.75rem;
433
+ width: 100%;
434
+ padding: 0.5rem 0.75rem;
435
+ background: transparent;
436
+ border: none;
437
+ cursor: pointer;
438
+ text-align: left;
439
+ }
440
+
441
+ .search-input__result:hover,
442
+ .search-input__result--selected {
443
+ background: var(--ds-color-background-subtle, #f5f5f5);
444
+ }
445
+
446
+ .search-input__result-icon {
447
+ flex-shrink: 0;
448
+ width: 20px;
449
+ height: 20px;
450
+ display: flex;
451
+ align-items: center;
452
+ justify-content: center;
453
+ color: var(--ds-color-foreground-muted, #666);
454
+ }
455
+
456
+ .search-input__result-content {
457
+ flex: 1;
458
+ min-width: 0;
459
+ }
460
+
461
+ .search-input__result-title {
462
+ display: block;
463
+ font-size: 0.875rem;
464
+ font-weight: 500;
465
+ color: var(--ds-color-foreground-default, #1a1a1a);
466
+ }
467
+
468
+ .search-input__result-description {
469
+ display: block;
470
+ font-size: 0.75rem;
471
+ color: var(--ds-color-foreground-muted, #666);
472
+ white-space: nowrap;
473
+ overflow: hidden;
474
+ text-overflow: ellipsis;
475
+ }
476
+
477
+ .search-input__result-status {
478
+ flex-shrink: 0;
479
+ padding: 0.125rem 0.375rem;
480
+ font-size: 0.625rem;
481
+ font-weight: 500;
482
+ text-transform: uppercase;
483
+ border-radius: 4px;
484
+ }
485
+
486
+ .search-input__result-status--stable {
487
+ background: var(--ds-color-success-subtle, #dcfce7);
488
+ color: var(--ds-color-success, #16a34a);
489
+ }
490
+
491
+ .search-input__result-status--beta {
492
+ background: var(--ds-color-warning-subtle, #fef3c7);
493
+ color: var(--ds-color-warning, #d97706);
494
+ }
495
+
496
+ .search-input__result-status--experimental {
497
+ background: var(--ds-color-info-subtle, #dbeafe);
498
+ color: var(--ds-color-info, #2563eb);
499
+ }
500
+
501
+ .search-input__result-status--deprecated {
502
+ background: var(--ds-color-error-subtle, #fee2e2);
503
+ color: var(--ds-color-error, #dc2626);
504
+ }
505
+ `}</style>
506
+ </div>
507
+ );
508
+ }
@@ -0,0 +1,35 @@
1
+ import Script from "next/script";
2
+
3
+ // Theme init script - runs before paint to prevent flash
4
+ const themeInitScript = `(function(){
5
+ var root = document.documentElement;
6
+ var mode = (function() {
7
+ try {
8
+ var stored = localStorage.getItem('ds-mode');
9
+ if (stored) return stored;
10
+ } catch(e) {}
11
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
12
+ if (window.matchMedia('(prefers-contrast: more)').matches) return 'high-contrast';
13
+ return 'light';
14
+ })();
15
+ root.dataset.mode = mode;
16
+ try {
17
+ var brand = localStorage.getItem('ds-brand');
18
+ if (brand) root.dataset.brand = brand;
19
+ } catch(e) {}
20
+ })();`;
21
+
22
+ /**
23
+ * Theme initialization script component
24
+ * Uses dangerouslySetInnerHTML to inject the script that runs before paint
25
+ * This is safe because the script content is a static string defined in this file
26
+ */
27
+ export function ThemeInitScript() {
28
+ return (
29
+ <Script
30
+ id="theme-init"
31
+ strategy="beforeInteractive"
32
+ dangerouslySetInnerHTML={{ __html: themeInitScript }}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,166 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ type ThemeMode = "light" | "dark" | "high-contrast";
6
+
7
+ interface ThemeSwitcherProps {
8
+ brands?: string[];
9
+ }
10
+
11
+ /**
12
+ * Theme Switcher Component
13
+ * Allows users to switch between modes and brands
14
+ */
15
+ export function ThemeSwitcher({ brands = ["default", "acme"] }: ThemeSwitcherProps) {
16
+ const [mode, setModeState] = useState<ThemeMode>("light");
17
+ const [brand, setBrandState] = useState<string>("default");
18
+
19
+ // Initialize from document on mount
20
+ useEffect(() => {
21
+ const currentMode = document.documentElement.dataset.mode as ThemeMode;
22
+ const currentBrand = document.documentElement.dataset.brand;
23
+
24
+ if (currentMode) setModeState(currentMode);
25
+ if (currentBrand) setBrandState(currentBrand);
26
+ }, []);
27
+
28
+ const handleModeChange = (newMode: ThemeMode) => {
29
+ setModeState(newMode);
30
+ document.documentElement.dataset.mode = newMode;
31
+ try {
32
+ localStorage.setItem("ds-mode", newMode);
33
+ } catch {
34
+ // localStorage not available
35
+ }
36
+ };
37
+
38
+ const handleBrandChange = (newBrand: string) => {
39
+ setBrandState(newBrand);
40
+ if (newBrand === "default") {
41
+ delete document.documentElement.dataset.brand;
42
+ try {
43
+ localStorage.removeItem("ds-brand");
44
+ } catch {
45
+ // localStorage not available
46
+ }
47
+ } else {
48
+ document.documentElement.dataset.brand = newBrand;
49
+ try {
50
+ localStorage.setItem("ds-brand", newBrand);
51
+ } catch {
52
+ // localStorage not available
53
+ }
54
+ }
55
+ };
56
+
57
+ return (
58
+ <div className="theme-switcher">
59
+ <fieldset className="theme-switcher-section">
60
+ <legend className="theme-switcher-label">Mode</legend>
61
+ <div className="theme-switcher-buttons">
62
+ <button
63
+ type="button"
64
+ className={mode === "light" ? "active" : ""}
65
+ onClick={() => handleModeChange("light")}
66
+ aria-pressed={mode === "light"}
67
+ >
68
+ ☀️ Light
69
+ </button>
70
+ <button
71
+ type="button"
72
+ className={mode === "dark" ? "active" : ""}
73
+ onClick={() => handleModeChange("dark")}
74
+ aria-pressed={mode === "dark"}
75
+ >
76
+ 🌙 Dark
77
+ </button>
78
+ <button
79
+ type="button"
80
+ className={mode === "high-contrast" ? "active" : ""}
81
+ onClick={() => handleModeChange("high-contrast")}
82
+ aria-pressed={mode === "high-contrast"}
83
+ >
84
+ ◐ High Contrast
85
+ </button>
86
+ </div>
87
+ </fieldset>
88
+
89
+ {brands.length > 1 && (
90
+ <div className="theme-switcher-section">
91
+ <label htmlFor="brand-select">Brand</label>
92
+ <select
93
+ id="brand-select"
94
+ value={brand}
95
+ onChange={(e) => handleBrandChange(e.target.value)}
96
+ >
97
+ {brands.map((b) => (
98
+ <option key={b} value={b}>
99
+ {b.charAt(0).toUpperCase() + b.slice(1)}
100
+ </option>
101
+ ))}
102
+ </select>
103
+ </div>
104
+ )}
105
+
106
+ <style jsx>{`
107
+ .theme-switcher {
108
+ display: flex;
109
+ gap: 1rem;
110
+ align-items: center;
111
+ padding: 0.5rem;
112
+ }
113
+
114
+ .theme-switcher-section {
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 0.5rem;
118
+ }
119
+
120
+ .theme-switcher-section label {
121
+ font-size: 0.875rem;
122
+ font-weight: 500;
123
+ color: var(--color-text-secondary, #6b7280);
124
+ }
125
+
126
+ .theme-switcher-buttons {
127
+ display: flex;
128
+ gap: 0.25rem;
129
+ background: var(--color-background-subtle, #f3f4f6);
130
+ padding: 0.25rem;
131
+ border-radius: var(--radius-md, 6px);
132
+ }
133
+
134
+ .theme-switcher-buttons button {
135
+ padding: 0.375rem 0.75rem;
136
+ font-size: 0.875rem;
137
+ border: none;
138
+ background: transparent;
139
+ color: var(--color-text-primary, #111827);
140
+ border-radius: var(--radius-sm, 4px);
141
+ cursor: pointer;
142
+ transition: background-color 0.15s;
143
+ }
144
+
145
+ .theme-switcher-buttons button:hover {
146
+ background: var(--color-background-elevated, #e5e7eb);
147
+ }
148
+
149
+ .theme-switcher-buttons button.active {
150
+ background: var(--color-background-surface, #ffffff);
151
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
152
+ }
153
+
154
+ .theme-switcher-section select {
155
+ padding: 0.375rem 0.75rem;
156
+ font-size: 0.875rem;
157
+ border: 1px solid var(--color-border-default, #e5e7eb);
158
+ border-radius: var(--radius-md, 6px);
159
+ background: var(--color-background-surface, #ffffff);
160
+ color: var(--color-text-primary, #111827);
161
+ cursor: pointer;
162
+ }
163
+ `}</style>
164
+ </div>
165
+ );
166
+ }