@veluai/velu 0.2.13 → 0.2.15
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/README.md +80 -80
- package/dist/cli.js +37 -37
- package/package.json +64 -64
- package/runtime/velu-ui/base.css +320 -320
- package/runtime/velu-ui/components/Accordion.jsx +64 -64
- package/runtime/velu-ui/components/ApiClient.jsx +207 -207
- package/runtime/velu-ui/components/ApiField.jsx +87 -87
- package/runtime/velu-ui/components/ApiPath.jsx +63 -63
- package/runtime/velu-ui/components/ApiReferencePage.jsx +384 -384
- package/runtime/velu-ui/components/ApiSamples.jsx +36 -36
- package/runtime/velu-ui/components/ApiSidebar.jsx +122 -122
- package/runtime/velu-ui/components/AskBar.jsx +71 -71
- package/runtime/velu-ui/components/Callout.jsx +114 -114
- package/runtime/velu-ui/components/Card.jsx +131 -131
- package/runtime/velu-ui/components/Chatbot.jsx +885 -885
- package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
- package/runtime/velu-ui/components/Columns.jsx +56 -56
- package/runtime/velu-ui/components/ContextMenu.jsx +298 -273
- package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
- package/runtime/velu-ui/components/Field.jsx +81 -81
- package/runtime/velu-ui/components/Image.jsx +163 -163
- package/runtime/velu-ui/components/Logo.jsx +31 -31
- package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
- package/runtime/velu-ui/components/NavSelect.jsx +108 -108
- package/runtime/velu-ui/components/NotFound.jsx +63 -63
- package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
- package/runtime/velu-ui/components/PageFooter.jsx +145 -145
- package/runtime/velu-ui/components/PageHeader.jsx +422 -422
- package/runtime/velu-ui/components/PageNav.jsx +77 -77
- package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
- package/runtime/velu-ui/components/Prompt.jsx +115 -115
- package/runtime/velu-ui/components/Search.jsx +460 -460
- package/runtime/velu-ui/components/Sidebar.jsx +254 -254
- package/runtime/velu-ui/components/SocialLinks.jsx +90 -90
- package/runtime/velu-ui/components/Steps.jsx +65 -65
- package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
- package/runtime/velu-ui/components/Toc.jsx +537 -537
- package/runtime/velu-ui/components/TocBar.jsx +195 -195
- package/runtime/velu-ui/components/Tree.jsx +87 -87
- package/runtime/velu-ui/components/TryItBar.jsx +102 -102
- package/runtime/velu-ui/components/accordion.css +92 -92
- package/runtime/velu-ui/components/api-page.css +208 -208
- package/runtime/velu-ui/components/api.css +635 -635
- package/runtime/velu-ui/components/ask-bar.css +94 -94
- package/runtime/velu-ui/components/card.css +105 -105
- package/runtime/velu-ui/components/chatbot.css +622 -622
- package/runtime/velu-ui/components/code-block.css +263 -263
- package/runtime/velu-ui/components/context-menu.css +173 -173
- package/runtime/velu-ui/components/docs-layout.css +822 -822
- package/runtime/velu-ui/components/field.css +82 -82
- package/runtime/velu-ui/components/image.css +237 -237
- package/runtime/velu-ui/components/nav-select.css +157 -157
- package/runtime/velu-ui/components/not-found.css +94 -94
- package/runtime/velu-ui/components/page-feedback.css +241 -241
- package/runtime/velu-ui/components/page-footer.css +130 -130
- package/runtime/velu-ui/components/page-header.css +558 -558
- package/runtime/velu-ui/components/page-nav.css +50 -50
- package/runtime/velu-ui/components/powered-by.css +92 -92
- package/runtime/velu-ui/components/prompt.css +99 -99
- package/runtime/velu-ui/components/search.css +307 -307
- package/runtime/velu-ui/components/sidebar.css +205 -205
- package/runtime/velu-ui/components/steps.css +77 -77
- package/runtime/velu-ui/components/theme-toggle.css +102 -102
- package/runtime/velu-ui/components/toc-bar.css +234 -234
- package/runtime/velu-ui/components/tree.css +49 -49
- package/runtime/velu-ui/index.js +54 -54
- package/runtime/velu-ui/lib/api-send.js +92 -92
- package/runtime/velu-ui/lib/brand-icons.jsx +103 -103
- package/runtime/velu-ui/lib/component-schemas.js +100 -100
- package/runtime/velu-ui/lib/copyText.js +64 -64
- package/runtime/velu-ui/lib/docs-assistant.js +250 -250
- package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
- package/runtime/velu-ui/lib/pagefind.js +113 -113
- package/runtime/velu-ui/lib/prism-langs.js +957 -957
- package/runtime/velu-ui/lib/prism-loader.js +74 -74
- package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
- package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
- package/runtime/velu-ui/mdx-components.jsx +105 -105
- package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
- package/runtime/velu-ui/primitives/Stack.jsx +63 -63
- package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
- package/runtime/velu-ui/primitives/stack.css +3 -3
- package/runtime/velu-ui/primitives/switcher.css +25 -25
- package/runtime/velu-ui/styles.css +46 -46
- package/runtime/velu-ui/tokens.css +4 -4
- package/schema/velu.schema.json +423 -423
- package/src/lib/extract-mdx-error.js +170 -170
- package/src/lib/issues.js +159 -159
- package/src/lib/known-components.js +34 -34
- package/src/navigation.js +443 -443
- package/src/runtime/App.jsx +1669 -1668
- package/src/runtime/ErrorBoundary.jsx +54 -54
- package/src/runtime/client-entry.jsx +22 -22
- package/src/runtime/server-entry.jsx +16 -16
- package/src/template.html +48 -48
- package/templates/starter/ai-tools/claude-code.mdx +26 -26
- package/templates/starter/ai-tools/cursor.mdx +17 -17
- package/templates/starter/api-reference/introduction.mdx +43 -43
- package/templates/starter/development.mdx +19 -19
- package/templates/starter/essentials/code.mdx +29 -29
- package/templates/starter/essentials/images.mdx +29 -29
- package/templates/starter/essentials/markdown.mdx +25 -25
- package/templates/starter/essentials/navigation.mdx +39 -39
- package/templates/starter/essentials/settings.mdx +30 -30
- package/templates/starter/favicon.svg +6 -6
- package/templates/starter/index.mdx +31 -31
- package/templates/starter/openapi.json +160 -160
- package/templates/starter/quickstart.mdx +31 -31
- package/templates/starter/velu.json +41 -41
|
@@ -1,460 +1,460 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
useState,
|
|
3
|
-
useRef,
|
|
4
|
-
useEffect,
|
|
5
|
-
useMemo,
|
|
6
|
-
useCallback,
|
|
7
|
-
} from 'react';
|
|
8
|
-
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Search — a Raycast-style command palette for the docs site. Ported
|
|
12
|
-
* from the Claude-Design "Velu Search" handoff and retokenized onto
|
|
13
|
-
* velu-ui's scale (themes for free via [data-theme]).
|
|
14
|
-
*
|
|
15
|
-
* <Search results={[…]} onSelect={(item) => …} />
|
|
16
|
-
*
|
|
17
|
-
* Renders a trigger box (drop it at the top of the page). Clicking it —
|
|
18
|
-
* or pressing ⌘K / Ctrl+K anywhere — reveals the palette: a scrim plus
|
|
19
|
-
* a centered panel with a search input and grouped, keyboard-navigable
|
|
20
|
-
* results (↑/↓ to move, Enter to pick, Esc / scrim-click to close).
|
|
21
|
-
*
|
|
22
|
-
* @typedef {Object} SearchResult
|
|
23
|
-
* @property {string} id
|
|
24
|
-
* @property {string} group 'Recent Searches' | 'Suggested' | 'Pages'
|
|
25
|
-
* @property {string[]} breadcrumb
|
|
26
|
-
* @property {string} title
|
|
27
|
-
* @property {'page'|'anchor'|'ext'} kind
|
|
28
|
-
* @property {string} desc
|
|
29
|
-
* @property {string} [href]
|
|
30
|
-
*
|
|
31
|
-
* @param {{ results?: SearchResult[], placeholder?: string,
|
|
32
|
-
* onSelect?: (item: SearchResult) => void,
|
|
33
|
-
* className?: string }} props
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
/* Sample result set — the demo data from the design. Override via the
|
|
37
|
-
`results` prop with real indexed content. */
|
|
38
|
-
const DEFAULT_RESULTS = [
|
|
39
|
-
{
|
|
40
|
-
id: 'auth-pw',
|
|
41
|
-
group: 'Recent Searches',
|
|
42
|
-
breadcrumb: ['Authentication setup', 'Password'],
|
|
43
|
-
title: 'Password',
|
|
44
|
-
kind: 'anchor',
|
|
45
|
-
desc: 'Password authentication provides access control through credentials.',
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
id: 'gh-sync',
|
|
49
|
-
group: 'Recent Searches',
|
|
50
|
-
breadcrumb: ['Get Started', 'GitHub & GitLab Sync'],
|
|
51
|
-
title: 'Enabling GitHub sync',
|
|
52
|
-
kind: 'page',
|
|
53
|
-
desc: 'Connect a GitHub repository to keep your docs in sync on every push.',
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
id: 'ai-search',
|
|
57
|
-
group: 'Recent Searches',
|
|
58
|
-
breadcrumb: ['Your Docs Site'],
|
|
59
|
-
title: 'AI Search',
|
|
60
|
-
kind: 'page',
|
|
61
|
-
desc: 'Configure semantic search for your documentation site.',
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
id: 'quickstart',
|
|
65
|
-
group: 'Suggested',
|
|
66
|
-
breadcrumb: ['Get Started'],
|
|
67
|
-
title: 'Quickstart',
|
|
68
|
-
kind: 'page',
|
|
69
|
-
desc: 'Set up Velu and publish your first docs site in under 5 minutes.',
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
id: 'overview',
|
|
73
|
-
group: 'Suggested',
|
|
74
|
-
breadcrumb: ['Get Started'],
|
|
75
|
-
title: 'Overview',
|
|
76
|
-
kind: 'page',
|
|
77
|
-
desc: 'Velu is an AI-native documentation platform built for humans and AI agents.',
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
id: 'site-settings',
|
|
81
|
-
group: 'Suggested',
|
|
82
|
-
breadcrumb: ['Your Docs Site'],
|
|
83
|
-
title: 'Site Settings',
|
|
84
|
-
kind: 'page',
|
|
85
|
-
desc: 'Configure domain, theme, navigation and SEO for your docs site.',
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
id: 'oauth',
|
|
89
|
-
group: 'Pages',
|
|
90
|
-
breadcrumb: ['Authentication setup', 'OAuth'],
|
|
91
|
-
title: 'OAuth',
|
|
92
|
-
kind: 'anchor',
|
|
93
|
-
desc: 'Configure OAuth providers for sign-in.',
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
id: 'saml',
|
|
97
|
-
group: 'Pages',
|
|
98
|
-
breadcrumb: ['Authentication setup', 'SAML'],
|
|
99
|
-
title: 'SAML',
|
|
100
|
-
kind: 'anchor',
|
|
101
|
-
desc: 'Enterprise single sign-on via SAML 2.0.',
|
|
102
|
-
},
|
|
103
|
-
];
|
|
104
|
-
|
|
105
|
-
const GROUP_ORDER = ['Recent Searches', 'Suggested', 'Pages'];
|
|
106
|
-
|
|
107
|
-
/* Left-hand glyph: '#' for an anchor, an icon for page / external. */
|
|
108
|
-
function ResultGlyph({ kind }) {
|
|
109
|
-
if (kind === 'anchor') {
|
|
110
|
-
return <span className="velu-search__glyph-hash">#</span>;
|
|
111
|
-
}
|
|
112
|
-
const icon = kind === 'ext' ? 'external-link' : 'file-text';
|
|
113
|
-
return (
|
|
114
|
-
<span className="velu-search__glyph-icon">
|
|
115
|
-
{resolveIcon(icon, { size: '1em' })}
|
|
116
|
-
</span>
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function Breadcrumb({ items }) {
|
|
121
|
-
return (
|
|
122
|
-
<div className="velu-search__crumbs">
|
|
123
|
-
{items.map((c, i) => (
|
|
124
|
-
<React.Fragment key={i}>
|
|
125
|
-
{i > 0 && <span className="velu-search__crumb-sep">›</span>}
|
|
126
|
-
<span>{c}</span>
|
|
127
|
-
</React.Fragment>
|
|
128
|
-
))}
|
|
129
|
-
</div>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
|
|
134
|
-
const ref = useRef(null);
|
|
135
|
-
useEffect(() => {
|
|
136
|
-
// Only auto-scroll for keyboard nav — on mouse hover the pointer is
|
|
137
|
-
// already on the row, and scrolling there nudges the list (a 1px
|
|
138
|
-
// jitter that flickers a line at the list edge).
|
|
139
|
-
if (selected && navMode.current === 'keyboard' && ref.current) {
|
|
140
|
-
ref.current.scrollIntoView({ block: 'nearest' });
|
|
141
|
-
}
|
|
142
|
-
}, [selected, navMode]);
|
|
143
|
-
return (
|
|
144
|
-
<div
|
|
145
|
-
ref={ref}
|
|
146
|
-
className={`velu-search__row${selected ? ' velu-search__row--selected' : ''}`}
|
|
147
|
-
onMouseEnter={onHover}
|
|
148
|
-
onClick={onSelect}
|
|
149
|
-
role="option"
|
|
150
|
-
aria-selected={selected}
|
|
151
|
-
>
|
|
152
|
-
<div className="velu-search__row-glyph">
|
|
153
|
-
<ResultGlyph kind={item.kind} />
|
|
154
|
-
</div>
|
|
155
|
-
<Breadcrumb items={item.breadcrumb} />
|
|
156
|
-
<div className="velu-search__row-title">{item.title}</div>
|
|
157
|
-
<div className="velu-search__row-desc">{item.desc}</div>
|
|
158
|
-
<div className="velu-search__row-meta">
|
|
159
|
-
<span className="velu-search__kbd">↵</span>
|
|
160
|
-
</div>
|
|
161
|
-
</div>
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/* The revealed palette — scrim + centered panel. When `search` (an async
|
|
166
|
-
query → results function, e.g. the Pagefind client) is provided, results
|
|
167
|
-
come from it; otherwise the static `results` list is filtered in-memory. */
|
|
168
|
-
function SearchPalette({ results, search, placeholder, onSelect, onClose }) {
|
|
169
|
-
const [query, setQuery] = useState('');
|
|
170
|
-
const [selected, setSelected] = useState(0);
|
|
171
|
-
const [asyncResults, setAsyncResults] = useState([]);
|
|
172
|
-
const [searching, setSearching] = useState(false);
|
|
173
|
-
const inputRef = useRef(null);
|
|
174
|
-
// 'keyboard' | 'mouse' — which input last moved the selection; gates
|
|
175
|
-
// the rows' scrollIntoView so mouse hover doesn't jitter the list.
|
|
176
|
-
const navMode = useRef('keyboard');
|
|
177
|
-
|
|
178
|
-
useEffect(() => {
|
|
179
|
-
inputRef.current?.focus();
|
|
180
|
-
}, []);
|
|
181
|
-
|
|
182
|
-
// Async source (Pagefind): debounce the query, fetch results.
|
|
183
|
-
useEffect(() => {
|
|
184
|
-
if (!search) return undefined;
|
|
185
|
-
const q = query.trim();
|
|
186
|
-
if (!q) {
|
|
187
|
-
setAsyncResults([]);
|
|
188
|
-
setSearching(false);
|
|
189
|
-
return undefined;
|
|
190
|
-
}
|
|
191
|
-
let cancelled = false;
|
|
192
|
-
setSearching(true);
|
|
193
|
-
const t = setTimeout(async () => {
|
|
194
|
-
const r = await search(q).catch(() => []);
|
|
195
|
-
if (!cancelled) {
|
|
196
|
-
setAsyncResults(Array.isArray(r) ? r : []);
|
|
197
|
-
setSearching(false);
|
|
198
|
-
}
|
|
199
|
-
}, 150);
|
|
200
|
-
return () => {
|
|
201
|
-
cancelled = true;
|
|
202
|
-
clearTimeout(t);
|
|
203
|
-
};
|
|
204
|
-
}, [query, search]);
|
|
205
|
-
|
|
206
|
-
const filtered = useMemo(() => {
|
|
207
|
-
if (search) return asyncResults;
|
|
208
|
-
const q = query.trim().toLowerCase();
|
|
209
|
-
if (!q) {
|
|
210
|
-
// Empty query → just the recents.
|
|
211
|
-
return results.filter((r) => r.group === 'Recent Searches');
|
|
212
|
-
}
|
|
213
|
-
return results.filter((r) => {
|
|
214
|
-
const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
|
|
215
|
-
return hay.includes(q);
|
|
216
|
-
});
|
|
217
|
-
}, [search, asyncResults, query, results]);
|
|
218
|
-
|
|
219
|
-
const grouped = useMemo(() => {
|
|
220
|
-
const map = new Map();
|
|
221
|
-
filtered.forEach((r) => {
|
|
222
|
-
const g = r.group || 'Pages';
|
|
223
|
-
if (!map.has(g)) map.set(g, []);
|
|
224
|
-
map.get(g).push(r);
|
|
225
|
-
});
|
|
226
|
-
const known = GROUP_ORDER.filter((g) => map.has(g));
|
|
227
|
-
const extra = [...map.keys()].filter((g) => !GROUP_ORDER.includes(g));
|
|
228
|
-
return [...known, ...extra].map((g) => [g, map.get(g)]);
|
|
229
|
-
}, [filtered]);
|
|
230
|
-
|
|
231
|
-
const flat = useMemo(
|
|
232
|
-
() => grouped.flatMap(([, items]) => items),
|
|
233
|
-
[grouped],
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
useEffect(() => setSelected(0), [query]);
|
|
237
|
-
|
|
238
|
-
const pick = useCallback(
|
|
239
|
-
(item) => {
|
|
240
|
-
onSelect?.(item);
|
|
241
|
-
onClose();
|
|
242
|
-
},
|
|
243
|
-
[onSelect, onClose],
|
|
244
|
-
);
|
|
245
|
-
|
|
246
|
-
useEffect(() => {
|
|
247
|
-
const onKey = (e) => {
|
|
248
|
-
if (e.key === 'Escape') {
|
|
249
|
-
e.preventDefault();
|
|
250
|
-
onClose();
|
|
251
|
-
} else if (e.key === 'ArrowDown') {
|
|
252
|
-
e.preventDefault();
|
|
253
|
-
navMode.current = 'keyboard';
|
|
254
|
-
setSelected((s) => Math.min(s + 1, flat.length - 1));
|
|
255
|
-
} else if (e.key === 'ArrowUp') {
|
|
256
|
-
e.preventDefault();
|
|
257
|
-
navMode.current = 'keyboard';
|
|
258
|
-
setSelected((s) => Math.max(s - 1, 0));
|
|
259
|
-
} else if (e.key === 'Enter') {
|
|
260
|
-
e.preventDefault();
|
|
261
|
-
if (flat[selected]) pick(flat[selected]);
|
|
262
|
-
else onClose();
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
|
-
window.addEventListener('keydown', onKey);
|
|
266
|
-
return () => window.removeEventListener('keydown', onKey);
|
|
267
|
-
}, [flat, selected, pick, onClose]);
|
|
268
|
-
|
|
269
|
-
let rowIndex = -1;
|
|
270
|
-
|
|
271
|
-
return (
|
|
272
|
-
<div
|
|
273
|
-
className="velu-search__scrim"
|
|
274
|
-
onClick={onClose}
|
|
275
|
-
role="presentation"
|
|
276
|
-
>
|
|
277
|
-
<div
|
|
278
|
-
className="velu-search__palette"
|
|
279
|
-
role="dialog"
|
|
280
|
-
aria-modal="true"
|
|
281
|
-
aria-label="Search"
|
|
282
|
-
onClick={(e) => e.stopPropagation()}
|
|
283
|
-
>
|
|
284
|
-
<div className="velu-search__input-wrap">
|
|
285
|
-
<span className="velu-search__input-icon" aria-hidden="true">
|
|
286
|
-
{resolveIcon('search', { size: '1em' })}
|
|
287
|
-
</span>
|
|
288
|
-
<input
|
|
289
|
-
ref={inputRef}
|
|
290
|
-
className="velu-search__input"
|
|
291
|
-
placeholder={placeholder}
|
|
292
|
-
value={query}
|
|
293
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
294
|
-
spellCheck={false}
|
|
295
|
-
aria-label="Search query"
|
|
296
|
-
/>
|
|
297
|
-
<span className="velu-search__kbd">esc</span>
|
|
298
|
-
</div>
|
|
299
|
-
|
|
300
|
-
<div className="velu-search__list" role="listbox">
|
|
301
|
-
{grouped.length === 0 &&
|
|
302
|
-
(search && !query.trim() ? (
|
|
303
|
-
<div className="velu-search__empty">
|
|
304
|
-
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
305
|
-
{resolveIcon('search', { size: '1em' })}
|
|
306
|
-
</span>
|
|
307
|
-
<div className="velu-search__empty-title">Search the docs</div>
|
|
308
|
-
<div className="velu-search__empty-sub">
|
|
309
|
-
Type to find pages and sections.
|
|
310
|
-
</div>
|
|
311
|
-
</div>
|
|
312
|
-
) : searching ? (
|
|
313
|
-
<div className="velu-search__empty">
|
|
314
|
-
<div className="velu-search__empty-sub">Searching…</div>
|
|
315
|
-
</div>
|
|
316
|
-
) : (
|
|
317
|
-
<div className="velu-search__empty">
|
|
318
|
-
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
319
|
-
{resolveIcon('search-x', { size: '1em' })}
|
|
320
|
-
</span>
|
|
321
|
-
<div className="velu-search__empty-title">
|
|
322
|
-
No results for “{query}”
|
|
323
|
-
</div>
|
|
324
|
-
<div className="velu-search__empty-sub">
|
|
325
|
-
Try a different keyword or browse the sidebar.
|
|
326
|
-
</div>
|
|
327
|
-
</div>
|
|
328
|
-
))}
|
|
329
|
-
{grouped.map(([group, items]) => (
|
|
330
|
-
<div key={group}>
|
|
331
|
-
<div className="velu-search__group">{group}</div>
|
|
332
|
-
{items.map((item) => {
|
|
333
|
-
rowIndex += 1;
|
|
334
|
-
const idx = rowIndex;
|
|
335
|
-
return (
|
|
336
|
-
<PaletteRow
|
|
337
|
-
key={item.id}
|
|
338
|
-
item={item}
|
|
339
|
-
selected={idx === selected}
|
|
340
|
-
navMode={navMode}
|
|
341
|
-
onHover={() => {
|
|
342
|
-
navMode.current = 'mouse';
|
|
343
|
-
setSelected(idx);
|
|
344
|
-
}}
|
|
345
|
-
onSelect={() => pick(item)}
|
|
346
|
-
/>
|
|
347
|
-
);
|
|
348
|
-
})}
|
|
349
|
-
</div>
|
|
350
|
-
))}
|
|
351
|
-
</div>
|
|
352
|
-
</div>
|
|
353
|
-
</div>
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/* Shown instead of the palette when search is unavailable (e.g. the local
|
|
358
|
-
dev preview has no search index — that's generated when the site deploys).
|
|
359
|
-
Reuses the palette's empty-state styling so it looks at home. */
|
|
360
|
-
function SearchUnavailable({ message, onClose }) {
|
|
361
|
-
useEffect(() => {
|
|
362
|
-
const onKey = (e) => {
|
|
363
|
-
if (e.key === 'Escape') {
|
|
364
|
-
e.preventDefault();
|
|
365
|
-
onClose();
|
|
366
|
-
}
|
|
367
|
-
};
|
|
368
|
-
window.addEventListener('keydown', onKey);
|
|
369
|
-
return () => window.removeEventListener('keydown', onKey);
|
|
370
|
-
}, [onClose]);
|
|
371
|
-
return (
|
|
372
|
-
<div className="velu-search__scrim" onClick={onClose} role="presentation">
|
|
373
|
-
<div
|
|
374
|
-
className="velu-search__palette"
|
|
375
|
-
role="dialog"
|
|
376
|
-
aria-modal="true"
|
|
377
|
-
aria-label="Search unavailable"
|
|
378
|
-
onClick={(e) => e.stopPropagation()}
|
|
379
|
-
>
|
|
380
|
-
<div className="velu-search__empty">
|
|
381
|
-
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
382
|
-
{resolveIcon('search-x', { size: '1em' })}
|
|
383
|
-
</span>
|
|
384
|
-
<div className="velu-search__empty-title">
|
|
385
|
-
Search isn’t available in the dev preview
|
|
386
|
-
</div>
|
|
387
|
-
<div className="velu-search__empty-sub">{message}</div>
|
|
388
|
-
</div>
|
|
389
|
-
</div>
|
|
390
|
-
</div>
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
export default function Search({
|
|
395
|
-
results = DEFAULT_RESULTS,
|
|
396
|
-
search,
|
|
397
|
-
placeholder = 'Search documentation',
|
|
398
|
-
onSelect,
|
|
399
|
-
unavailable = false,
|
|
400
|
-
unavailableMessage = 'Full-text search is generated when your site is deployed — it isn’t available in the local preview.',
|
|
401
|
-
className = '',
|
|
402
|
-
...rest
|
|
403
|
-
}) {
|
|
404
|
-
const [open, setOpen] = useState(false);
|
|
405
|
-
// Show ⌘ K on Mac, Ctrl K elsewhere. SSR-safe: default to the
|
|
406
|
-
// non-Mac label (so the server render matches the first client
|
|
407
|
-
// render), then switch on the client if it's actually Mac.
|
|
408
|
-
const [isMac, setIsMac] = useState(false);
|
|
409
|
-
useEffect(() => {
|
|
410
|
-
if (typeof navigator === 'undefined') return;
|
|
411
|
-
const ua = navigator.userAgent || '';
|
|
412
|
-
const plat = navigator.platform || '';
|
|
413
|
-
setIsMac(/Mac|iPhone|iPad|iPod/i.test(plat) || /Mac/i.test(ua));
|
|
414
|
-
}, []);
|
|
415
|
-
|
|
416
|
-
// Global ⌘K (Mac) / Ctrl+K (Win/Linux) opens the palette.
|
|
417
|
-
useEffect(() => {
|
|
418
|
-
const onKey = (e) => {
|
|
419
|
-
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
420
|
-
e.preventDefault();
|
|
421
|
-
setOpen(true);
|
|
422
|
-
}
|
|
423
|
-
};
|
|
424
|
-
window.addEventListener('keydown', onKey);
|
|
425
|
-
return () => window.removeEventListener('keydown', onKey);
|
|
426
|
-
}, []);
|
|
427
|
-
|
|
428
|
-
return (
|
|
429
|
-
<>
|
|
430
|
-
<button
|
|
431
|
-
type="button"
|
|
432
|
-
className={`velu-search__trigger ${className}`.trim()}
|
|
433
|
-
onClick={() => setOpen(true)}
|
|
434
|
-
{...rest}
|
|
435
|
-
>
|
|
436
|
-
<span className="velu-search__trigger-icon" aria-hidden="true">
|
|
437
|
-
{resolveIcon('search', { size: '1.5em' })}
|
|
438
|
-
</span>
|
|
439
|
-
<span className="velu-search__trigger-label">Search…</span>
|
|
440
|
-
<span className="velu-search__kbd">{isMac ? '⌘ K' : 'Ctrl K'}</span>
|
|
441
|
-
</button>
|
|
442
|
-
|
|
443
|
-
{open &&
|
|
444
|
-
(unavailable ? (
|
|
445
|
-
<SearchUnavailable
|
|
446
|
-
message={unavailableMessage}
|
|
447
|
-
onClose={() => setOpen(false)}
|
|
448
|
-
/>
|
|
449
|
-
) : (
|
|
450
|
-
<SearchPalette
|
|
451
|
-
results={results}
|
|
452
|
-
search={search}
|
|
453
|
-
placeholder={placeholder}
|
|
454
|
-
onSelect={onSelect}
|
|
455
|
-
onClose={() => setOpen(false)}
|
|
456
|
-
/>
|
|
457
|
-
))}
|
|
458
|
-
</>
|
|
459
|
-
);
|
|
460
|
-
}
|
|
1
|
+
import React, {
|
|
2
|
+
useState,
|
|
3
|
+
useRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useCallback,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Search — a Raycast-style command palette for the docs site. Ported
|
|
12
|
+
* from the Claude-Design "Velu Search" handoff and retokenized onto
|
|
13
|
+
* velu-ui's scale (themes for free via [data-theme]).
|
|
14
|
+
*
|
|
15
|
+
* <Search results={[…]} onSelect={(item) => …} />
|
|
16
|
+
*
|
|
17
|
+
* Renders a trigger box (drop it at the top of the page). Clicking it —
|
|
18
|
+
* or pressing ⌘K / Ctrl+K anywhere — reveals the palette: a scrim plus
|
|
19
|
+
* a centered panel with a search input and grouped, keyboard-navigable
|
|
20
|
+
* results (↑/↓ to move, Enter to pick, Esc / scrim-click to close).
|
|
21
|
+
*
|
|
22
|
+
* @typedef {Object} SearchResult
|
|
23
|
+
* @property {string} id
|
|
24
|
+
* @property {string} group 'Recent Searches' | 'Suggested' | 'Pages'
|
|
25
|
+
* @property {string[]} breadcrumb
|
|
26
|
+
* @property {string} title
|
|
27
|
+
* @property {'page'|'anchor'|'ext'} kind
|
|
28
|
+
* @property {string} desc
|
|
29
|
+
* @property {string} [href]
|
|
30
|
+
*
|
|
31
|
+
* @param {{ results?: SearchResult[], placeholder?: string,
|
|
32
|
+
* onSelect?: (item: SearchResult) => void,
|
|
33
|
+
* className?: string }} props
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/* Sample result set — the demo data from the design. Override via the
|
|
37
|
+
`results` prop with real indexed content. */
|
|
38
|
+
const DEFAULT_RESULTS = [
|
|
39
|
+
{
|
|
40
|
+
id: 'auth-pw',
|
|
41
|
+
group: 'Recent Searches',
|
|
42
|
+
breadcrumb: ['Authentication setup', 'Password'],
|
|
43
|
+
title: 'Password',
|
|
44
|
+
kind: 'anchor',
|
|
45
|
+
desc: 'Password authentication provides access control through credentials.',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'gh-sync',
|
|
49
|
+
group: 'Recent Searches',
|
|
50
|
+
breadcrumb: ['Get Started', 'GitHub & GitLab Sync'],
|
|
51
|
+
title: 'Enabling GitHub sync',
|
|
52
|
+
kind: 'page',
|
|
53
|
+
desc: 'Connect a GitHub repository to keep your docs in sync on every push.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'ai-search',
|
|
57
|
+
group: 'Recent Searches',
|
|
58
|
+
breadcrumb: ['Your Docs Site'],
|
|
59
|
+
title: 'AI Search',
|
|
60
|
+
kind: 'page',
|
|
61
|
+
desc: 'Configure semantic search for your documentation site.',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'quickstart',
|
|
65
|
+
group: 'Suggested',
|
|
66
|
+
breadcrumb: ['Get Started'],
|
|
67
|
+
title: 'Quickstart',
|
|
68
|
+
kind: 'page',
|
|
69
|
+
desc: 'Set up Velu and publish your first docs site in under 5 minutes.',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'overview',
|
|
73
|
+
group: 'Suggested',
|
|
74
|
+
breadcrumb: ['Get Started'],
|
|
75
|
+
title: 'Overview',
|
|
76
|
+
kind: 'page',
|
|
77
|
+
desc: 'Velu is an AI-native documentation platform built for humans and AI agents.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'site-settings',
|
|
81
|
+
group: 'Suggested',
|
|
82
|
+
breadcrumb: ['Your Docs Site'],
|
|
83
|
+
title: 'Site Settings',
|
|
84
|
+
kind: 'page',
|
|
85
|
+
desc: 'Configure domain, theme, navigation and SEO for your docs site.',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'oauth',
|
|
89
|
+
group: 'Pages',
|
|
90
|
+
breadcrumb: ['Authentication setup', 'OAuth'],
|
|
91
|
+
title: 'OAuth',
|
|
92
|
+
kind: 'anchor',
|
|
93
|
+
desc: 'Configure OAuth providers for sign-in.',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'saml',
|
|
97
|
+
group: 'Pages',
|
|
98
|
+
breadcrumb: ['Authentication setup', 'SAML'],
|
|
99
|
+
title: 'SAML',
|
|
100
|
+
kind: 'anchor',
|
|
101
|
+
desc: 'Enterprise single sign-on via SAML 2.0.',
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const GROUP_ORDER = ['Recent Searches', 'Suggested', 'Pages'];
|
|
106
|
+
|
|
107
|
+
/* Left-hand glyph: '#' for an anchor, an icon for page / external. */
|
|
108
|
+
function ResultGlyph({ kind }) {
|
|
109
|
+
if (kind === 'anchor') {
|
|
110
|
+
return <span className="velu-search__glyph-hash">#</span>;
|
|
111
|
+
}
|
|
112
|
+
const icon = kind === 'ext' ? 'external-link' : 'file-text';
|
|
113
|
+
return (
|
|
114
|
+
<span className="velu-search__glyph-icon">
|
|
115
|
+
{resolveIcon(icon, { size: '1em' })}
|
|
116
|
+
</span>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function Breadcrumb({ items }) {
|
|
121
|
+
return (
|
|
122
|
+
<div className="velu-search__crumbs">
|
|
123
|
+
{items.map((c, i) => (
|
|
124
|
+
<React.Fragment key={i}>
|
|
125
|
+
{i > 0 && <span className="velu-search__crumb-sep">›</span>}
|
|
126
|
+
<span>{c}</span>
|
|
127
|
+
</React.Fragment>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
|
|
134
|
+
const ref = useRef(null);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
// Only auto-scroll for keyboard nav — on mouse hover the pointer is
|
|
137
|
+
// already on the row, and scrolling there nudges the list (a 1px
|
|
138
|
+
// jitter that flickers a line at the list edge).
|
|
139
|
+
if (selected && navMode.current === 'keyboard' && ref.current) {
|
|
140
|
+
ref.current.scrollIntoView({ block: 'nearest' });
|
|
141
|
+
}
|
|
142
|
+
}, [selected, navMode]);
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
ref={ref}
|
|
146
|
+
className={`velu-search__row${selected ? ' velu-search__row--selected' : ''}`}
|
|
147
|
+
onMouseEnter={onHover}
|
|
148
|
+
onClick={onSelect}
|
|
149
|
+
role="option"
|
|
150
|
+
aria-selected={selected}
|
|
151
|
+
>
|
|
152
|
+
<div className="velu-search__row-glyph">
|
|
153
|
+
<ResultGlyph kind={item.kind} />
|
|
154
|
+
</div>
|
|
155
|
+
<Breadcrumb items={item.breadcrumb} />
|
|
156
|
+
<div className="velu-search__row-title">{item.title}</div>
|
|
157
|
+
<div className="velu-search__row-desc">{item.desc}</div>
|
|
158
|
+
<div className="velu-search__row-meta">
|
|
159
|
+
<span className="velu-search__kbd">↵</span>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* The revealed palette — scrim + centered panel. When `search` (an async
|
|
166
|
+
query → results function, e.g. the Pagefind client) is provided, results
|
|
167
|
+
come from it; otherwise the static `results` list is filtered in-memory. */
|
|
168
|
+
function SearchPalette({ results, search, placeholder, onSelect, onClose }) {
|
|
169
|
+
const [query, setQuery] = useState('');
|
|
170
|
+
const [selected, setSelected] = useState(0);
|
|
171
|
+
const [asyncResults, setAsyncResults] = useState([]);
|
|
172
|
+
const [searching, setSearching] = useState(false);
|
|
173
|
+
const inputRef = useRef(null);
|
|
174
|
+
// 'keyboard' | 'mouse' — which input last moved the selection; gates
|
|
175
|
+
// the rows' scrollIntoView so mouse hover doesn't jitter the list.
|
|
176
|
+
const navMode = useRef('keyboard');
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
inputRef.current?.focus();
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
// Async source (Pagefind): debounce the query, fetch results.
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!search) return undefined;
|
|
185
|
+
const q = query.trim();
|
|
186
|
+
if (!q) {
|
|
187
|
+
setAsyncResults([]);
|
|
188
|
+
setSearching(false);
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
let cancelled = false;
|
|
192
|
+
setSearching(true);
|
|
193
|
+
const t = setTimeout(async () => {
|
|
194
|
+
const r = await search(q).catch(() => []);
|
|
195
|
+
if (!cancelled) {
|
|
196
|
+
setAsyncResults(Array.isArray(r) ? r : []);
|
|
197
|
+
setSearching(false);
|
|
198
|
+
}
|
|
199
|
+
}, 150);
|
|
200
|
+
return () => {
|
|
201
|
+
cancelled = true;
|
|
202
|
+
clearTimeout(t);
|
|
203
|
+
};
|
|
204
|
+
}, [query, search]);
|
|
205
|
+
|
|
206
|
+
const filtered = useMemo(() => {
|
|
207
|
+
if (search) return asyncResults;
|
|
208
|
+
const q = query.trim().toLowerCase();
|
|
209
|
+
if (!q) {
|
|
210
|
+
// Empty query → just the recents.
|
|
211
|
+
return results.filter((r) => r.group === 'Recent Searches');
|
|
212
|
+
}
|
|
213
|
+
return results.filter((r) => {
|
|
214
|
+
const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
|
|
215
|
+
return hay.includes(q);
|
|
216
|
+
});
|
|
217
|
+
}, [search, asyncResults, query, results]);
|
|
218
|
+
|
|
219
|
+
const grouped = useMemo(() => {
|
|
220
|
+
const map = new Map();
|
|
221
|
+
filtered.forEach((r) => {
|
|
222
|
+
const g = r.group || 'Pages';
|
|
223
|
+
if (!map.has(g)) map.set(g, []);
|
|
224
|
+
map.get(g).push(r);
|
|
225
|
+
});
|
|
226
|
+
const known = GROUP_ORDER.filter((g) => map.has(g));
|
|
227
|
+
const extra = [...map.keys()].filter((g) => !GROUP_ORDER.includes(g));
|
|
228
|
+
return [...known, ...extra].map((g) => [g, map.get(g)]);
|
|
229
|
+
}, [filtered]);
|
|
230
|
+
|
|
231
|
+
const flat = useMemo(
|
|
232
|
+
() => grouped.flatMap(([, items]) => items),
|
|
233
|
+
[grouped],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
useEffect(() => setSelected(0), [query]);
|
|
237
|
+
|
|
238
|
+
const pick = useCallback(
|
|
239
|
+
(item) => {
|
|
240
|
+
onSelect?.(item);
|
|
241
|
+
onClose();
|
|
242
|
+
},
|
|
243
|
+
[onSelect, onClose],
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
const onKey = (e) => {
|
|
248
|
+
if (e.key === 'Escape') {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
onClose();
|
|
251
|
+
} else if (e.key === 'ArrowDown') {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
navMode.current = 'keyboard';
|
|
254
|
+
setSelected((s) => Math.min(s + 1, flat.length - 1));
|
|
255
|
+
} else if (e.key === 'ArrowUp') {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
navMode.current = 'keyboard';
|
|
258
|
+
setSelected((s) => Math.max(s - 1, 0));
|
|
259
|
+
} else if (e.key === 'Enter') {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
if (flat[selected]) pick(flat[selected]);
|
|
262
|
+
else onClose();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
window.addEventListener('keydown', onKey);
|
|
266
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
267
|
+
}, [flat, selected, pick, onClose]);
|
|
268
|
+
|
|
269
|
+
let rowIndex = -1;
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div
|
|
273
|
+
className="velu-search__scrim"
|
|
274
|
+
onClick={onClose}
|
|
275
|
+
role="presentation"
|
|
276
|
+
>
|
|
277
|
+
<div
|
|
278
|
+
className="velu-search__palette"
|
|
279
|
+
role="dialog"
|
|
280
|
+
aria-modal="true"
|
|
281
|
+
aria-label="Search"
|
|
282
|
+
onClick={(e) => e.stopPropagation()}
|
|
283
|
+
>
|
|
284
|
+
<div className="velu-search__input-wrap">
|
|
285
|
+
<span className="velu-search__input-icon" aria-hidden="true">
|
|
286
|
+
{resolveIcon('search', { size: '1em' })}
|
|
287
|
+
</span>
|
|
288
|
+
<input
|
|
289
|
+
ref={inputRef}
|
|
290
|
+
className="velu-search__input"
|
|
291
|
+
placeholder={placeholder}
|
|
292
|
+
value={query}
|
|
293
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
294
|
+
spellCheck={false}
|
|
295
|
+
aria-label="Search query"
|
|
296
|
+
/>
|
|
297
|
+
<span className="velu-search__kbd">esc</span>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<div className="velu-search__list" role="listbox">
|
|
301
|
+
{grouped.length === 0 &&
|
|
302
|
+
(search && !query.trim() ? (
|
|
303
|
+
<div className="velu-search__empty">
|
|
304
|
+
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
305
|
+
{resolveIcon('search', { size: '1em' })}
|
|
306
|
+
</span>
|
|
307
|
+
<div className="velu-search__empty-title">Search the docs</div>
|
|
308
|
+
<div className="velu-search__empty-sub">
|
|
309
|
+
Type to find pages and sections.
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
) : searching ? (
|
|
313
|
+
<div className="velu-search__empty">
|
|
314
|
+
<div className="velu-search__empty-sub">Searching…</div>
|
|
315
|
+
</div>
|
|
316
|
+
) : (
|
|
317
|
+
<div className="velu-search__empty">
|
|
318
|
+
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
319
|
+
{resolveIcon('search-x', { size: '1em' })}
|
|
320
|
+
</span>
|
|
321
|
+
<div className="velu-search__empty-title">
|
|
322
|
+
No results for “{query}”
|
|
323
|
+
</div>
|
|
324
|
+
<div className="velu-search__empty-sub">
|
|
325
|
+
Try a different keyword or browse the sidebar.
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
))}
|
|
329
|
+
{grouped.map(([group, items]) => (
|
|
330
|
+
<div key={group}>
|
|
331
|
+
<div className="velu-search__group">{group}</div>
|
|
332
|
+
{items.map((item) => {
|
|
333
|
+
rowIndex += 1;
|
|
334
|
+
const idx = rowIndex;
|
|
335
|
+
return (
|
|
336
|
+
<PaletteRow
|
|
337
|
+
key={item.id}
|
|
338
|
+
item={item}
|
|
339
|
+
selected={idx === selected}
|
|
340
|
+
navMode={navMode}
|
|
341
|
+
onHover={() => {
|
|
342
|
+
navMode.current = 'mouse';
|
|
343
|
+
setSelected(idx);
|
|
344
|
+
}}
|
|
345
|
+
onSelect={() => pick(item)}
|
|
346
|
+
/>
|
|
347
|
+
);
|
|
348
|
+
})}
|
|
349
|
+
</div>
|
|
350
|
+
))}
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* Shown instead of the palette when search is unavailable (e.g. the local
|
|
358
|
+
dev preview has no search index — that's generated when the site deploys).
|
|
359
|
+
Reuses the palette's empty-state styling so it looks at home. */
|
|
360
|
+
function SearchUnavailable({ message, onClose }) {
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
const onKey = (e) => {
|
|
363
|
+
if (e.key === 'Escape') {
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
onClose();
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
window.addEventListener('keydown', onKey);
|
|
369
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
370
|
+
}, [onClose]);
|
|
371
|
+
return (
|
|
372
|
+
<div className="velu-search__scrim" onClick={onClose} role="presentation">
|
|
373
|
+
<div
|
|
374
|
+
className="velu-search__palette"
|
|
375
|
+
role="dialog"
|
|
376
|
+
aria-modal="true"
|
|
377
|
+
aria-label="Search unavailable"
|
|
378
|
+
onClick={(e) => e.stopPropagation()}
|
|
379
|
+
>
|
|
380
|
+
<div className="velu-search__empty">
|
|
381
|
+
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
382
|
+
{resolveIcon('search-x', { size: '1em' })}
|
|
383
|
+
</span>
|
|
384
|
+
<div className="velu-search__empty-title">
|
|
385
|
+
Search isn’t available in the dev preview
|
|
386
|
+
</div>
|
|
387
|
+
<div className="velu-search__empty-sub">{message}</div>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export default function Search({
|
|
395
|
+
results = DEFAULT_RESULTS,
|
|
396
|
+
search,
|
|
397
|
+
placeholder = 'Search documentation',
|
|
398
|
+
onSelect,
|
|
399
|
+
unavailable = false,
|
|
400
|
+
unavailableMessage = 'Full-text search is generated when your site is deployed — it isn’t available in the local preview.',
|
|
401
|
+
className = '',
|
|
402
|
+
...rest
|
|
403
|
+
}) {
|
|
404
|
+
const [open, setOpen] = useState(false);
|
|
405
|
+
// Show ⌘ K on Mac, Ctrl K elsewhere. SSR-safe: default to the
|
|
406
|
+
// non-Mac label (so the server render matches the first client
|
|
407
|
+
// render), then switch on the client if it's actually Mac.
|
|
408
|
+
const [isMac, setIsMac] = useState(false);
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
if (typeof navigator === 'undefined') return;
|
|
411
|
+
const ua = navigator.userAgent || '';
|
|
412
|
+
const plat = navigator.platform || '';
|
|
413
|
+
setIsMac(/Mac|iPhone|iPad|iPod/i.test(plat) || /Mac/i.test(ua));
|
|
414
|
+
}, []);
|
|
415
|
+
|
|
416
|
+
// Global ⌘K (Mac) / Ctrl+K (Win/Linux) opens the palette.
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
const onKey = (e) => {
|
|
419
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
setOpen(true);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
window.addEventListener('keydown', onKey);
|
|
425
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
426
|
+
}, []);
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
<>
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
className={`velu-search__trigger ${className}`.trim()}
|
|
433
|
+
onClick={() => setOpen(true)}
|
|
434
|
+
{...rest}
|
|
435
|
+
>
|
|
436
|
+
<span className="velu-search__trigger-icon" aria-hidden="true">
|
|
437
|
+
{resolveIcon('search', { size: '1.5em' })}
|
|
438
|
+
</span>
|
|
439
|
+
<span className="velu-search__trigger-label">Search…</span>
|
|
440
|
+
<span className="velu-search__kbd">{isMac ? '⌘ K' : 'Ctrl K'}</span>
|
|
441
|
+
</button>
|
|
442
|
+
|
|
443
|
+
{open &&
|
|
444
|
+
(unavailable ? (
|
|
445
|
+
<SearchUnavailable
|
|
446
|
+
message={unavailableMessage}
|
|
447
|
+
onClose={() => setOpen(false)}
|
|
448
|
+
/>
|
|
449
|
+
) : (
|
|
450
|
+
<SearchPalette
|
|
451
|
+
results={results}
|
|
452
|
+
search={search}
|
|
453
|
+
placeholder={placeholder}
|
|
454
|
+
onSelect={onSelect}
|
|
455
|
+
onClose={() => setOpen(false)}
|
|
456
|
+
/>
|
|
457
|
+
))}
|
|
458
|
+
</>
|
|
459
|
+
);
|
|
460
|
+
}
|