@specglass/theme-default 0.0.10 → 0.0.11
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/dist/__tests__/design-tokens.test.d.ts +1 -0
- package/dist/__tests__/design-tokens.test.js +107 -0
- package/dist/islands/CopyButton.js +2 -6
- package/dist/islands/LanguageToggle.d.ts +16 -0
- package/dist/islands/LanguageToggle.js +88 -0
- package/dist/islands/SearchPalette.js +57 -8
- package/dist/islands/ThemeToggle.js +1 -1
- package/dist/scripts/code-block-enhancer.js +6 -3
- package/dist/themes/notdiamond-dark.json +168 -0
- package/dist/themes/notdiamond-light.json +168 -0
- package/dist/ui/command.js +2 -2
- package/dist/ui/dialog.js +2 -2
- package/dist/utils/shiki.d.ts +1 -1
- package/dist/utils/shiki.js +5 -3
- package/package.json +5 -3
- package/src/components/ApiAuth.astro +31 -4
- package/src/components/ApiEndpoint.astro +67 -44
- package/src/components/ApiNavigation.astro +8 -11
- package/src/components/ApiParameters.astro +113 -162
- package/src/components/ApiResponse.astro +1 -1
- package/src/components/Callout.astro +59 -18
- package/src/components/Card.astro +4 -4
- package/src/components/CodeBlock.astro +7 -7
- package/src/components/CodeBlockGroup.astro +3 -3
- package/src/components/CodeExample.astro +183 -0
- package/src/components/EditLink.astro +53 -0
- package/src/components/Footer.astro +87 -25
- package/src/components/Header.astro +63 -7
- package/src/components/Sidebar.astro +43 -11
- package/src/components/TableOfContents.astro +5 -5
- package/src/components/Tabs.astro +51 -20
- package/src/islands/CopyButton.tsx +36 -34
- package/src/islands/LanguageToggle.tsx +214 -0
- package/src/islands/SearchPalette.tsx +121 -39
- package/src/islands/ThemeToggle.tsx +45 -48
- package/src/layouts/ApiReferencePage.astro +67 -56
- package/src/layouts/DocPage.astro +32 -27
- package/src/layouts/LandingPage.astro +348 -27
- package/src/scripts/code-block-enhancer.ts +8 -3
- package/src/styles/global.css +388 -59
- package/src/themes/notdiamond-dark.json +168 -0
- package/src/themes/notdiamond-light.json +168 -0
- package/src/ui/command.tsx +1 -2
- package/src/ui/dialog.tsx +8 -5
- package/src/utils/shiki.ts +5 -3
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Children must be <TabItem> components. The first tab is active by default.
|
|
6
6
|
* Uses inline <script> for tab switching — NOT a React island.
|
|
7
|
+
* Features a smooth animated underline indicator on the active tab.
|
|
7
8
|
*
|
|
8
9
|
* Accessible: role="tablist", role="tab", role="tabpanel", aria-selected,
|
|
9
10
|
* aria-controls, arrow key navigation.
|
|
@@ -20,7 +21,8 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
20
21
|
|
|
21
22
|
<div class="tabs-container" data-tabs-id={tabGroupId}>
|
|
22
23
|
<div role="tablist" class="tabs-header" aria-label="Tab options">
|
|
23
|
-
<!-- Tab buttons are generated client-side from TabItem children -->
|
|
24
|
+
<!-- Tab buttons and indicator are generated client-side from TabItem children -->
|
|
25
|
+
<span class="tabs-indicator" aria-hidden="true"></span>
|
|
24
26
|
</div>
|
|
25
27
|
<div class="tabs-content">
|
|
26
28
|
<slot />
|
|
@@ -34,15 +36,29 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
34
36
|
*/
|
|
35
37
|
.tabs-container {
|
|
36
38
|
margin: 1.5rem 0;
|
|
37
|
-
border: 1px solid var(--tabs-border,
|
|
39
|
+
border: 1px solid var(--tabs-border, oklch(0.92 0.004 286.32));
|
|
38
40
|
border-radius: 0.5rem;
|
|
39
41
|
overflow: hidden;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
.tabs-header {
|
|
43
45
|
display: flex;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
position: relative;
|
|
47
|
+
border-bottom: 1px solid var(--tabs-border, oklch(0.92 0.004 286.32));
|
|
48
|
+
background-color: var(--tabs-header-bg, oklch(0.967 0.001 286.375));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Animated underline indicator */
|
|
52
|
+
.tabs-indicator {
|
|
53
|
+
position: absolute;
|
|
54
|
+
bottom: -1px;
|
|
55
|
+
height: 2px;
|
|
56
|
+
background-color: var(--color-primary, oklch(0.82 0.24 135));
|
|
57
|
+
transition:
|
|
58
|
+
left 150ms ease,
|
|
59
|
+
width 150ms ease;
|
|
60
|
+
pointer-events: none;
|
|
61
|
+
border-radius: 1px;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
.tabs-header [role="tab"] {
|
|
@@ -52,26 +68,21 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
52
68
|
cursor: pointer;
|
|
53
69
|
font-size: 0.875rem;
|
|
54
70
|
font-weight: 500;
|
|
55
|
-
color: var(--tabs-text,
|
|
56
|
-
|
|
57
|
-
transition:
|
|
58
|
-
color 0.15s,
|
|
59
|
-
border-color 0.15s;
|
|
71
|
+
color: var(--tabs-text, oklch(0.552 0.016 285.938));
|
|
72
|
+
transition: color 0.15s;
|
|
60
73
|
white-space: nowrap;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
.tabs-header [role="tab"]:hover {
|
|
64
|
-
color: var(--tabs-text-hover,
|
|
77
|
+
color: var(--tabs-text-hover, oklch(0.374 0.02 264));
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
.tabs-header [role="tab"][aria-selected="true"] {
|
|
68
|
-
color: var(--tabs-text-active,
|
|
69
|
-
border-bottom-color: var(--tabs-active-border, #3b82f6);
|
|
70
|
-
background-color: var(--tabs-active-bg, transparent);
|
|
81
|
+
color: var(--tabs-text-active, oklch(0.141 0.005 285.823));
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
.tabs-header [role="tab"]:focus-visible {
|
|
74
|
-
outline: 2px solid var(--
|
|
85
|
+
outline: 2px solid var(--color-primary, oklch(0.82 0.24 135));
|
|
75
86
|
outline-offset: -2px;
|
|
76
87
|
}
|
|
77
88
|
|
|
@@ -89,19 +100,24 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
89
100
|
|
|
90
101
|
/* Dark mode — remap CSS vars to dark surface colors */
|
|
91
102
|
.dark .tabs-container {
|
|
92
|
-
--tabs-border: oklch(0.
|
|
93
|
-
--tabs-header-bg: oklch(0.
|
|
94
|
-
--tabs-text: oklch(0.
|
|
103
|
+
--tabs-border: oklch(0.274 0.006 286.033);
|
|
104
|
+
--tabs-header-bg: oklch(0.185 0.025 265);
|
|
105
|
+
--tabs-text: oklch(0.705 0.015 286.067);
|
|
95
106
|
--tabs-text-hover: oklch(0.85 0.01 264);
|
|
96
107
|
--tabs-text-active: oklch(0.95 0.005 264);
|
|
97
108
|
}
|
|
98
109
|
</style>
|
|
99
110
|
|
|
100
111
|
<script>
|
|
101
|
-
|
|
112
|
+
function initTabs() {
|
|
102
113
|
document.querySelectorAll<HTMLDivElement>("[data-tabs-id]").forEach((container) => {
|
|
114
|
+
// Skip already-initialized containers
|
|
115
|
+
if (container.dataset.tabsInit) return;
|
|
116
|
+
container.dataset.tabsInit = "true";
|
|
117
|
+
|
|
103
118
|
const tablist = container.querySelector<HTMLDivElement>('[role="tablist"]');
|
|
104
119
|
const contentArea = container.querySelector<HTMLDivElement>(".tabs-content");
|
|
120
|
+
const indicator = container.querySelector<HTMLSpanElement>(".tabs-indicator");
|
|
105
121
|
if (!tablist || !contentArea) return;
|
|
106
122
|
|
|
107
123
|
const tabItems = contentArea.querySelectorAll<HTMLDivElement>(".tab-item");
|
|
@@ -123,7 +139,7 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
123
139
|
button.setAttribute("aria-selected", index === 0 ? "true" : "false");
|
|
124
140
|
button.tabIndex = index === 0 ? 0 : -1;
|
|
125
141
|
button.textContent = label;
|
|
126
|
-
tablist.
|
|
142
|
+
tablist.insertBefore(button, indicator);
|
|
127
143
|
|
|
128
144
|
// Set up panel
|
|
129
145
|
item.id = panelId;
|
|
@@ -139,6 +155,14 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
139
155
|
// Tab switching
|
|
140
156
|
const tabs = tablist.querySelectorAll<HTMLButtonElement>('[role="tab"]');
|
|
141
157
|
|
|
158
|
+
/** Move the underline indicator to the active tab */
|
|
159
|
+
function moveIndicator(index: number) {
|
|
160
|
+
if (!indicator || !tabs[index]) return;
|
|
161
|
+
const tab = tabs[index];
|
|
162
|
+
indicator.style.left = `${tab.offsetLeft}px`;
|
|
163
|
+
indicator.style.width = `${tab.offsetWidth}px`;
|
|
164
|
+
}
|
|
165
|
+
|
|
142
166
|
function activateTab(index: number) {
|
|
143
167
|
tabs.forEach((tab, i) => {
|
|
144
168
|
const isActive = i === index;
|
|
@@ -148,6 +172,7 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
148
172
|
tabItems.forEach((item, i) => {
|
|
149
173
|
item.classList.toggle("active", i === index);
|
|
150
174
|
});
|
|
175
|
+
moveIndicator(index);
|
|
151
176
|
}
|
|
152
177
|
|
|
153
178
|
tabs.forEach((tab, index) => {
|
|
@@ -180,6 +205,12 @@ const tabGroupId = `tabs-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
180
205
|
tabs[nextIndex].focus();
|
|
181
206
|
}
|
|
182
207
|
});
|
|
208
|
+
|
|
209
|
+
// Initial indicator position (after DOM layout)
|
|
210
|
+
requestAnimationFrame(() => moveIndicator(0));
|
|
183
211
|
});
|
|
184
|
-
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
initTabs();
|
|
215
|
+
document.addEventListener("astro:after-swap", initTabs);
|
|
185
216
|
</script>
|
|
@@ -44,7 +44,7 @@ export function CopyButton({ code }: CopyButtonProps) {
|
|
|
44
44
|
onClick={handleCopy}
|
|
45
45
|
aria-label={copied ? "Copied!" : "Copy code"}
|
|
46
46
|
title={copied ? "Copied!" : "Copy code"}
|
|
47
|
-
className="sg-copy-btn"
|
|
47
|
+
className={`sg-copy-btn${copied ? " sg-copy-btn-copied" : ""}`}
|
|
48
48
|
style={{
|
|
49
49
|
position: "absolute",
|
|
50
50
|
top: "0.5rem",
|
|
@@ -63,39 +63,41 @@ export function CopyButton({ code }: CopyButtonProps) {
|
|
|
63
63
|
transition: "opacity 150ms ease, background-color 150ms ease, color 150ms ease",
|
|
64
64
|
}}
|
|
65
65
|
>
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
66
|
+
{/* Copy icon (visible by default) */}
|
|
67
|
+
<svg
|
|
68
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
69
|
+
width="16"
|
|
70
|
+
height="16"
|
|
71
|
+
viewBox="0 0 24 24"
|
|
72
|
+
fill="none"
|
|
73
|
+
stroke="currentColor"
|
|
74
|
+
strokeWidth="2"
|
|
75
|
+
strokeLinecap="round"
|
|
76
|
+
strokeLinejoin="round"
|
|
77
|
+
className="sg-copy-icon"
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
>
|
|
80
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
81
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
82
|
+
</svg>
|
|
83
|
+
{/* Checkmark icon (visible when copied) */}
|
|
84
|
+
<svg
|
|
85
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
86
|
+
width="16"
|
|
87
|
+
height="16"
|
|
88
|
+
viewBox="0 0 24 24"
|
|
89
|
+
fill="none"
|
|
90
|
+
stroke="currentColor"
|
|
91
|
+
strokeWidth="2"
|
|
92
|
+
strokeLinecap="round"
|
|
93
|
+
strokeLinejoin="round"
|
|
94
|
+
className="sg-copy-check"
|
|
95
|
+
style={{ color: "#22c55e" }}
|
|
96
|
+
aria-hidden="true"
|
|
97
|
+
>
|
|
98
|
+
<polyline points="20 6 9 17 4 12" />
|
|
99
|
+
</svg>
|
|
99
100
|
</button>
|
|
100
101
|
);
|
|
101
102
|
}
|
|
103
|
+
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* LanguageToggle — Global language selector for code examples.
|
|
6
|
+
*
|
|
7
|
+
* Broadcasts the same `ndocs-language-change` CustomEvent that CodeTabs
|
|
8
|
+
* uses, so all code tab groups on the page (and future pages) sync to
|
|
9
|
+
* the selected language. Persists preference in localStorage.
|
|
10
|
+
*
|
|
11
|
+
* Designed for `client:idle` hydration in the site header.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Must match CodeTabs constants
|
|
15
|
+
const LANGUAGE_CHANGE_EVENT = "ndocs-language-change";
|
|
16
|
+
const DEFAULT_SYNC_KEY = "sdk-language";
|
|
17
|
+
const STORAGE_KEY = `ndocs-preferred-${DEFAULT_SYNC_KEY}`;
|
|
18
|
+
|
|
19
|
+
/** A selectable language option */
|
|
20
|
+
interface LanguageOption {
|
|
21
|
+
/** Display label (e.g., "Python") */
|
|
22
|
+
label: string;
|
|
23
|
+
/** Language key matching CodeTabs (e.g., "python") */
|
|
24
|
+
value: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_LANGUAGES: LanguageOption[] = [
|
|
28
|
+
{ label: "Python", value: "python" },
|
|
29
|
+
{ label: "Node.js", value: "nodejs" },
|
|
30
|
+
{ label: "Go", value: "go" },
|
|
31
|
+
{ label: "cURL", value: "curl" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
interface LanguageToggleProps {
|
|
35
|
+
/** Available languages. Defaults to Python/Node.js/Go/cURL */
|
|
36
|
+
languages?: LanguageOption[];
|
|
37
|
+
/** Sync key — must match CodeTabs syncKey. Default: "sdk-language" */
|
|
38
|
+
syncKey?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function LanguageToggle({
|
|
42
|
+
languages = DEFAULT_LANGUAGES,
|
|
43
|
+
syncKey = DEFAULT_SYNC_KEY,
|
|
44
|
+
}: LanguageToggleProps) {
|
|
45
|
+
const storageKey = `ndocs-preferred-${syncKey}`;
|
|
46
|
+
const [selected, setSelected] = useState(languages[0]?.value ?? "");
|
|
47
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
48
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
49
|
+
|
|
50
|
+
// Restore stored preference after hydration
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
try {
|
|
53
|
+
const stored = localStorage.getItem(storageKey);
|
|
54
|
+
if (stored && languages.some((l) => l.value === stored)) {
|
|
55
|
+
setSelected(stored);
|
|
56
|
+
}
|
|
57
|
+
} catch { /* noop */ }
|
|
58
|
+
}, [storageKey, languages]);
|
|
59
|
+
|
|
60
|
+
// Listen for external language changes
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const handler = (e: Event) => {
|
|
63
|
+
const detail = (e as CustomEvent).detail;
|
|
64
|
+
if (detail?.syncKey === syncKey && languages.some((l) => l.value === detail.language)) {
|
|
65
|
+
setSelected(detail.language);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
window.addEventListener(LANGUAGE_CHANGE_EVENT, handler);
|
|
69
|
+
return () => window.removeEventListener(LANGUAGE_CHANGE_EVENT, handler);
|
|
70
|
+
}, [syncKey, languages]);
|
|
71
|
+
|
|
72
|
+
// Close dropdown on outside click
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!isOpen) return;
|
|
75
|
+
const handler = (e: MouseEvent) => {
|
|
76
|
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
77
|
+
setIsOpen(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
document.addEventListener("mousedown", handler);
|
|
81
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
82
|
+
}, [isOpen]);
|
|
83
|
+
|
|
84
|
+
// Close on Escape
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!isOpen) return;
|
|
87
|
+
const handler = (e: KeyboardEvent) => {
|
|
88
|
+
if (e.key === "Escape") setIsOpen(false);
|
|
89
|
+
};
|
|
90
|
+
document.addEventListener("keydown", handler);
|
|
91
|
+
return () => document.removeEventListener("keydown", handler);
|
|
92
|
+
}, [isOpen]);
|
|
93
|
+
|
|
94
|
+
const handleSelect = useCallback(
|
|
95
|
+
(value: string) => {
|
|
96
|
+
setSelected(value);
|
|
97
|
+
setIsOpen(false);
|
|
98
|
+
try {
|
|
99
|
+
localStorage.setItem(storageKey, value);
|
|
100
|
+
} catch { /* noop */ }
|
|
101
|
+
window.dispatchEvent(
|
|
102
|
+
new CustomEvent(LANGUAGE_CHANGE_EVENT, {
|
|
103
|
+
detail: { syncKey, language: value },
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
[storageKey, syncKey],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const selectedLabel = languages.find((l) => l.value === selected)?.label ?? selected;
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div ref={dropdownRef} className="relative">
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
117
|
+
className={cn(
|
|
118
|
+
"inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg",
|
|
119
|
+
"border border-border/50 bg-surface text-text-muted",
|
|
120
|
+
"hover:bg-surface-2 hover:text-text transition-colors duration-150",
|
|
121
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
122
|
+
)}
|
|
123
|
+
aria-label={`Language: ${selectedLabel}`}
|
|
124
|
+
aria-expanded={isOpen}
|
|
125
|
+
aria-haspopup="listbox"
|
|
126
|
+
>
|
|
127
|
+
<svg
|
|
128
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
129
|
+
width="12"
|
|
130
|
+
height="12"
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
strokeWidth="2"
|
|
135
|
+
strokeLinecap="round"
|
|
136
|
+
strokeLinejoin="round"
|
|
137
|
+
className="shrink-0"
|
|
138
|
+
aria-hidden="true"
|
|
139
|
+
>
|
|
140
|
+
<polyline points="16 18 22 12 16 6" />
|
|
141
|
+
<polyline points="8 6 2 12 8 18" />
|
|
142
|
+
</svg>
|
|
143
|
+
<span>{selectedLabel}</span>
|
|
144
|
+
<svg
|
|
145
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
146
|
+
width="10"
|
|
147
|
+
height="10"
|
|
148
|
+
viewBox="0 0 24 24"
|
|
149
|
+
fill="none"
|
|
150
|
+
stroke="currentColor"
|
|
151
|
+
strokeWidth="2.5"
|
|
152
|
+
strokeLinecap="round"
|
|
153
|
+
strokeLinejoin="round"
|
|
154
|
+
className={cn("shrink-0 transition-transform duration-150", isOpen && "rotate-180")}
|
|
155
|
+
aria-hidden="true"
|
|
156
|
+
>
|
|
157
|
+
<polyline points="6 9 12 15 18 9" />
|
|
158
|
+
</svg>
|
|
159
|
+
</button>
|
|
160
|
+
|
|
161
|
+
{isOpen && (
|
|
162
|
+
<div
|
|
163
|
+
role="listbox"
|
|
164
|
+
aria-label="Select language"
|
|
165
|
+
className={cn(
|
|
166
|
+
"absolute right-0 top-full mt-1.5 z-50 min-w-32",
|
|
167
|
+
"rounded-xl border border-border/50 bg-surface shadow-xl shadow-black/20",
|
|
168
|
+
"py-1 animate-in fade-in-0 zoom-in-95 duration-150",
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
{languages.map((lang) => (
|
|
172
|
+
<button
|
|
173
|
+
key={lang.value}
|
|
174
|
+
type="button"
|
|
175
|
+
role="option"
|
|
176
|
+
aria-selected={lang.value === selected}
|
|
177
|
+
className={cn(
|
|
178
|
+
"flex items-center w-full px-3 py-1.5 text-xs text-left rounded-md mx-auto",
|
|
179
|
+
"hover:bg-surface-2 transition-colors duration-150",
|
|
180
|
+
lang.value === selected
|
|
181
|
+
? "text-text font-medium"
|
|
182
|
+
: "text-text-muted",
|
|
183
|
+
)}
|
|
184
|
+
onClick={() => handleSelect(lang.value)}
|
|
185
|
+
>
|
|
186
|
+
{lang.value === selected && (
|
|
187
|
+
<svg
|
|
188
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
189
|
+
width="12"
|
|
190
|
+
height="12"
|
|
191
|
+
viewBox="0 0 24 24"
|
|
192
|
+
fill="none"
|
|
193
|
+
stroke="currentColor"
|
|
194
|
+
strokeWidth="2.5"
|
|
195
|
+
strokeLinecap="round"
|
|
196
|
+
strokeLinejoin="round"
|
|
197
|
+
className="mr-2 shrink-0"
|
|
198
|
+
aria-hidden="true"
|
|
199
|
+
>
|
|
200
|
+
<polyline points="20 6 9 17 4 12" />
|
|
201
|
+
</svg>
|
|
202
|
+
)}
|
|
203
|
+
{lang.value !== selected && <span className="w-[12px] mr-2" />}
|
|
204
|
+
{lang.label}
|
|
205
|
+
</button>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export { LanguageToggle };
|
|
214
|
+
export type { LanguageOption, LanguageToggleProps };
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CommandInput,
|
|
7
7
|
CommandList,
|
|
8
8
|
CommandEmpty,
|
|
9
|
+
CommandGroup,
|
|
9
10
|
CommandItem,
|
|
10
11
|
} from "../ui/command";
|
|
11
12
|
|
|
@@ -50,11 +51,50 @@ interface PagefindAPI {
|
|
|
50
51
|
|
|
51
52
|
const DEBOUNCE_MS = 150;
|
|
52
53
|
const MAX_RESULTS = 8;
|
|
54
|
+
const MAX_RECENT = 5;
|
|
55
|
+
const RECENT_STORAGE_KEY = "ndocs-recent-searches";
|
|
56
|
+
|
|
57
|
+
/** Load recent searches from localStorage */
|
|
58
|
+
function loadRecentSearches(): string[] {
|
|
59
|
+
try {
|
|
60
|
+
const stored = localStorage.getItem(RECENT_STORAGE_KEY);
|
|
61
|
+
if (stored) return JSON.parse(stored);
|
|
62
|
+
} catch { /* noop */ }
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Save a query to recent searches */
|
|
67
|
+
function saveRecentSearch(query: string) {
|
|
68
|
+
try {
|
|
69
|
+
const recents = loadRecentSearches().filter((q) => q !== query);
|
|
70
|
+
recents.unshift(query);
|
|
71
|
+
localStorage.setItem(
|
|
72
|
+
RECENT_STORAGE_KEY,
|
|
73
|
+
JSON.stringify(recents.slice(0, MAX_RECENT)),
|
|
74
|
+
);
|
|
75
|
+
} catch { /* noop */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Group results by section (first URL segment) */
|
|
79
|
+
function groupResults(results: PagefindResult[]): Map<string, PagefindResult[]> {
|
|
80
|
+
const groups = new Map<string, PagefindResult[]>();
|
|
81
|
+
for (const result of results) {
|
|
82
|
+
// Extract section from URL: /docs/getting-started → "Docs"
|
|
83
|
+
const segments = result.url.replace(/^\//, "").split("/");
|
|
84
|
+
const section = segments.length > 1
|
|
85
|
+
? segments[0].charAt(0).toUpperCase() + segments[0].slice(1).replace(/-/g, " ")
|
|
86
|
+
: "Pages";
|
|
87
|
+
if (!groups.has(section)) groups.set(section, []);
|
|
88
|
+
groups.get(section)!.push(result);
|
|
89
|
+
}
|
|
90
|
+
return groups;
|
|
91
|
+
}
|
|
53
92
|
|
|
54
93
|
export function SearchPalette() {
|
|
55
94
|
const [open, setOpen] = useState(false);
|
|
56
95
|
const [query, setQuery] = useState("");
|
|
57
96
|
const [results, setResults] = useState<PagefindResult[]>([]);
|
|
97
|
+
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
|
58
98
|
const [isLoading, setIsLoading] = useState(false);
|
|
59
99
|
const [isUnavailable, setIsUnavailable] = useState(false);
|
|
60
100
|
const pagefindRef = useRef<PagefindAPI | null>(null);
|
|
@@ -140,6 +180,9 @@ export function SearchPalette() {
|
|
|
140
180
|
// Reset state when closing
|
|
141
181
|
const handleOpenChange = useCallback((isOpen: boolean) => {
|
|
142
182
|
setOpen(isOpen);
|
|
183
|
+
if (isOpen) {
|
|
184
|
+
setRecentSearches(loadRecentSearches());
|
|
185
|
+
}
|
|
143
186
|
if (!isOpen) {
|
|
144
187
|
setTimeout(() => {
|
|
145
188
|
setQuery("");
|
|
@@ -151,10 +194,22 @@ export function SearchPalette() {
|
|
|
151
194
|
// Navigate to a search result
|
|
152
195
|
const handleSelect = useCallback(
|
|
153
196
|
(url: string) => {
|
|
197
|
+
if (query.trim()) {
|
|
198
|
+
saveRecentSearch(query.trim());
|
|
199
|
+
}
|
|
154
200
|
handleOpenChange(false);
|
|
155
201
|
window.location.href = url;
|
|
156
202
|
},
|
|
157
|
-
[handleOpenChange],
|
|
203
|
+
[handleOpenChange, query],
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Handle selecting a recent search
|
|
207
|
+
const handleRecentSelect = useCallback(
|
|
208
|
+
(recentQuery: string) => {
|
|
209
|
+
setQuery(recentQuery);
|
|
210
|
+
performSearch(recentQuery);
|
|
211
|
+
},
|
|
212
|
+
[performSearch],
|
|
158
213
|
);
|
|
159
214
|
|
|
160
215
|
// Cleanup debounce on unmount
|
|
@@ -173,9 +228,9 @@ export function SearchPalette() {
|
|
|
173
228
|
type="button"
|
|
174
229
|
onClick={() => setOpen(true)}
|
|
175
230
|
className={cn(
|
|
176
|
-
"inline-flex items-center gap-2 px-3 py-1.5 text-
|
|
177
|
-
"border border-border bg-surface text-text-muted",
|
|
178
|
-
"hover:bg-
|
|
231
|
+
"inline-flex items-center gap-2 px-3 py-1.5 text-[0.8125rem] rounded-lg",
|
|
232
|
+
"border border-border/50 bg-surface text-text-muted",
|
|
233
|
+
"hover:bg-surface-2 hover:text-text transition-colors",
|
|
179
234
|
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
|
180
235
|
)}
|
|
181
236
|
aria-label="Search documentation (⌘K)"
|
|
@@ -200,8 +255,8 @@ export function SearchPalette() {
|
|
|
200
255
|
<kbd
|
|
201
256
|
className={cn(
|
|
202
257
|
"hidden sm:inline-flex items-center gap-0.5",
|
|
203
|
-
"rounded border border-border bg-surface-
|
|
204
|
-
"font-mono text-[
|
|
258
|
+
"rounded border border-border/50 bg-surface-2 px-1.5 py-0.5",
|
|
259
|
+
"font-mono text-[0.625rem] text-text-muted",
|
|
205
260
|
)}
|
|
206
261
|
>
|
|
207
262
|
⌘K
|
|
@@ -241,44 +296,71 @@ export function SearchPalette() {
|
|
|
241
296
|
</CommandEmpty>
|
|
242
297
|
)}
|
|
243
298
|
|
|
244
|
-
{/*
|
|
299
|
+
{/* Recent searches — shown when no query */}
|
|
245
300
|
{!isUnavailable && !isLoading && query.trim().length === 0 && results.length === 0 && (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
301
|
+
<>
|
|
302
|
+
{recentSearches.length > 0 ? (
|
|
303
|
+
<CommandGroup heading="Recent searches">
|
|
304
|
+
{recentSearches.map((recent) => (
|
|
305
|
+
<CommandItem
|
|
306
|
+
key={recent}
|
|
307
|
+
value={`recent-${recent}`}
|
|
308
|
+
onSelect={() => handleRecentSelect(recent)}
|
|
309
|
+
className="flex items-center gap-2 px-3 py-2 text-sm"
|
|
310
|
+
>
|
|
311
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-muted shrink-0" aria-hidden="true">
|
|
312
|
+
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
313
|
+
<path d="M3 3v5h5" />
|
|
314
|
+
<path d="M12 7v5l4 2" />
|
|
315
|
+
</svg>
|
|
316
|
+
<span className="text-text-muted">{recent}</span>
|
|
317
|
+
</CommandItem>
|
|
318
|
+
))}
|
|
319
|
+
</CommandGroup>
|
|
320
|
+
) : (
|
|
321
|
+
<div className="px-4 py-6 text-center text-sm text-text-muted">
|
|
322
|
+
Type to search documentation…
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
</>
|
|
249
326
|
)}
|
|
250
327
|
|
|
251
|
-
{/*
|
|
328
|
+
{/* Grouped results */}
|
|
252
329
|
{!isUnavailable &&
|
|
253
|
-
results.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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}
|
|
330
|
+
results.length > 0 &&
|
|
331
|
+
Array.from(groupResults(results)).map(([section, sectionResults]) => (
|
|
332
|
+
<CommandGroup key={section} heading={section}>
|
|
333
|
+
{sectionResults.map((result) => (
|
|
334
|
+
<CommandItem
|
|
335
|
+
key={result.id}
|
|
336
|
+
value={result.id}
|
|
337
|
+
onSelect={() => handleSelect(result.url)}
|
|
338
|
+
className="flex flex-col items-start gap-1 px-3 py-2.5"
|
|
339
|
+
>
|
|
340
|
+
{/* Page title */}
|
|
341
|
+
<div className="font-medium text-text">
|
|
342
|
+
{result.meta?.title ?? "Untitled"}
|
|
270
343
|
</div>
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
344
|
+
|
|
345
|
+
{/* Sub-result heading */}
|
|
346
|
+
{result.sub_results?.[0]?.title &&
|
|
347
|
+
result.sub_results[0].title !== result.meta?.title && (
|
|
348
|
+
<div className="text-xs text-text-muted">
|
|
349
|
+
{result.sub_results[0].title}
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
|
|
353
|
+
{/* Content snippet */}
|
|
354
|
+
<div
|
|
355
|
+
className="text-xs text-text-muted line-clamp-2 [&_mark]:bg-primary/20 [&_mark]:text-text [&_mark]:rounded-sm [&_mark]:px-0.5"
|
|
356
|
+
dangerouslySetInnerHTML={{
|
|
357
|
+
__html:
|
|
358
|
+
result.sub_results?.[0]?.excerpt ?? result.excerpt ?? "",
|
|
359
|
+
}}
|
|
360
|
+
/>
|
|
361
|
+
</CommandItem>
|
|
362
|
+
))}
|
|
363
|
+
</CommandGroup>
|
|
282
364
|
))}
|
|
283
365
|
</CommandList>
|
|
284
366
|
|