@voltro/ui-shadcn 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/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +3016 -0
- package/dist/brand.d.ts +73 -0
- package/dist/brand.js +177 -0
- package/dist/cn.d.ts +11 -0
- package/dist/cn.js +6 -0
- package/dist/index.d.ts +1183 -0
- package/dist/index.js +2636 -0
- package/dist/tokens.css +532 -0
- package/package.json +64 -0
- package/src/brand/README.md +60 -0
- package/src/brand/assets/voltro-favicon.svg +30 -0
- package/src/brand/assets/voltro-icon-dark.svg +26 -0
- package/src/brand/assets/voltro-icon.svg +14 -0
- package/src/brand/assets/voltro-mark-mono.svg +5 -0
- package/src/brand/assets/voltro-mark.svg +14 -0
- package/src/brand/voltroLogo.tsx +244 -0
- package/src/cn.ts +10 -0
- package/src/compositions/appShell.tsx +72 -0
- package/src/compositions/codeCompare.tsx +88 -0
- package/src/compositions/docShell.tsx +112 -0
- package/src/compositions/docsLayout.tsx +577 -0
- package/src/compositions/featureBento.tsx +103 -0
- package/src/compositions/featureGrid.tsx +41 -0
- package/src/compositions/heroSection.tsx +55 -0
- package/src/compositions/landingCta.tsx +85 -0
- package/src/compositions/landingHero.tsx +174 -0
- package/src/compositions/landingStats.tsx +99 -0
- package/src/compositions/loginCard.tsx +139 -0
- package/src/compositions/pageHeader.tsx +58 -0
- package/src/compositions/profileMenu.tsx +316 -0
- package/src/compositions/siteFooter.tsx +250 -0
- package/src/compositions/themeToggle.tsx +82 -0
- package/src/cookies.ts +109 -0
- package/src/index.ts +160 -0
- package/src/primitives/animatedNumber.tsx +73 -0
- package/src/primitives/avatar.tsx +39 -0
- package/src/primitives/badge.tsx +39 -0
- package/src/primitives/button.tsx +53 -0
- package/src/primitives/callout.tsx +97 -0
- package/src/primitives/card.tsx +68 -0
- package/src/primitives/checkbox.tsx +55 -0
- package/src/primitives/codeBlock.tsx +134 -0
- package/src/primitives/codeWindow.tsx +84 -0
- package/src/primitives/dialog.tsx +43 -0
- package/src/primitives/docCard.tsx +109 -0
- package/src/primitives/docIcons.tsx +268 -0
- package/src/primitives/dropdownMenu.tsx +162 -0
- package/src/primitives/gridOverlay.tsx +51 -0
- package/src/primitives/highlightedCode.tsx +112 -0
- package/src/primitives/input.tsx +25 -0
- package/src/primitives/label.tsx +19 -0
- package/src/primitives/localeSwitcher.tsx +90 -0
- package/src/primitives/meshBackdrop.tsx +62 -0
- package/src/primitives/scrollReveal.tsx +70 -0
- package/src/primitives/searchModal.tsx +304 -0
- package/src/primitives/select.tsx +24 -0
- package/src/primitives/separator.tsx +24 -0
- package/src/primitives/skeleton.tsx +12 -0
- package/src/primitives/sparkles.tsx +105 -0
- package/src/primitives/steps.tsx +55 -0
- package/src/primitives/tabs.tsx +102 -0
- package/src/primitives/textarea.tsx +24 -0
- package/src/primitives/toast.tsx +44 -0
- package/src/primitives/tocScrollSpy.tsx +110 -0
- package/src/primitives/toggle.tsx +50 -0
- package/src/primitives/toggleGroup.tsx +62 -0
- package/src/tokens.css +532 -0
- package/src/widgets.tsx +297 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
// DocsLayout — Fumadocs-style three-pane shell for documentation.
|
|
2
|
+
//
|
|
3
|
+
// ┌─────────────────────────────────────────────────────┐
|
|
4
|
+
// │ TopBar (sticky) │ ← brand · topNav · search · theme
|
|
5
|
+
// ├──────────┬───────────────────────────────────────┬──┤
|
|
6
|
+
// │ Sidebar │ Main content │T │ ← TOC pane (right)
|
|
7
|
+
// │ (sticky) │ - breadcrumbs │O │
|
|
8
|
+
// │ nav │ - title + description │C │
|
|
9
|
+
// │ groups │ - children │ │
|
|
10
|
+
// │ │ - footer pager (prev / next) │ │
|
|
11
|
+
// │ │ - edit-on-github │ │
|
|
12
|
+
// └──────────┴───────────────────────────────────────┴──┘
|
|
13
|
+
//
|
|
14
|
+
// The shell is dumb data-in / JSX-out — apps pass nav, brand, current
|
|
15
|
+
// page metadata, and Link from their router. All visual primitives
|
|
16
|
+
// (callouts, code blocks, steps) are siblings of this file and
|
|
17
|
+
// composable in any combination.
|
|
18
|
+
|
|
19
|
+
import { useEffect, useState, type ComponentType, type ReactNode } from 'react'
|
|
20
|
+
import { cn } from '../cn'
|
|
21
|
+
import type { ShellLinkProps } from './docShell'
|
|
22
|
+
import { ChevronLeftIcon, ChevronRightIcon } from '../primitives/docIcons'
|
|
23
|
+
import { PageHeader } from './pageHeader'
|
|
24
|
+
|
|
25
|
+
// ---------- Public data types ----------
|
|
26
|
+
|
|
27
|
+
export interface DocsNavItem {
|
|
28
|
+
readonly label: string
|
|
29
|
+
readonly href: string
|
|
30
|
+
/** Visual hint shown next to the label (e.g. "new", "beta"). */
|
|
31
|
+
readonly badge?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A collapsible sub-heading nested one level under a section. Its
|
|
35
|
+
* entries render indented; the chevron + open/close affordance mirror
|
|
36
|
+
* the top-level section. */
|
|
37
|
+
export interface DocsNavSubGroup {
|
|
38
|
+
readonly label: string
|
|
39
|
+
readonly entries: ReadonlyArray<DocsNavItem>
|
|
40
|
+
readonly defaultOpen?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DocsNavGroup {
|
|
44
|
+
readonly section: string
|
|
45
|
+
/** Entries shown directly under the section header, ABOVE the first
|
|
46
|
+
* sub-group. Use for a section's 1-2 intro pages. May be empty. */
|
|
47
|
+
readonly entries: ReadonlyArray<DocsNavItem>
|
|
48
|
+
/** Optional nested sub-groups. When present they render as
|
|
49
|
+
* collapsible sub-headings below `entries`. Sections with no
|
|
50
|
+
* meaningful taxonomy leave this empty and render flat. */
|
|
51
|
+
readonly subgroups?: ReadonlyArray<DocsNavSubGroup>
|
|
52
|
+
/** Flat entries rendered AFTER the sub-groups — a section's trailing
|
|
53
|
+
* ungrouped tail (e.g. plugins not assigned to a sub-group). Keeps
|
|
54
|
+
* them visible + in order without forcing every page into a group. */
|
|
55
|
+
readonly trailingEntries?: ReadonlyArray<DocsNavItem>
|
|
56
|
+
/** Collapse the group by default. Open groups stay open across
|
|
57
|
+
* navigations because the layout keeps its state in React. */
|
|
58
|
+
readonly defaultOpen?: boolean
|
|
59
|
+
/** Render the contents WITHOUT the section header (promoted one level),
|
|
60
|
+
* always expanded. Use when the sidebar is already scoped to this one
|
|
61
|
+
* section by an external switcher, so repeating the section name as a
|
|
62
|
+
* header would be redundant. */
|
|
63
|
+
readonly headerless?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DocsTocEntry {
|
|
67
|
+
readonly id: string
|
|
68
|
+
readonly text: string
|
|
69
|
+
readonly level: 2 | 3
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface DocsBreadcrumb {
|
|
73
|
+
readonly label: string
|
|
74
|
+
readonly href?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** User-facing strings for the layout's own chrome. Defaults are English
|
|
78
|
+
* so the layout works unconfigured; pass catalog values to localize. */
|
|
79
|
+
export interface DocsLayoutLabels {
|
|
80
|
+
/** `aria-label` of the mobile menu-open button. Default `'Toggle menu'`. */
|
|
81
|
+
readonly toggleMenu?: string
|
|
82
|
+
/** `aria-label` of the mobile drawer backdrop. Default `'Close menu'`. */
|
|
83
|
+
readonly closeMenu?: string
|
|
84
|
+
/** `aria-label` of the drawer close button. Default `'Close'`. */
|
|
85
|
+
readonly close?: string
|
|
86
|
+
/** Heading of the mobile drawer. Default `'Menu'`. */
|
|
87
|
+
readonly menu?: string
|
|
88
|
+
/** Heading above the table of contents. Default `'On this page'`. */
|
|
89
|
+
readonly onThisPage?: string
|
|
90
|
+
/** Edit-on-GitHub link text. Default `'Edit this page on GitHub →'`. */
|
|
91
|
+
readonly editPage?: string
|
|
92
|
+
/** Prefix before the last-updated date. Default `'Updated'`. */
|
|
93
|
+
readonly updated?: string
|
|
94
|
+
/** "Previous page" pager caption. Default `'Previous'`. */
|
|
95
|
+
readonly previous?: string
|
|
96
|
+
/** "Next page" pager caption. Default `'Next'`. */
|
|
97
|
+
readonly next?: string
|
|
98
|
+
/** `aria-label` of the prev/next pager nav. Default `'Pagination'`. */
|
|
99
|
+
readonly pagination?: string
|
|
100
|
+
/** `aria-label` of the breadcrumb nav. Default `'Breadcrumb'`. */
|
|
101
|
+
readonly breadcrumb?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DEFAULT_DOCS_LABELS: Required<DocsLayoutLabels> = {
|
|
105
|
+
toggleMenu: 'Toggle menu',
|
|
106
|
+
closeMenu: 'Close menu',
|
|
107
|
+
close: 'Close',
|
|
108
|
+
menu: 'Menu',
|
|
109
|
+
onThisPage: 'On this page',
|
|
110
|
+
editPage: 'Edit this page on GitHub →',
|
|
111
|
+
updated: 'Updated',
|
|
112
|
+
previous: 'Previous',
|
|
113
|
+
next: 'Next',
|
|
114
|
+
pagination: 'Pagination',
|
|
115
|
+
breadcrumb: 'Breadcrumb',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------- Layout props ----------
|
|
119
|
+
|
|
120
|
+
interface DocsLayoutProps {
|
|
121
|
+
readonly brand: ReactNode
|
|
122
|
+
readonly nav: ReadonlyArray<DocsNavGroup>
|
|
123
|
+
readonly children: ReactNode
|
|
124
|
+
/** Current pathname — used to highlight the active nav entry +
|
|
125
|
+
* decide which group opens by default. */
|
|
126
|
+
readonly currentPath?: string
|
|
127
|
+
|
|
128
|
+
/** Top-bar slots — render between brand and the right-hand actions. */
|
|
129
|
+
readonly topNav?: ReactNode
|
|
130
|
+
/** Right-hand actions in the top bar (theme toggle, github link, …). */
|
|
131
|
+
readonly topRight?: ReactNode
|
|
132
|
+
|
|
133
|
+
/** Optional search slot in the top bar. Drop in `<SearchTrigger
|
|
134
|
+
* onClick={…} />` to wire up the kit's cmd-k modal, or any custom
|
|
135
|
+
* search button you want. */
|
|
136
|
+
readonly search?: ReactNode
|
|
137
|
+
|
|
138
|
+
/** Per-page metadata — when set, the layout renders the breadcrumbs,
|
|
139
|
+
* title, description, TOC, prev/next pager, and edit-on-github
|
|
140
|
+
* link. Drop this for landing-style index pages. */
|
|
141
|
+
readonly page?: {
|
|
142
|
+
readonly title: string
|
|
143
|
+
readonly description?: string
|
|
144
|
+
readonly section?: string
|
|
145
|
+
readonly breadcrumbs?: ReadonlyArray<DocsBreadcrumb>
|
|
146
|
+
readonly toc?: ReadonlyArray<DocsTocEntry>
|
|
147
|
+
readonly prev?: { readonly href: string; readonly label: string; readonly description?: string }
|
|
148
|
+
readonly next?: { readonly href: string; readonly label: string; readonly description?: string }
|
|
149
|
+
readonly editUrl?: string
|
|
150
|
+
readonly lastUpdated?: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Optional client-router Link. Same contract as DocShell. */
|
|
154
|
+
readonly LinkComponent?: ComponentType<ShellLinkProps>
|
|
155
|
+
|
|
156
|
+
readonly className?: string
|
|
157
|
+
/** Override the layout's own chrome strings. Defaults are English. */
|
|
158
|
+
readonly labels?: DocsLayoutLabels
|
|
159
|
+
/** Optional footer node — rendered at the very bottom inside the
|
|
160
|
+
* content column. */
|
|
161
|
+
readonly footer?: ReactNode
|
|
162
|
+
/** Optional node pinned to the TOP of the sidebar (above the nav
|
|
163
|
+
* groups), in BOTH the desktop aside and the mobile drawer. Use for
|
|
164
|
+
* a section/category switcher ("root toggle"). Omitted → the sidebar
|
|
165
|
+
* renders exactly as before. */
|
|
166
|
+
readonly sidebarHeader?: ReactNode
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode => (
|
|
170
|
+
<a href={to} className={className}>{children}</a>
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const isActiveHref = (href: string, currentPath: string | undefined): boolean => {
|
|
174
|
+
if (!currentPath) return false
|
|
175
|
+
if (currentPath === href) return true
|
|
176
|
+
// Treat /docs/foo/ as a match for /docs/foo (trailing slash tolerance).
|
|
177
|
+
if (currentPath.replace(/\/$/, '') === href.replace(/\/$/, '')) return true
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------- Sidebar nav group (collapsible) ----------
|
|
182
|
+
|
|
183
|
+
// Shared row styling — leaf links AND collapsible sub-group triggers
|
|
184
|
+
// render identically (same size, colour, padding, left-rail indicator);
|
|
185
|
+
// a sub-group only adds a chevron on the right. Keeps the sidebar one
|
|
186
|
+
// uniform list instead of two visually distinct tiers.
|
|
187
|
+
const NAV_ROW = 'relative w-full py-1.5 pl-4 pr-2 -ml-px text-sm text-left transition-colors border-l-2'
|
|
188
|
+
const navRowColor = (active: boolean): string =>
|
|
189
|
+
active
|
|
190
|
+
? 'text-foreground font-medium border-primary'
|
|
191
|
+
: 'text-muted-foreground hover:text-foreground border-transparent hover:border-border'
|
|
192
|
+
|
|
193
|
+
// One leaf link row.
|
|
194
|
+
const NavLeaf = ({
|
|
195
|
+
entry, currentPath, Link,
|
|
196
|
+
}: {
|
|
197
|
+
readonly entry: DocsNavItem
|
|
198
|
+
readonly currentPath?: string
|
|
199
|
+
readonly Link: ComponentType<ShellLinkProps>
|
|
200
|
+
}): ReactNode => {
|
|
201
|
+
const active = isActiveHref(entry.href, currentPath)
|
|
202
|
+
return (
|
|
203
|
+
<li>
|
|
204
|
+
<Link to={entry.href} prefetch className={cn(NAV_ROW, 'block', navRowColor(active))}>
|
|
205
|
+
<span className="inline-flex items-center gap-2">
|
|
206
|
+
{entry.label}
|
|
207
|
+
{entry.badge ? (
|
|
208
|
+
<span className="text-[0.6rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold">
|
|
209
|
+
{entry.badge}
|
|
210
|
+
</span>
|
|
211
|
+
) : null}
|
|
212
|
+
</span>
|
|
213
|
+
</Link>
|
|
214
|
+
</li>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A collapsible sub-group. Its trigger row is styled EXACTLY like a leaf
|
|
219
|
+
// (NAV_ROW) plus a right-aligned chevron; children render in a further-
|
|
220
|
+
// indented nested rail when open. Expanded by default when it holds the
|
|
221
|
+
// active page.
|
|
222
|
+
const SidebarSubGroup = ({
|
|
223
|
+
subgroup, currentPath, Link,
|
|
224
|
+
}: {
|
|
225
|
+
readonly subgroup: DocsNavSubGroup
|
|
226
|
+
readonly currentPath?: string
|
|
227
|
+
readonly Link: ComponentType<ShellLinkProps>
|
|
228
|
+
}): ReactNode => {
|
|
229
|
+
const containsActive = subgroup.entries.some((e) => isActiveHref(e.href, currentPath))
|
|
230
|
+
const [open, setOpen] = useState(subgroup.defaultOpen ?? containsActive)
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (containsActive) setOpen(true)
|
|
233
|
+
}, [containsActive])
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<li>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={() => setOpen(!open)}
|
|
240
|
+
aria-expanded={open}
|
|
241
|
+
className={cn(NAV_ROW, 'flex items-center justify-between gap-2', navRowColor(false))}
|
|
242
|
+
>
|
|
243
|
+
<span>{subgroup.label}</span>
|
|
244
|
+
<svg
|
|
245
|
+
width="10" height="10" viewBox="0 0 10 10" fill="none"
|
|
246
|
+
className={cn('shrink-0 transition-transform', open ? 'rotate-90' : '')}
|
|
247
|
+
aria-hidden="true"
|
|
248
|
+
>
|
|
249
|
+
<path d="M3 1.5 L6.5 5 L3 8.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
250
|
+
</svg>
|
|
251
|
+
</button>
|
|
252
|
+
{open ? (
|
|
253
|
+
<ul className="border-l border-border ml-4">
|
|
254
|
+
{subgroup.entries.map((e) => (
|
|
255
|
+
<NavLeaf key={e.href} entry={e} {...(currentPath ? { currentPath } : {})} Link={Link} />
|
|
256
|
+
))}
|
|
257
|
+
</ul>
|
|
258
|
+
) : null}
|
|
259
|
+
</li>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const SidebarGroup = ({
|
|
264
|
+
group, currentPath, Link,
|
|
265
|
+
}: {
|
|
266
|
+
readonly group: DocsNavGroup
|
|
267
|
+
readonly currentPath?: string
|
|
268
|
+
readonly Link: ComponentType<ShellLinkProps>
|
|
269
|
+
}): ReactNode => {
|
|
270
|
+
// A section is "open" if explicitly defaultOpen OR contains the active
|
|
271
|
+
// entry (directly or in any sub-group). Local state lets the user
|
|
272
|
+
// toggle either way after that.
|
|
273
|
+
const subgroups = group.subgroups ?? []
|
|
274
|
+
const trailingEntries = group.trailingEntries ?? []
|
|
275
|
+
const headerless = group.headerless ?? false
|
|
276
|
+
const containsActive =
|
|
277
|
+
group.entries.some((e) => isActiveHref(e.href, currentPath)) ||
|
|
278
|
+
subgroups.some((sg) => sg.entries.some((e) => isActiveHref(e.href, currentPath))) ||
|
|
279
|
+
trailingEntries.some((e) => isActiveHref(e.href, currentPath))
|
|
280
|
+
const [open, setOpen] = useState(group.defaultOpen ?? containsActive ?? true)
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (containsActive) setOpen(true)
|
|
283
|
+
}, [containsActive])
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div className="mb-4">
|
|
287
|
+
{headerless ? null : (
|
|
288
|
+
<button
|
|
289
|
+
type="button"
|
|
290
|
+
onClick={() => setOpen(!open)}
|
|
291
|
+
className="flex w-full items-center justify-between text-[0.7rem] uppercase tracking-[0.08em] text-muted-foreground font-semibold py-1.5 hover:text-foreground transition-colors"
|
|
292
|
+
aria-expanded={open}
|
|
293
|
+
>
|
|
294
|
+
<span>{group.section}</span>
|
|
295
|
+
<svg
|
|
296
|
+
width="10" height="10" viewBox="0 0 10 10" fill="none"
|
|
297
|
+
className={cn('transition-transform', open ? 'rotate-90' : '')}
|
|
298
|
+
aria-hidden="true"
|
|
299
|
+
>
|
|
300
|
+
<path d="M3 1.5 L6.5 5 L3 8.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
301
|
+
</svg>
|
|
302
|
+
</button>
|
|
303
|
+
)}
|
|
304
|
+
{(headerless || open) && (group.entries.length > 0 || subgroups.length > 0 || trailingEntries.length > 0) ? (
|
|
305
|
+
<ul className="mt-1 border-l border-border ml-[3px]">
|
|
306
|
+
{group.entries.map((e) => (
|
|
307
|
+
<NavLeaf key={e.href} entry={e} {...(currentPath ? { currentPath } : {})} Link={Link} />
|
|
308
|
+
))}
|
|
309
|
+
{subgroups.map((sg) => (
|
|
310
|
+
<SidebarSubGroup key={sg.label} subgroup={sg} {...(currentPath ? { currentPath } : {})} Link={Link} />
|
|
311
|
+
))}
|
|
312
|
+
{trailingEntries.map((e) => (
|
|
313
|
+
<NavLeaf key={e.href} entry={e} {...(currentPath ? { currentPath } : {})} Link={Link} />
|
|
314
|
+
))}
|
|
315
|
+
</ul>
|
|
316
|
+
) : null}
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------- TOC aside (right column) ----------
|
|
322
|
+
|
|
323
|
+
const TocAside = ({ toc, editUrl, lastUpdated, t }: {
|
|
324
|
+
readonly toc: ReadonlyArray<DocsTocEntry>
|
|
325
|
+
readonly editUrl?: string
|
|
326
|
+
readonly lastUpdated?: string
|
|
327
|
+
readonly t: Required<DocsLayoutLabels>
|
|
328
|
+
}): ReactNode => (
|
|
329
|
+
<aside className="hidden xl:block w-56 shrink-0 pl-8">
|
|
330
|
+
<div className="sticky top-24">
|
|
331
|
+
{toc.length > 0 ? (
|
|
332
|
+
<>
|
|
333
|
+
<div className="text-[0.7rem] uppercase tracking-[0.08em] text-muted-foreground font-semibold mb-3">
|
|
334
|
+
{t.onThisPage}
|
|
335
|
+
</div>
|
|
336
|
+
<ul className="space-y-1.5 text-sm border-l border-border">
|
|
337
|
+
{toc.map((item) => (
|
|
338
|
+
<li key={item.id}>
|
|
339
|
+
<a
|
|
340
|
+
href={`#${item.id}`}
|
|
341
|
+
className={cn(
|
|
342
|
+
'block py-0.5 -ml-px border-l-2 border-transparent text-muted-foreground hover:text-foreground hover:border-border transition-colors data-[active=true]:border-primary data-[active=true]:text-foreground data-[active=true]:font-medium',
|
|
343
|
+
item.level === 3 ? 'pl-6' : 'pl-3',
|
|
344
|
+
)}
|
|
345
|
+
data-toc-link={item.id}
|
|
346
|
+
>
|
|
347
|
+
{item.text}
|
|
348
|
+
</a>
|
|
349
|
+
</li>
|
|
350
|
+
))}
|
|
351
|
+
</ul>
|
|
352
|
+
</>
|
|
353
|
+
) : null}
|
|
354
|
+
{(editUrl || lastUpdated) ? (
|
|
355
|
+
<div className={cn('text-xs text-muted-foreground space-y-1.5', toc.length > 0 ? 'mt-6 pt-6 border-t border-border' : '')}>
|
|
356
|
+
{editUrl ? (
|
|
357
|
+
<a
|
|
358
|
+
href={editUrl}
|
|
359
|
+
target="_blank"
|
|
360
|
+
rel="noopener noreferrer"
|
|
361
|
+
className="block hover:text-foreground transition-colors"
|
|
362
|
+
>
|
|
363
|
+
{t.editPage}
|
|
364
|
+
</a>
|
|
365
|
+
) : null}
|
|
366
|
+
{lastUpdated ? <div>{t.updated} {lastUpdated}</div> : null}
|
|
367
|
+
</div>
|
|
368
|
+
) : null}
|
|
369
|
+
</div>
|
|
370
|
+
</aside>
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
// ---------- Footer pager ----------
|
|
374
|
+
|
|
375
|
+
const FooterPager = ({
|
|
376
|
+
prev, next, Link, t,
|
|
377
|
+
}: {
|
|
378
|
+
readonly prev?: { readonly href: string; readonly label: string; readonly description?: string }
|
|
379
|
+
readonly next?: { readonly href: string; readonly label: string; readonly description?: string }
|
|
380
|
+
readonly Link: ComponentType<ShellLinkProps>
|
|
381
|
+
readonly t: Required<DocsLayoutLabels>
|
|
382
|
+
}): ReactNode => {
|
|
383
|
+
if (!prev && !next) return null
|
|
384
|
+
return (
|
|
385
|
+
<nav aria-label={t.pagination} className="mt-16 pt-8 border-t border-border grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
386
|
+
<div>
|
|
387
|
+
{prev ? (
|
|
388
|
+
<Link
|
|
389
|
+
to={prev.href}
|
|
390
|
+
prefetch
|
|
391
|
+
className="group block p-4 border border-border rounded-lg hover:border-primary/50 hover:bg-card/40 transition-colors h-full"
|
|
392
|
+
>
|
|
393
|
+
<div className="text-xs text-muted-foreground mb-1 inline-flex items-center gap-1">
|
|
394
|
+
<ChevronLeftIcon className="w-3 h-3 transition-transform group-hover:-translate-x-0.5" />
|
|
395
|
+
{t.previous}
|
|
396
|
+
</div>
|
|
397
|
+
<div className="font-medium group-hover:text-primary transition-colors">{prev.label}</div>
|
|
398
|
+
{prev.description ? (
|
|
399
|
+
<div className="text-xs text-muted-foreground mt-1 line-clamp-1">{prev.description}</div>
|
|
400
|
+
) : null}
|
|
401
|
+
</Link>
|
|
402
|
+
) : null}
|
|
403
|
+
</div>
|
|
404
|
+
<div className="md:text-right">
|
|
405
|
+
{next ? (
|
|
406
|
+
<Link
|
|
407
|
+
to={next.href}
|
|
408
|
+
prefetch
|
|
409
|
+
className="group block p-4 border border-border rounded-lg hover:border-primary/50 hover:bg-card/40 transition-colors h-full"
|
|
410
|
+
>
|
|
411
|
+
<div className="text-xs text-muted-foreground mb-1 inline-flex items-center gap-1 md:justify-end">
|
|
412
|
+
{t.next}
|
|
413
|
+
<ChevronRightIcon className="w-3 h-3 transition-transform group-hover:translate-x-0.5" />
|
|
414
|
+
</div>
|
|
415
|
+
<div className="font-medium group-hover:text-primary transition-colors">{next.label}</div>
|
|
416
|
+
{next.description ? (
|
|
417
|
+
<div className="text-xs text-muted-foreground mt-1 line-clamp-1">{next.description}</div>
|
|
418
|
+
) : null}
|
|
419
|
+
</Link>
|
|
420
|
+
) : null}
|
|
421
|
+
</div>
|
|
422
|
+
</nav>
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------- Breadcrumbs ----------
|
|
427
|
+
|
|
428
|
+
const Breadcrumbs = ({
|
|
429
|
+
items, Link, ariaLabel,
|
|
430
|
+
}: {
|
|
431
|
+
readonly items: ReadonlyArray<DocsBreadcrumb>
|
|
432
|
+
readonly Link: ComponentType<ShellLinkProps>
|
|
433
|
+
readonly ariaLabel: string
|
|
434
|
+
}): ReactNode => (
|
|
435
|
+
<nav aria-label={ariaLabel} className="text-xs text-muted-foreground mb-3 flex items-center gap-1.5 flex-wrap">
|
|
436
|
+
{items.map((c, i) => {
|
|
437
|
+
const isLast = i === items.length - 1
|
|
438
|
+
return (
|
|
439
|
+
<span key={i} className="inline-flex items-center gap-1.5">
|
|
440
|
+
{c.href && !isLast ? (
|
|
441
|
+
<Link to={c.href} className="hover:text-foreground transition-colors">{c.label}</Link>
|
|
442
|
+
) : (
|
|
443
|
+
<span className={isLast ? 'text-foreground' : ''}>{c.label}</span>
|
|
444
|
+
)}
|
|
445
|
+
{!isLast ? <span className="text-muted-foreground/50" aria-hidden="true">/</span> : null}
|
|
446
|
+
</span>
|
|
447
|
+
)
|
|
448
|
+
})}
|
|
449
|
+
</nav>
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
// ---------- The layout itself ----------
|
|
453
|
+
|
|
454
|
+
export const DocsLayout = ({
|
|
455
|
+
brand, nav, children, currentPath, topNav, topRight, search,
|
|
456
|
+
page, LinkComponent = PlainLink, className, labels, footer, sidebarHeader,
|
|
457
|
+
}: DocsLayoutProps): ReactNode => {
|
|
458
|
+
const Link = LinkComponent
|
|
459
|
+
const t = { ...DEFAULT_DOCS_LABELS, ...labels }
|
|
460
|
+
// Mobile drawer state — only the inline state, no portal/escape
|
|
461
|
+
// handling here; the search-trigger button can call setMobileOpen
|
|
462
|
+
// via a parent island when full cmd-k lands.
|
|
463
|
+
const [mobileOpen, setMobileOpen] = useState(false)
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<div className={cn('min-h-screen bg-background text-foreground', className)}>
|
|
467
|
+
{/* Top bar — shared <PageHeader> chrome. The PageHeader owns
|
|
468
|
+
* sticky / max-width / h-16 / glass-on-scroll fade. The
|
|
469
|
+
* children below define the docs-specific row: mobile menu,
|
|
470
|
+
* brand, optional topNav, search, topRight slots. */}
|
|
471
|
+
<PageHeader>
|
|
472
|
+
<div className="flex items-center gap-4 min-w-0 flex-1">
|
|
473
|
+
<button
|
|
474
|
+
type="button"
|
|
475
|
+
onClick={() => setMobileOpen(!mobileOpen)}
|
|
476
|
+
className="md:hidden inline-flex items-center justify-center w-8 h-8 rounded-md hover:bg-card transition-colors"
|
|
477
|
+
aria-label={t.toggleMenu}
|
|
478
|
+
aria-expanded={mobileOpen}
|
|
479
|
+
>
|
|
480
|
+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
481
|
+
<path d="M3 5 H15 M3 9 H15 M3 13 H15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
482
|
+
</svg>
|
|
483
|
+
</button>
|
|
484
|
+
<div className="flex items-center gap-2 font-semibold">{brand}</div>
|
|
485
|
+
{topNav ? <div className="hidden md:flex items-center gap-1 ml-4">{topNav}</div> : null}
|
|
486
|
+
</div>
|
|
487
|
+
<div className="flex items-center gap-3">
|
|
488
|
+
{search ? <div className="hidden md:block">{search}</div> : null}
|
|
489
|
+
{topRight ? <div className="flex items-center gap-1">{topRight}</div> : null}
|
|
490
|
+
</div>
|
|
491
|
+
</PageHeader>
|
|
492
|
+
|
|
493
|
+
<div className="mx-auto max-w-7xl flex">
|
|
494
|
+
{/* Sidebar (desktop) */}
|
|
495
|
+
<aside className="hidden md:block w-64 shrink-0 border-r border-border">
|
|
496
|
+
<div className="sticky top-16 max-h-[calc(100vh-4rem)] overflow-y-auto px-6 py-8">
|
|
497
|
+
{sidebarHeader ? <div className="mb-6">{sidebarHeader}</div> : null}
|
|
498
|
+
{nav.map((group) => (
|
|
499
|
+
<SidebarGroup key={group.section} group={group} {...(currentPath ? { currentPath } : {})} Link={Link} />
|
|
500
|
+
))}
|
|
501
|
+
</div>
|
|
502
|
+
</aside>
|
|
503
|
+
|
|
504
|
+
{/* Sidebar (mobile drawer) */}
|
|
505
|
+
{mobileOpen ? (
|
|
506
|
+
<div className="md:hidden fixed inset-0 z-40 flex">
|
|
507
|
+
{/* Backdrop */}
|
|
508
|
+
<div
|
|
509
|
+
role="button"
|
|
510
|
+
aria-label={t.closeMenu}
|
|
511
|
+
className="flex-1 bg-background/70 backdrop-blur-sm"
|
|
512
|
+
onClick={() => setMobileOpen(false)}
|
|
513
|
+
/>
|
|
514
|
+
<aside className="w-72 max-w-[80vw] bg-card border-l border-border overflow-y-auto px-6 py-6">
|
|
515
|
+
<div className="mb-4 flex items-center justify-between">
|
|
516
|
+
<span className="font-semibold">{t.menu}</span>
|
|
517
|
+
<button
|
|
518
|
+
type="button"
|
|
519
|
+
onClick={() => setMobileOpen(false)}
|
|
520
|
+
className="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-background transition-colors"
|
|
521
|
+
aria-label={t.close}
|
|
522
|
+
>
|
|
523
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
524
|
+
<path d="M3 3 L11 11 M11 3 L3 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
525
|
+
</svg>
|
|
526
|
+
</button>
|
|
527
|
+
</div>
|
|
528
|
+
{sidebarHeader ? <div className="mb-4">{sidebarHeader}</div> : null}
|
|
529
|
+
{nav.map((group) => (
|
|
530
|
+
<SidebarGroup key={group.section} group={group} {...(currentPath ? { currentPath } : {})} Link={Link} />
|
|
531
|
+
))}
|
|
532
|
+
</aside>
|
|
533
|
+
</div>
|
|
534
|
+
) : null}
|
|
535
|
+
|
|
536
|
+
{/* Content + TOC */}
|
|
537
|
+
<div className="flex-1 min-w-0 flex">
|
|
538
|
+
<main className="flex-1 min-w-0 px-6 md:px-10 py-10 max-w-3xl">
|
|
539
|
+
{page?.breadcrumbs && page.breadcrumbs.length > 0 ? (
|
|
540
|
+
<Breadcrumbs items={page.breadcrumbs} Link={Link} ariaLabel={t.breadcrumb} />
|
|
541
|
+
) : null}
|
|
542
|
+
{page?.title ? (
|
|
543
|
+
<h1 className="text-3xl md:text-4xl font-bold tracking-tight text-foreground mb-3">
|
|
544
|
+
{page.title}
|
|
545
|
+
</h1>
|
|
546
|
+
) : null}
|
|
547
|
+
{page?.description ? (
|
|
548
|
+
<p className="text-base md:text-lg text-muted-foreground mb-8 leading-relaxed">
|
|
549
|
+
{page.description}
|
|
550
|
+
</p>
|
|
551
|
+
) : null}
|
|
552
|
+
|
|
553
|
+
{children}
|
|
554
|
+
|
|
555
|
+
<FooterPager
|
|
556
|
+
{...(page?.prev ? { prev: page.prev } : {})}
|
|
557
|
+
{...(page?.next ? { next: page.next } : {})}
|
|
558
|
+
Link={Link}
|
|
559
|
+
t={t}
|
|
560
|
+
/>
|
|
561
|
+
|
|
562
|
+
{footer ? <div className="mt-16 pt-8 border-t border-border">{footer}</div> : null}
|
|
563
|
+
</main>
|
|
564
|
+
|
|
565
|
+
{page?.toc !== undefined ? (
|
|
566
|
+
<TocAside
|
|
567
|
+
toc={page.toc}
|
|
568
|
+
{...(page.editUrl ? { editUrl: page.editUrl } : {})}
|
|
569
|
+
{...(page.lastUpdated ? { lastUpdated: page.lastUpdated } : {})}
|
|
570
|
+
t={t}
|
|
571
|
+
/>
|
|
572
|
+
) : null}
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
)
|
|
577
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// FeatureBento — Linear/Vercel-style mixed-size feature grid. Cells
|
|
2
|
+
// support three sizes (`wide`, `tall`, `square`) and arrange into a
|
|
3
|
+
// 4-column grid on desktop, single column on mobile.
|
|
4
|
+
//
|
|
5
|
+
// Each cell has its own hover treatment: a soft glow plus a subtle
|
|
6
|
+
// border lift. Pass an `accent` ReactNode to render a custom
|
|
7
|
+
// illustration / mini-demo as the cell's hero area.
|
|
8
|
+
|
|
9
|
+
import type { ReactNode } from 'react'
|
|
10
|
+
import { cn } from '../cn'
|
|
11
|
+
|
|
12
|
+
export interface BentoCellProps {
|
|
13
|
+
readonly title: ReactNode
|
|
14
|
+
readonly description: ReactNode
|
|
15
|
+
/** Icon shown in the top-left chip. */
|
|
16
|
+
readonly icon?: ReactNode
|
|
17
|
+
/** Custom hero/illustration on the cell. */
|
|
18
|
+
readonly accent?: ReactNode
|
|
19
|
+
/** Cell size: 'wide' (cols 1-2 on desktop), 'tall' (rows 1-2),
|
|
20
|
+
* 'square' (1×1). Default 'square'. */
|
|
21
|
+
readonly size?: 'wide' | 'tall' | 'square'
|
|
22
|
+
/** Optional extra content rendered below the description in the text
|
|
23
|
+
* column — e.g. a "Learn more" link. */
|
|
24
|
+
readonly children?: ReactNode
|
|
25
|
+
readonly className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface BentoProps {
|
|
29
|
+
readonly children: ReactNode
|
|
30
|
+
readonly className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sizeClass: Record<NonNullable<BentoCellProps['size']>, string> = {
|
|
34
|
+
wide: 'md:col-span-2',
|
|
35
|
+
tall: 'md:row-span-2',
|
|
36
|
+
square: '',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const Bento = ({ children, className }: BentoProps): ReactNode => (
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
// Rows are at least 240px tall but expand to fit content — so
|
|
43
|
+
// a long-copy cell never has its text mashed against the
|
|
44
|
+
// bottom edge, and short-copy cells keep visual rhythm.
|
|
45
|
+
'not-prose grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 auto-rows-[minmax(240px,auto)]',
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
export const BentoCell = ({
|
|
54
|
+
title, description, icon, accent, size = 'square', className, children,
|
|
55
|
+
}: BentoCellProps): ReactNode => (
|
|
56
|
+
<div
|
|
57
|
+
className={cn(
|
|
58
|
+
'group relative overflow-hidden rounded-xl border border-border',
|
|
59
|
+
'bg-gradient-to-br from-card/60 to-card/20 backdrop-blur-sm',
|
|
60
|
+
'p-6 flex flex-col',
|
|
61
|
+
// Inner highlight stroke for the lit-from-above feel
|
|
62
|
+
'before:absolute before:inset-x-6 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-primary/40 before:to-transparent',
|
|
63
|
+
// Hover treatment
|
|
64
|
+
'hover:border-primary/40 hover:shadow-[0_8px_32px_-12px_oklch(0.55_0.25_290_/_0.4)]',
|
|
65
|
+
'transition-[border-color,box-shadow] duration-300',
|
|
66
|
+
sizeClass[size],
|
|
67
|
+
className,
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
{/* Hero illustration slot — pinned to the cell's right edge, full
|
|
71
|
+
* height, ~50% wide. Accents inside can use `top-1/2
|
|
72
|
+
* -translate-y-1/2` to vertically centre on the cell. Text on the
|
|
73
|
+
* left stays clear because it has its own constrained max-width
|
|
74
|
+
* below. */}
|
|
75
|
+
{accent ? (
|
|
76
|
+
<div
|
|
77
|
+
aria-hidden="true"
|
|
78
|
+
className="absolute right-0 inset-y-0 w-1/2 overflow-hidden pointer-events-none opacity-70 group-hover:opacity-100 transition-opacity duration-500"
|
|
79
|
+
>
|
|
80
|
+
{accent}
|
|
81
|
+
</div>
|
|
82
|
+
) : null}
|
|
83
|
+
|
|
84
|
+
{/* Content column — explicit max-width so text always stays clear
|
|
85
|
+
* of the right-half accent slot. ~32ch reads as roughly 280px at
|
|
86
|
+
* text-sm, which leaves comfortable breathing room next to any
|
|
87
|
+
* w-28 / w-32 accent in the right half of the cell. */}
|
|
88
|
+
<div className="relative max-w-[32ch]">
|
|
89
|
+
{icon ? (
|
|
90
|
+
<div className="inline-flex items-center justify-center w-9 h-9 rounded-md bg-primary/10 text-primary mb-4 [&_svg]:w-5 [&_svg]:h-5">
|
|
91
|
+
{icon}
|
|
92
|
+
</div>
|
|
93
|
+
) : null}
|
|
94
|
+
<h3 className="text-lg font-semibold text-foreground tracking-tight">
|
|
95
|
+
{title}
|
|
96
|
+
</h3>
|
|
97
|
+
<p className="mt-1.5 text-sm text-muted-foreground leading-relaxed">
|
|
98
|
+
{description}
|
|
99
|
+
</p>
|
|
100
|
+
{children}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|