@veluai/velu 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/dist/cli.js +11 -0
- package/package.json +52 -0
- package/runtime/velu-ui/base.css +311 -0
- package/runtime/velu-ui/components/Accordion.jsx +64 -0
- package/runtime/velu-ui/components/ApiClient.jsx +121 -0
- package/runtime/velu-ui/components/ApiField.jsx +87 -0
- package/runtime/velu-ui/components/ApiPath.jsx +63 -0
- package/runtime/velu-ui/components/ApiSidebar.jsx +122 -0
- package/runtime/velu-ui/components/AskBar.jsx +71 -0
- package/runtime/velu-ui/components/Callout.jsx +114 -0
- package/runtime/velu-ui/components/Card.jsx +131 -0
- package/runtime/velu-ui/components/Chatbot.jsx +596 -0
- package/runtime/velu-ui/components/CodeBlock.jsx +375 -0
- package/runtime/velu-ui/components/Columns.jsx +56 -0
- package/runtime/velu-ui/components/Field.jsx +81 -0
- package/runtime/velu-ui/components/Image.jsx +163 -0
- package/runtime/velu-ui/components/MethodBadge.jsx +31 -0
- package/runtime/velu-ui/components/NavSelect.jsx +108 -0
- package/runtime/velu-ui/components/PageFeedback.jsx +219 -0
- package/runtime/velu-ui/components/PageFooter.jsx +213 -0
- package/runtime/velu-ui/components/PageHeader.jsx +414 -0
- package/runtime/velu-ui/components/PageNav.jsx +77 -0
- package/runtime/velu-ui/components/PoweredBy.jsx +51 -0
- package/runtime/velu-ui/components/Prompt.jsx +115 -0
- package/runtime/velu-ui/components/Search.jsx +366 -0
- package/runtime/velu-ui/components/Sidebar.jsx +191 -0
- package/runtime/velu-ui/components/Steps.jsx +65 -0
- package/runtime/velu-ui/components/ThemeToggle.jsx +48 -0
- package/runtime/velu-ui/components/Toc.jsx +537 -0
- package/runtime/velu-ui/components/TocBar.jsx +195 -0
- package/runtime/velu-ui/components/Tree.jsx +87 -0
- package/runtime/velu-ui/components/TryItBar.jsx +90 -0
- package/runtime/velu-ui/components/accordion.css +92 -0
- package/runtime/velu-ui/components/api.css +479 -0
- package/runtime/velu-ui/components/ask-bar.css +94 -0
- package/runtime/velu-ui/components/card.css +105 -0
- package/runtime/velu-ui/components/chatbot.css +617 -0
- package/runtime/velu-ui/components/code-block.css +263 -0
- package/runtime/velu-ui/components/docs-layout.css +775 -0
- package/runtime/velu-ui/components/field.css +82 -0
- package/runtime/velu-ui/components/image.css +237 -0
- package/runtime/velu-ui/components/nav-select.css +157 -0
- package/runtime/velu-ui/components/page-feedback.css +241 -0
- package/runtime/velu-ui/components/page-footer.css +130 -0
- package/runtime/velu-ui/components/page-header.css +520 -0
- package/runtime/velu-ui/components/page-nav.css +50 -0
- package/runtime/velu-ui/components/powered-by.css +66 -0
- package/runtime/velu-ui/components/prompt.css +99 -0
- package/runtime/velu-ui/components/search.css +307 -0
- package/runtime/velu-ui/components/sidebar.css +144 -0
- package/runtime/velu-ui/components/steps.css +77 -0
- package/runtime/velu-ui/components/theme-toggle.css +70 -0
- package/runtime/velu-ui/components/toc-bar.css +234 -0
- package/runtime/velu-ui/components/tree.css +49 -0
- package/runtime/velu-ui/index.js +45 -0
- package/runtime/velu-ui/lib/copyText.js +64 -0
- package/runtime/velu-ui/lib/lang-icons.jsx +156 -0
- package/runtime/velu-ui/lib/prism-langs.js +957 -0
- package/runtime/velu-ui/lib/prism-loader.js +74 -0
- package/runtime/velu-ui/lib/resolveIcon.jsx +29 -0
- package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -0
- package/runtime/velu-ui/mdx-components.jsx +85 -0
- package/runtime/velu-ui/primitives/Cluster.jsx +49 -0
- package/runtime/velu-ui/primitives/Stack.jsx +63 -0
- package/runtime/velu-ui/primitives/Switcher.jsx +57 -0
- package/runtime/velu-ui/primitives/stack.css +3 -0
- package/runtime/velu-ui/primitives/switcher.css +25 -0
- package/runtime/velu-ui/styles.css +43 -0
- package/runtime/velu-ui/tokens.css +4 -0
- package/schema/velu.schema.json +167 -0
- package/src/navigation.js +434 -0
- package/src/runtime/App.jsx +1473 -0
- package/src/runtime/client-entry.jsx +22 -0
- package/src/runtime/server-entry.jsx +16 -0
- package/src/template.html +48 -0
- package/templates/starter/ai-tools/claude-code.mdx +26 -0
- package/templates/starter/ai-tools/cursor.mdx +17 -0
- package/templates/starter/api-reference/endpoint/create.mdx +24 -0
- package/templates/starter/api-reference/endpoint/get.mdx +27 -0
- package/templates/starter/api-reference/introduction.mdx +28 -0
- package/templates/starter/development.mdx +19 -0
- package/templates/starter/essentials/code.mdx +28 -0
- package/templates/starter/essentials/images.mdx +29 -0
- package/templates/starter/essentials/markdown.mdx +25 -0
- package/templates/starter/essentials/navigation.mdx +39 -0
- package/templates/starter/essentials/settings.mdx +30 -0
- package/templates/starter/favicon.svg +6 -0
- package/templates/starter/index.mdx +31 -0
- package/templates/starter/quickstart.mdx +31 -0
- package/templates/starter/velu.json +33 -0
|
@@ -0,0 +1,366 @@
|
|
|
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. */
|
|
166
|
+
function SearchPalette({ results, placeholder, onSelect, onClose }) {
|
|
167
|
+
const [query, setQuery] = useState('');
|
|
168
|
+
const [selected, setSelected] = useState(0);
|
|
169
|
+
const inputRef = useRef(null);
|
|
170
|
+
// 'keyboard' | 'mouse' — which input last moved the selection; gates
|
|
171
|
+
// the rows' scrollIntoView so mouse hover doesn't jitter the list.
|
|
172
|
+
const navMode = useRef('keyboard');
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
inputRef.current?.focus();
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const filtered = useMemo(() => {
|
|
179
|
+
const q = query.trim().toLowerCase();
|
|
180
|
+
if (!q) {
|
|
181
|
+
// Empty query → just the recents.
|
|
182
|
+
return results.filter((r) => r.group === 'Recent Searches');
|
|
183
|
+
}
|
|
184
|
+
return results.filter((r) => {
|
|
185
|
+
const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
|
|
186
|
+
return hay.includes(q);
|
|
187
|
+
});
|
|
188
|
+
}, [query, results]);
|
|
189
|
+
|
|
190
|
+
const grouped = useMemo(() => {
|
|
191
|
+
const map = new Map();
|
|
192
|
+
filtered.forEach((r) => {
|
|
193
|
+
if (!map.has(r.group)) map.set(r.group, []);
|
|
194
|
+
map.get(r.group).push(r);
|
|
195
|
+
});
|
|
196
|
+
return GROUP_ORDER.filter((g) => map.has(g)).map((g) => [g, map.get(g)]);
|
|
197
|
+
}, [filtered]);
|
|
198
|
+
|
|
199
|
+
const flat = useMemo(
|
|
200
|
+
() => grouped.flatMap(([, items]) => items),
|
|
201
|
+
[grouped],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
useEffect(() => setSelected(0), [query]);
|
|
205
|
+
|
|
206
|
+
const pick = useCallback(
|
|
207
|
+
(item) => {
|
|
208
|
+
onSelect?.(item);
|
|
209
|
+
onClose();
|
|
210
|
+
},
|
|
211
|
+
[onSelect, onClose],
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
const onKey = (e) => {
|
|
216
|
+
if (e.key === 'Escape') {
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
onClose();
|
|
219
|
+
} else if (e.key === 'ArrowDown') {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
navMode.current = 'keyboard';
|
|
222
|
+
setSelected((s) => Math.min(s + 1, flat.length - 1));
|
|
223
|
+
} else if (e.key === 'ArrowUp') {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
navMode.current = 'keyboard';
|
|
226
|
+
setSelected((s) => Math.max(s - 1, 0));
|
|
227
|
+
} else if (e.key === 'Enter') {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
if (flat[selected]) pick(flat[selected]);
|
|
230
|
+
else onClose();
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
window.addEventListener('keydown', onKey);
|
|
234
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
235
|
+
}, [flat, selected, pick, onClose]);
|
|
236
|
+
|
|
237
|
+
let rowIndex = -1;
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div
|
|
241
|
+
className="velu-search__scrim"
|
|
242
|
+
onClick={onClose}
|
|
243
|
+
role="presentation"
|
|
244
|
+
>
|
|
245
|
+
<div
|
|
246
|
+
className="velu-search__palette"
|
|
247
|
+
role="dialog"
|
|
248
|
+
aria-modal="true"
|
|
249
|
+
aria-label="Search"
|
|
250
|
+
onClick={(e) => e.stopPropagation()}
|
|
251
|
+
>
|
|
252
|
+
<div className="velu-search__input-wrap">
|
|
253
|
+
<span className="velu-search__input-icon" aria-hidden="true">
|
|
254
|
+
{resolveIcon('search', { size: '1em' })}
|
|
255
|
+
</span>
|
|
256
|
+
<input
|
|
257
|
+
ref={inputRef}
|
|
258
|
+
className="velu-search__input"
|
|
259
|
+
placeholder={placeholder}
|
|
260
|
+
value={query}
|
|
261
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
262
|
+
spellCheck={false}
|
|
263
|
+
aria-label="Search query"
|
|
264
|
+
/>
|
|
265
|
+
<span className="velu-search__kbd">esc</span>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div className="velu-search__list" role="listbox">
|
|
269
|
+
{grouped.length === 0 && (
|
|
270
|
+
<div className="velu-search__empty">
|
|
271
|
+
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
272
|
+
{resolveIcon('search-x', { size: '1em' })}
|
|
273
|
+
</span>
|
|
274
|
+
<div className="velu-search__empty-title">
|
|
275
|
+
No results for “{query}”
|
|
276
|
+
</div>
|
|
277
|
+
<div className="velu-search__empty-sub">
|
|
278
|
+
Try a different keyword or browse the sidebar.
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
{grouped.map(([group, items]) => (
|
|
283
|
+
<div key={group}>
|
|
284
|
+
<div className="velu-search__group">{group}</div>
|
|
285
|
+
{items.map((item) => {
|
|
286
|
+
rowIndex += 1;
|
|
287
|
+
const idx = rowIndex;
|
|
288
|
+
return (
|
|
289
|
+
<PaletteRow
|
|
290
|
+
key={item.id}
|
|
291
|
+
item={item}
|
|
292
|
+
selected={idx === selected}
|
|
293
|
+
navMode={navMode}
|
|
294
|
+
onHover={() => {
|
|
295
|
+
navMode.current = 'mouse';
|
|
296
|
+
setSelected(idx);
|
|
297
|
+
}}
|
|
298
|
+
onSelect={() => pick(item)}
|
|
299
|
+
/>
|
|
300
|
+
);
|
|
301
|
+
})}
|
|
302
|
+
</div>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export default function Search({
|
|
311
|
+
results = DEFAULT_RESULTS,
|
|
312
|
+
placeholder = 'Search documentation',
|
|
313
|
+
onSelect,
|
|
314
|
+
className = '',
|
|
315
|
+
...rest
|
|
316
|
+
}) {
|
|
317
|
+
const [open, setOpen] = useState(false);
|
|
318
|
+
// Show ⌘ K on Mac, Ctrl K elsewhere. SSR-safe: default to the
|
|
319
|
+
// non-Mac label (so the server render matches the first client
|
|
320
|
+
// render), then switch on the client if it's actually Mac.
|
|
321
|
+
const [isMac, setIsMac] = useState(false);
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
if (typeof navigator === 'undefined') return;
|
|
324
|
+
const ua = navigator.userAgent || '';
|
|
325
|
+
const plat = navigator.platform || '';
|
|
326
|
+
setIsMac(/Mac|iPhone|iPad|iPod/i.test(plat) || /Mac/i.test(ua));
|
|
327
|
+
}, []);
|
|
328
|
+
|
|
329
|
+
// Global ⌘K (Mac) / Ctrl+K (Win/Linux) opens the palette.
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
const onKey = (e) => {
|
|
332
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
setOpen(true);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
window.addEventListener('keydown', onKey);
|
|
338
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
339
|
+
}, []);
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<>
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
className={`velu-search__trigger ${className}`.trim()}
|
|
346
|
+
onClick={() => setOpen(true)}
|
|
347
|
+
{...rest}
|
|
348
|
+
>
|
|
349
|
+
<span className="velu-search__trigger-icon" aria-hidden="true">
|
|
350
|
+
{resolveIcon('search', { size: '1.5em' })}
|
|
351
|
+
</span>
|
|
352
|
+
<span className="velu-search__trigger-label">Search…</span>
|
|
353
|
+
<span className="velu-search__kbd">{isMac ? '⌘ K' : 'Ctrl K'}</span>
|
|
354
|
+
</button>
|
|
355
|
+
|
|
356
|
+
{open && (
|
|
357
|
+
<SearchPalette
|
|
358
|
+
results={results}
|
|
359
|
+
placeholder={placeholder}
|
|
360
|
+
onSelect={onSelect}
|
|
361
|
+
onClose={() => setOpen(false)}
|
|
362
|
+
/>
|
|
363
|
+
)}
|
|
364
|
+
</>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ChevronRight, ExternalLink } from 'lucide-react';
|
|
3
|
+
import Stack from '../primitives/Stack.jsx';
|
|
4
|
+
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
5
|
+
import scrollIntoNearestView from '../lib/scrollIntoNearestView.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sidebar — docs navigation. Data-driven, recursive (arbitrary depth),
|
|
9
|
+
* tokenized, SSR-safe.
|
|
10
|
+
*
|
|
11
|
+
* Composition: ALL vertical rhythm is the Stack primitive (DRY) — faithful to
|
|
12
|
+
* the source design's flat structure:
|
|
13
|
+
* <Stack as="nav" space=--s1> ← headings + lists alternate here
|
|
14
|
+
* <h5 section/> (uniform --s1 between every child)
|
|
15
|
+
* <Stack as="ul" space=--s0> items </Stack>
|
|
16
|
+
* nested groups: <Stack as="ul" space="0"> (spacing via item padding)
|
|
17
|
+
* sidebar.css holds only what Stack cannot: rails, active state, item rows,
|
|
18
|
+
* chevron, summary, icon sizing.
|
|
19
|
+
*
|
|
20
|
+
* Icons (`section.icon` / `item.icon`) are **lucide icon id strings**
|
|
21
|
+
* (kebab-case), resolved by the shared resolveIcon util (a React node is
|
|
22
|
+
* also accepted). Size + stroke come from .velu-sidebar__icon CSS.
|
|
23
|
+
*
|
|
24
|
+
* Selection is ROUTE-DRIVEN (SSR-correct, reload-safe, URL-shareable): an
|
|
25
|
+
* item is active when its href === activeHref (or active:true). Ancestor
|
|
26
|
+
* groups of the active item auto-open. Router-agnostic via `linkComponent`
|
|
27
|
+
* (defaults to <a>); external items always render a real <a target=_blank>.
|
|
28
|
+
*
|
|
29
|
+
* @typedef {Object} SidebarItem
|
|
30
|
+
* @property {string} label
|
|
31
|
+
* @property {string} [href]
|
|
32
|
+
* @property {string|React.ReactNode} [icon] // lucide id or node
|
|
33
|
+
* @property {boolean} [active]
|
|
34
|
+
* @property {boolean} [external]
|
|
35
|
+
* @property {SidebarItem[]} [items]
|
|
36
|
+
*
|
|
37
|
+
* @param {{ sections: { title: string, icon?: string|React.ReactNode,
|
|
38
|
+
* items: SidebarItem[] }[], activeHref?: string,
|
|
39
|
+
* linkComponent?: React.ElementType, className?: string }} props
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const SidebarCtx = React.createContext({ activeHref: undefined, Link: 'a' });
|
|
43
|
+
|
|
44
|
+
function isActive(item, activeHref) {
|
|
45
|
+
return Boolean(
|
|
46
|
+
item.active || (item.href != null && item.href === activeHref)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasActiveDescendant(item, activeHref) {
|
|
51
|
+
if (isActive(item, activeHref)) return true;
|
|
52
|
+
return (
|
|
53
|
+
Array.isArray(item.items) &&
|
|
54
|
+
item.items.some((c) => hasActiveDescendant(c, activeHref))
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Icon({ icon }) {
|
|
59
|
+
const node = resolveIcon(icon);
|
|
60
|
+
return node ? (
|
|
61
|
+
<span className="velu-sidebar__icon" aria-hidden="true">
|
|
62
|
+
{node}
|
|
63
|
+
</span>
|
|
64
|
+
) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Chevron() {
|
|
68
|
+
return (
|
|
69
|
+
<ChevronRight
|
|
70
|
+
className="velu-sidebar__chevron"
|
|
71
|
+
aria-hidden="true"
|
|
72
|
+
focusable="false"
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ExternalIcon() {
|
|
78
|
+
return (
|
|
79
|
+
<ExternalLink
|
|
80
|
+
className="velu-sidebar__icon"
|
|
81
|
+
aria-hidden="true"
|
|
82
|
+
focusable="false"
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Recursive node. depth 0 = top-level; depth >= 1 = nested (subitem style). */
|
|
88
|
+
function Node({ item, depth }) {
|
|
89
|
+
const { activeHref, Link } = React.useContext(SidebarCtx);
|
|
90
|
+
const { label, href = '#', icon, external, items } = item;
|
|
91
|
+
const base = depth === 0 ? 'velu-sidebar__item' : 'velu-sidebar__subitem';
|
|
92
|
+
|
|
93
|
+
if (items && items.length) {
|
|
94
|
+
return (
|
|
95
|
+
<li>
|
|
96
|
+
<details open={hasActiveDescendant(item, activeHref) || undefined}>
|
|
97
|
+
<summary className={`${base} velu-sidebar__summary`}>
|
|
98
|
+
<Icon icon={icon} />
|
|
99
|
+
<span className="velu-sidebar__label">{label}</span>
|
|
100
|
+
<Chevron />
|
|
101
|
+
</summary>
|
|
102
|
+
{/* nested list: Stack with no gap (spacing = item padding-block),
|
|
103
|
+
matching the source's stack-s */}
|
|
104
|
+
<Stack as="ul" space="0" className="velu-sidebar__sublist">
|
|
105
|
+
{items.map((child, i) => (
|
|
106
|
+
<Node key={i} item={child} depth={depth + 1} />
|
|
107
|
+
))}
|
|
108
|
+
</Stack>
|
|
109
|
+
</details>
|
|
110
|
+
</li>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const active = isActive(item, activeHref);
|
|
115
|
+
const cls = base + (active ? ' velu-sidebar__item--active' : '');
|
|
116
|
+
const LinkTag = external ? 'a' : Link;
|
|
117
|
+
const linkProps = external
|
|
118
|
+
? { href, target: '_blank', rel: 'noreferrer' }
|
|
119
|
+
: { href };
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<li>
|
|
123
|
+
<LinkTag
|
|
124
|
+
className={cls}
|
|
125
|
+
aria-current={active ? 'page' : undefined}
|
|
126
|
+
{...linkProps}
|
|
127
|
+
>
|
|
128
|
+
<Icon icon={icon} />
|
|
129
|
+
<span className="velu-sidebar__label">{label}</span>
|
|
130
|
+
{external ? <ExternalIcon /> : null}
|
|
131
|
+
</LinkTag>
|
|
132
|
+
</li>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export default function Sidebar({
|
|
137
|
+
sections = [],
|
|
138
|
+
activeHref,
|
|
139
|
+
linkComponent = 'a',
|
|
140
|
+
className = '',
|
|
141
|
+
...rest
|
|
142
|
+
}) {
|
|
143
|
+
const ctx = React.useMemo(
|
|
144
|
+
() => ({ activeHref, Link: linkComponent }),
|
|
145
|
+
[activeHref, linkComponent]
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Bring the active item into view inside the parent scroll container
|
|
149
|
+
// whenever the active route changes — e.g. after navigation, the
|
|
150
|
+
// selected page is already visible without the user scrolling the
|
|
151
|
+
// sidebar manually. Uses the shared util (instead of native
|
|
152
|
+
// scrollIntoView) so `scroll-padding-*` on the parent — which the
|
|
153
|
+
// docs layout uses to mark the footer-eclipsed band as off-limits —
|
|
154
|
+
// is reliably honoured.
|
|
155
|
+
const rootRef = React.useRef(null);
|
|
156
|
+
React.useEffect(() => {
|
|
157
|
+
const el = rootRef.current?.querySelector('[aria-current="page"]');
|
|
158
|
+
scrollIntoNearestView(el);
|
|
159
|
+
}, [activeHref]);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<SidebarCtx.Provider value={ctx}>
|
|
163
|
+
<Stack
|
|
164
|
+
ref={rootRef}
|
|
165
|
+
as="nav"
|
|
166
|
+
space="var(--s0)"
|
|
167
|
+
className={`velu-sidebar ${className}`.trim()}
|
|
168
|
+
aria-label="Documentation"
|
|
169
|
+
{...rest}
|
|
170
|
+
>
|
|
171
|
+
{/* Each section is its own Stack so the heading sits TIGHT to
|
|
172
|
+
its list (small inner gap), while the nav's larger gap
|
|
173
|
+
separates one section from the next — compact but still
|
|
174
|
+
visibly grouped. */}
|
|
175
|
+
{sections.map((section, i) => (
|
|
176
|
+
<Stack key={i} space="var(--s-4)">
|
|
177
|
+
<h5 className="velu-sidebar__section">
|
|
178
|
+
<Icon icon={section.icon} />
|
|
179
|
+
{section.title}
|
|
180
|
+
</h5>
|
|
181
|
+
<Stack as="ul" space="var(--s-4)" className="velu-sidebar__list">
|
|
182
|
+
{section.items.map((it, j) => (
|
|
183
|
+
<Node key={j} item={it} depth={0} />
|
|
184
|
+
))}
|
|
185
|
+
</Stack>
|
|
186
|
+
</Stack>
|
|
187
|
+
))}
|
|
188
|
+
</Stack>
|
|
189
|
+
</SidebarCtx.Provider>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React, { Children, cloneElement, isValidElement } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Steps + Step — numbered procedural steps with a continuous accent rail
|
|
5
|
+
* down the left edge.
|
|
6
|
+
*
|
|
7
|
+
* <Steps>
|
|
8
|
+
* <Step title="First Step">These are instructions…</Step>
|
|
9
|
+
* <Step title="Second Step">…</Step>
|
|
10
|
+
* <Step title="Third Step">…</Step>
|
|
11
|
+
* </Steps>
|
|
12
|
+
*
|
|
13
|
+
* Steps assigns 1-based `index` to each <Step> child via cloneElement —
|
|
14
|
+
* callers don't have to number them by hand. A <Step> rendered outside
|
|
15
|
+
* <Steps> falls back to whatever `index` it was given (or 1).
|
|
16
|
+
*
|
|
17
|
+
* Each Step is a 2-column grid:
|
|
18
|
+
*
|
|
19
|
+
* [circle + line] title
|
|
20
|
+
* body
|
|
21
|
+
*
|
|
22
|
+
* The marker column spans both rows. The vertical rail (line below the
|
|
23
|
+
* circle) is `flex: 1`, so it stretches to fill the Step's full height —
|
|
24
|
+
* meaning adjacent steps' rails meet end-to-end with no manual height
|
|
25
|
+
* math. The last step keeps its rail too (matches the design).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export function Step({
|
|
29
|
+
title,
|
|
30
|
+
index = 1,
|
|
31
|
+
children,
|
|
32
|
+
className = '',
|
|
33
|
+
...rest
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<div className={`velu-step ${className}`.trim()} {...rest}>
|
|
37
|
+
<div className="velu-step__circle" aria-hidden="true">
|
|
38
|
+
{index}
|
|
39
|
+
</div>
|
|
40
|
+
<div className="velu-step__line" aria-hidden="true" />
|
|
41
|
+
{title != null && (
|
|
42
|
+
<div className="velu-step__title">{title}</div>
|
|
43
|
+
)}
|
|
44
|
+
{children != null && children !== false && (
|
|
45
|
+
<div className="velu-step__body">{children}</div>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
Step.displayName = 'Step';
|
|
51
|
+
|
|
52
|
+
export default function Steps({ children, className = '', ...rest }) {
|
|
53
|
+
let i = 0;
|
|
54
|
+
const numbered = Children.map(children, (child) => {
|
|
55
|
+
if (!isValidElement(child)) return child;
|
|
56
|
+
if (child.type !== Step && child.type?.displayName !== 'Step') return child;
|
|
57
|
+
i += 1;
|
|
58
|
+
return cloneElement(child, { index: child.props.index ?? i });
|
|
59
|
+
});
|
|
60
|
+
return (
|
|
61
|
+
<div className={`velu-steps ${className}`.trim()} {...rest}>
|
|
62
|
+
{numbered}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Sun, Moon } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ThemeToggle — pill-shaped two-state light/dark switch. The thumb
|
|
6
|
+
* sits at the inline-start in light mode and slides to the inline-end
|
|
7
|
+
* in dark mode. A sun glyph rides the thumb in light, a moon in dark.
|
|
8
|
+
*
|
|
9
|
+
* SSR-safe by design: markup is identical regardless of theme (the
|
|
10
|
+
* server doesn't know it), and the thumb position + which glyph shows
|
|
11
|
+
* are decided purely by CSS via the `[data-theme]` attribute — NOT by
|
|
12
|
+
* React state. So there is no hydration mismatch and no icon flash. JS
|
|
13
|
+
* only handles the click: it flips `data-theme` and persists the
|
|
14
|
+
* explicit choice (which then overrides the OS preference on future
|
|
15
|
+
* visits, per the anti-flash script in the template).
|
|
16
|
+
*/
|
|
17
|
+
export default function ThemeToggle({ className = '', ...rest }) {
|
|
18
|
+
function toggle() {
|
|
19
|
+
const root = document.documentElement;
|
|
20
|
+
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
21
|
+
root.dataset.theme = next;
|
|
22
|
+
try {
|
|
23
|
+
localStorage.setItem('velu-theme', next);
|
|
24
|
+
} catch {
|
|
25
|
+
/* private mode / storage disabled — toggle still works for the session */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={toggle}
|
|
33
|
+
aria-label="Toggle color theme"
|
|
34
|
+
title="Toggle color theme"
|
|
35
|
+
className={`velu-theme-toggle ${className}`.trim()}
|
|
36
|
+
{...rest}
|
|
37
|
+
>
|
|
38
|
+
<Sun
|
|
39
|
+
className="velu-theme-toggle__icon velu-theme-toggle__icon--sun"
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
/>
|
|
42
|
+
<Moon
|
|
43
|
+
className="velu-theme-toggle__icon velu-theme-toggle__icon--moon"
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
/>
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
}
|