@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.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/app/accessibility/CategoryFilter.tsx +123 -0
- package/app/accessibility/ConformanceTable.tsx +109 -0
- package/app/accessibility/StatusBadge.tsx +47 -0
- package/app/accessibility/[component]/page.tsx +166 -0
- package/app/accessibility/page.tsx +207 -0
- package/app/api/search/route.ts +241 -0
- package/app/components/[id]/page.tsx +316 -0
- package/app/edition-upgrade/page.tsx +76 -0
- package/app/guides/[id]/page.tsx +67 -0
- package/app/layout.tsx +93 -0
- package/app/page.tsx +29 -0
- package/components/branding/header.tsx +82 -0
- package/components/branding/logo.tsx +54 -0
- package/components/feedback/feedback-widget.tsx +263 -0
- package/components/live-example.tsx +477 -0
- package/components/mdx/edition.tsx +149 -0
- package/components/mdx-renderer.tsx +90 -0
- package/components/nav-sidebar.tsx +269 -0
- package/components/search/search-input.tsx +508 -0
- package/components/theme-init-script.tsx +35 -0
- package/components/theme-switcher.tsx +166 -0
- package/components/tokens-used.tsx +135 -0
- package/components/upgrade/upgrade-prompt.tsx +141 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +751 -0
- package/package.json +66 -0
- package/styles/globals.css +613 -0
|
@@ -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 “{query}”
|
|
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
|
+
}
|