@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +57 -0
  3. package/README.md +26 -0
  4. package/SECURITY.md +56 -0
  5. package/THIRD-PARTY-NOTICES.md +3016 -0
  6. package/dist/brand.d.ts +73 -0
  7. package/dist/brand.js +177 -0
  8. package/dist/cn.d.ts +11 -0
  9. package/dist/cn.js +6 -0
  10. package/dist/index.d.ts +1183 -0
  11. package/dist/index.js +2636 -0
  12. package/dist/tokens.css +532 -0
  13. package/package.json +64 -0
  14. package/src/brand/README.md +60 -0
  15. package/src/brand/assets/voltro-favicon.svg +30 -0
  16. package/src/brand/assets/voltro-icon-dark.svg +26 -0
  17. package/src/brand/assets/voltro-icon.svg +14 -0
  18. package/src/brand/assets/voltro-mark-mono.svg +5 -0
  19. package/src/brand/assets/voltro-mark.svg +14 -0
  20. package/src/brand/voltroLogo.tsx +244 -0
  21. package/src/cn.ts +10 -0
  22. package/src/compositions/appShell.tsx +72 -0
  23. package/src/compositions/codeCompare.tsx +88 -0
  24. package/src/compositions/docShell.tsx +112 -0
  25. package/src/compositions/docsLayout.tsx +577 -0
  26. package/src/compositions/featureBento.tsx +103 -0
  27. package/src/compositions/featureGrid.tsx +41 -0
  28. package/src/compositions/heroSection.tsx +55 -0
  29. package/src/compositions/landingCta.tsx +85 -0
  30. package/src/compositions/landingHero.tsx +174 -0
  31. package/src/compositions/landingStats.tsx +99 -0
  32. package/src/compositions/loginCard.tsx +139 -0
  33. package/src/compositions/pageHeader.tsx +58 -0
  34. package/src/compositions/profileMenu.tsx +316 -0
  35. package/src/compositions/siteFooter.tsx +250 -0
  36. package/src/compositions/themeToggle.tsx +82 -0
  37. package/src/cookies.ts +109 -0
  38. package/src/index.ts +160 -0
  39. package/src/primitives/animatedNumber.tsx +73 -0
  40. package/src/primitives/avatar.tsx +39 -0
  41. package/src/primitives/badge.tsx +39 -0
  42. package/src/primitives/button.tsx +53 -0
  43. package/src/primitives/callout.tsx +97 -0
  44. package/src/primitives/card.tsx +68 -0
  45. package/src/primitives/checkbox.tsx +55 -0
  46. package/src/primitives/codeBlock.tsx +134 -0
  47. package/src/primitives/codeWindow.tsx +84 -0
  48. package/src/primitives/dialog.tsx +43 -0
  49. package/src/primitives/docCard.tsx +109 -0
  50. package/src/primitives/docIcons.tsx +268 -0
  51. package/src/primitives/dropdownMenu.tsx +162 -0
  52. package/src/primitives/gridOverlay.tsx +51 -0
  53. package/src/primitives/highlightedCode.tsx +112 -0
  54. package/src/primitives/input.tsx +25 -0
  55. package/src/primitives/label.tsx +19 -0
  56. package/src/primitives/localeSwitcher.tsx +90 -0
  57. package/src/primitives/meshBackdrop.tsx +62 -0
  58. package/src/primitives/scrollReveal.tsx +70 -0
  59. package/src/primitives/searchModal.tsx +304 -0
  60. package/src/primitives/select.tsx +24 -0
  61. package/src/primitives/separator.tsx +24 -0
  62. package/src/primitives/skeleton.tsx +12 -0
  63. package/src/primitives/sparkles.tsx +105 -0
  64. package/src/primitives/steps.tsx +55 -0
  65. package/src/primitives/tabs.tsx +102 -0
  66. package/src/primitives/textarea.tsx +24 -0
  67. package/src/primitives/toast.tsx +44 -0
  68. package/src/primitives/tocScrollSpy.tsx +110 -0
  69. package/src/primitives/toggle.tsx +50 -0
  70. package/src/primitives/toggleGroup.tsx +62 -0
  71. package/src/tokens.css +532 -0
  72. package/src/widgets.tsx +297 -0
@@ -0,0 +1,90 @@
1
+ // LocaleSwitcher — a minimal, SSR-safe language picker. Writes the canonical
2
+ // `voltro:lang` cookie the framework's auto-wired i18n resolver reads, then
3
+ // reloads so the server re-renders in the chosen locale. Same contract as
4
+ // <ProfileMenu>'s language switch, extracted for standalone use in any app that
5
+ // doesn't want the full profile menu (every scaffolded template drops this in).
6
+ //
7
+ // Native <select> on purpose: works everywhere with zero portal/positioning
8
+ // chrome, keyboard-accessible by default, and SSR-safe. Pass `current` (e.g.
9
+ // `useLocale()` from @voltro/i18n) so the server + first client paint agree on
10
+ // the selected option; without it the control hydrates from the cookie after
11
+ // mount (a brief flash from the first option, no hydration mismatch).
12
+
13
+ import {
14
+ useEffect,
15
+ useState,
16
+ type ChangeEvent,
17
+ type ReactNode,
18
+ } from 'react'
19
+ import { cn } from '../cn'
20
+ import { LANG_COOKIE, getCookie, setCookie } from '../cookies'
21
+
22
+ export interface LocaleOption {
23
+ /** Language code written to the cookie (e.g. 'en', 'de'). */
24
+ readonly code: string
25
+ /** Human label shown in the picker (e.g. 'English', 'Deutsch'). */
26
+ readonly label: string
27
+ }
28
+
29
+ export interface LocaleSwitcherProps {
30
+ /** The languages to offer. */
31
+ readonly locales: ReadonlyArray<LocaleOption>
32
+ /** Override the active code. When omitted, hydrates from the `voltro:lang`
33
+ * cookie on mount (falling back to the first option until then). Pass
34
+ * `useLocale()` for a flash-free, mismatch-free control. */
35
+ readonly current?: string
36
+ /** Called with the chosen code before the reload. Return `false` to suppress
37
+ * the reload (e.g. when the consumer has a hot-swap i18n runtime). */
38
+ readonly onChange?: (code: string) => boolean | void
39
+ /** Accessible label for the control. Default `'Language'`. */
40
+ readonly ariaLabel?: string
41
+ readonly className?: string
42
+ }
43
+
44
+ export const LocaleSwitcher = ({
45
+ locales,
46
+ current,
47
+ onChange,
48
+ ariaLabel = 'Language',
49
+ className,
50
+ }: LocaleSwitcherProps): ReactNode => {
51
+ // Hydrate from the cookie on mount; `current` wins when set. The first client
52
+ // render (before this effect) matches the server (both = first option when no
53
+ // `current`), so hydration never mismatches — the cookie value lands as a
54
+ // post-hydration controlled update.
55
+ const [cookieLang, setCookieLang] = useState<string | undefined>(undefined)
56
+ useEffect(() => {
57
+ setCookieLang(getCookie(LANG_COOKIE) ?? undefined)
58
+ }, [])
59
+
60
+ const active = current ?? cookieLang ?? locales[0]?.code ?? ''
61
+
62
+ const handle = (event: ChangeEvent<HTMLSelectElement>): void => {
63
+ const next = event.target.value
64
+ setCookie(LANG_COOKIE, next)
65
+ setCookieLang(next)
66
+ const suppress = onChange?.(next) === false
67
+ if (!suppress && typeof window !== 'undefined') window.location.reload()
68
+ }
69
+
70
+ return (
71
+ <select
72
+ aria-label={ariaLabel}
73
+ value={active}
74
+ onChange={handle}
75
+ className={cn(
76
+ 'h-9 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs ' +
77
+ 'outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring ' +
78
+ 'cursor-pointer',
79
+ className,
80
+ )}
81
+ >
82
+ {locales.map((l) => (
83
+ <option key={l.code} value={l.code}>
84
+ {l.label}
85
+ </option>
86
+ ))}
87
+ </select>
88
+ )
89
+ }
90
+ LocaleSwitcher.displayName = 'LocaleSwitcher'
@@ -0,0 +1,62 @@
1
+ // MeshBackdrop — layered radial gradients that drift slowly over a
2
+ // 60-second loop. Builds the cinematic "ambient nebula" feel without
3
+ // shipping a video or a heavy 3D canvas. CSS-only, GPU-composited
4
+ // transforms — no main-thread work after first paint.
5
+ //
6
+ // Composition: drop this once near the top of the page as
7
+ // `position: absolute; inset: 0;` underneath your content. The blobs
8
+ // drift in opposite directions so the composite movement never
9
+ // repeats visibly.
10
+
11
+ import type { ReactNode } from 'react'
12
+ import { cn } from '../cn'
13
+
14
+ interface MeshBackdropProps {
15
+ readonly className?: string
16
+ /** Brand mode — picks the colour palette. `cool` = violet/cyan
17
+ * (default), `warm` = amber/rose. The kit's `--primary` is honoured
18
+ * via the `from-primary/*` Tailwind utility callers can pass via
19
+ * className. */
20
+ readonly tone?: 'cool' | 'warm'
21
+ }
22
+
23
+ export const MeshBackdrop = ({
24
+ className, tone = 'cool',
25
+ }: MeshBackdropProps): ReactNode => {
26
+ // OK to read tone here — the blobs themselves are styled via inline
27
+ // gradients to avoid Tailwind shipping an extra colour class.
28
+ const blob1 = tone === 'warm'
29
+ ? 'oklch(0.6 0.2 25 / 0.55)'
30
+ : 'oklch(0.55 0.25 290 / 0.55)'
31
+ const blob2 = tone === 'warm'
32
+ ? 'oklch(0.7 0.18 60 / 0.4)'
33
+ : 'oklch(0.65 0.2 220 / 0.4)'
34
+ const blob3 = tone === 'warm'
35
+ ? 'oklch(0.55 0.25 350 / 0.35)'
36
+ : 'oklch(0.5 0.25 310 / 0.45)'
37
+
38
+ return (
39
+ <div
40
+ aria-hidden="true"
41
+ className={cn(
42
+ 'absolute inset-0 overflow-hidden pointer-events-none',
43
+ className,
44
+ )}
45
+ >
46
+ {/* Three slow-drifting blobs at offset positions + offset
47
+ * animation phases. Heavy blur = ambient feel. */}
48
+ <div
49
+ className="absolute -top-1/4 -left-1/4 w-[60vw] h-[60vw] rounded-full blur-3xl opacity-70 motion-safe:animate-mesh-drift-a"
50
+ style={{ background: `radial-gradient(circle, ${blob1}, transparent 60%)` }}
51
+ />
52
+ <div
53
+ className="absolute -bottom-1/3 -right-1/4 w-[55vw] h-[55vw] rounded-full blur-3xl opacity-60 motion-safe:animate-mesh-drift-b"
54
+ style={{ background: `radial-gradient(circle, ${blob2}, transparent 60%)` }}
55
+ />
56
+ <div
57
+ className="absolute top-1/3 left-1/2 w-[40vw] h-[40vw] -translate-x-1/2 rounded-full blur-3xl opacity-50 motion-safe:animate-mesh-drift-c"
58
+ style={{ background: `radial-gradient(circle, ${blob3}, transparent 60%)` }}
59
+ />
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,70 @@
1
+ // ScrollReveal — wrap a section to fade + slide it up when it
2
+ // enters the viewport. One-shot (never animates back out). Uses
3
+ // IntersectionObserver, no scroll listeners, no main-thread work.
4
+ //
5
+ // `prefers-reduced-motion: reduce` short-circuits to instantly visible.
6
+
7
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
8
+ import { cn } from '../cn'
9
+
10
+ interface ScrollRevealProps {
11
+ readonly children: ReactNode
12
+ readonly className?: string
13
+ /** Pixel distance the element rises from. Default 24. */
14
+ readonly distance?: number
15
+ /** Delay before the animation runs, in ms. Default 0. */
16
+ readonly delay?: number
17
+ /** Intersection threshold 0…1. Default 0.15 — element starts
18
+ * animating when 15% of it is in view. */
19
+ readonly threshold?: number
20
+ /** Disable the animation entirely (passthrough). Useful when the
21
+ * parent is already a stagger container. */
22
+ readonly disabled?: boolean
23
+ }
24
+
25
+ export const ScrollReveal = ({
26
+ children, className, distance = 24, delay = 0,
27
+ threshold = 0.15, disabled = false,
28
+ }: ScrollRevealProps): ReactNode => {
29
+ const ref = useRef<HTMLDivElement>(null)
30
+ const [shown, setShown] = useState(disabled)
31
+
32
+ useEffect(() => {
33
+ if (disabled) return
34
+ if (typeof IntersectionObserver === 'undefined') {
35
+ // Old browser — just show instantly.
36
+ setShown(true)
37
+ return
38
+ }
39
+ const node = ref.current
40
+ if (!node) return
41
+ const observer = new IntersectionObserver(
42
+ (entries) => {
43
+ for (const entry of entries) {
44
+ if (entry.isIntersecting) {
45
+ setShown(true)
46
+ observer.disconnect()
47
+ return
48
+ }
49
+ }
50
+ },
51
+ { threshold, rootMargin: '0px 0px -10% 0px' },
52
+ )
53
+ observer.observe(node)
54
+ return () => observer.disconnect()
55
+ }, [threshold, disabled])
56
+
57
+ return (
58
+ <div
59
+ ref={ref}
60
+ className={cn('motion-safe:transition-all motion-safe:duration-700 motion-safe:ease-out', className)}
61
+ style={
62
+ shown
63
+ ? { opacity: 1, transform: 'translate3d(0, 0, 0)', transitionDelay: `${delay}ms` }
64
+ : { opacity: 0, transform: `translate3d(0, ${distance}px, 0)` }
65
+ }
66
+ >
67
+ {children}
68
+ </div>
69
+ )
70
+ }
@@ -0,0 +1,304 @@
1
+ // SearchModal — cmd-k modal with live filter + keyboard navigation.
2
+ //
3
+ // Generic-enough that any app can wire it: pass a `search(query)`
4
+ // function (returns a list of hits in whatever shape you have) and
5
+ // render each hit with the `renderItem` prop. Selecting a hit calls
6
+ // `onSelect(hit)` — typical use: navigate to `hit.href`.
7
+ //
8
+ // Keyboard:
9
+ // ⌘K / Ctrl+K toggle open
10
+ // Esc close
11
+ // ↑ / ↓ move highlight
12
+ // Enter select highlighted hit
13
+ //
14
+ // Visual: Fumadocs/Linear-style centered modal, dark backdrop, blur,
15
+ // rounded sharp-cornered card, monospaced ⌘K + Esc hints in the
16
+ // header. Uses the kit's tokens so light + dark modes both work.
17
+ //
18
+ // Implementation notes:
19
+ // - The modal is conditionally mounted (only in the DOM when open)
20
+ // so we don't pay portal/listener cost on every page.
21
+ // - The cmd-k global listener is always attached when this component
22
+ // is in the tree, so the user can hit ⌘K from anywhere.
23
+
24
+ import {
25
+ useCallback, useEffect, useId, useMemo, useRef, useState,
26
+ type KeyboardEvent, type ReactNode,
27
+ } from 'react'
28
+ import { cn } from '../cn'
29
+ import { SearchIcon, ArrowReturnIcon } from './docIcons'
30
+
31
+ export interface SearchModalProps<TItem> {
32
+ /** Search function — called on every keystroke. Should return
33
+ * results synchronously (the kit doesn't manage async state here;
34
+ * if you need async, debounce + useState in the caller). */
35
+ readonly search: (query: string) => ReadonlyArray<TItem>
36
+ /** Renders a single hit. Receives the item + whether it's currently
37
+ * highlighted (for hover/keyboard focus styles). */
38
+ readonly renderItem: (item: TItem, isActive: boolean) => ReactNode
39
+ /** Stable key for an item — used for React keys + the active index
40
+ * match across renders. */
41
+ readonly itemKey: (item: TItem) => string
42
+ /** Selecting a hit (click OR Enter). Caller typically navigates. */
43
+ readonly onSelect: (item: TItem) => void
44
+
45
+ /** Placeholder for the input. */
46
+ readonly placeholder?: string
47
+ /** Label shown when there are no hits + the query has 1+ char. */
48
+ readonly emptyLabel?: string
49
+ /** Initial empty state — when the input is empty. Show recent
50
+ * searches, sections, whatever fits. */
51
+ readonly empty?: ReactNode
52
+
53
+ /** Open state — controlled. Default uses internal state. */
54
+ readonly open?: boolean
55
+ readonly onOpenChange?: (open: boolean) => void
56
+
57
+ /** User-facing strings for the modal's own chrome (dialog/backdrop
58
+ * aria-labels, footer hint captions, initial-state prompt). Defaults
59
+ * are English so the modal works unconfigured. */
60
+ readonly labels?: SearchModalLabels
61
+ }
62
+
63
+ export interface SearchModalLabels {
64
+ /** `aria-label` of the dialog. Default `'Search'`. */
65
+ readonly dialog?: string
66
+ /** `aria-label` of the click-to-close backdrop. Default `'Close search'`. */
67
+ readonly close?: string
68
+ /** Prompt shown when the input is empty (and no `empty` node given).
69
+ * Default `'Start typing to search.'`. */
70
+ readonly startTyping?: string
71
+ /** Footer caption next to the ↑↓ keys. Default `'navigate'`. */
72
+ readonly navigate?: string
73
+ /** Footer caption next to the return key. Default `'select'`. */
74
+ readonly select?: string
75
+ /** Footer caption next to the ⌘K key. Default `'toggle'`. */
76
+ readonly toggle?: string
77
+ }
78
+
79
+ const DEFAULT_SEARCH_LABELS: Required<SearchModalLabels> = {
80
+ dialog: 'Search',
81
+ close: 'Close search',
82
+ startTyping: 'Start typing to search.',
83
+ navigate: 'navigate',
84
+ select: 'select',
85
+ toggle: 'toggle',
86
+ }
87
+
88
+ export const SearchModal = <TItem,>({
89
+ search, renderItem, itemKey, onSelect,
90
+ placeholder = 'Search…',
91
+ emptyLabel = 'No results.',
92
+ empty,
93
+ open: controlledOpen,
94
+ onOpenChange,
95
+ labels,
96
+ }: SearchModalProps<TItem>): ReactNode => {
97
+ const t = { ...DEFAULT_SEARCH_LABELS, ...labels }
98
+ const isControlled = controlledOpen !== undefined
99
+ const [internalOpen, setInternalOpen] = useState(false)
100
+ const open = isControlled ? controlledOpen : internalOpen
101
+ const setOpen = useCallback((next: boolean): void => {
102
+ if (!isControlled) setInternalOpen(next)
103
+ onOpenChange?.(next)
104
+ }, [isControlled, onOpenChange])
105
+
106
+ const [query, setQuery] = useState('')
107
+ const [activeIdx, setActiveIdx] = useState(0)
108
+ const inputRef = useRef<HTMLInputElement>(null)
109
+ const listboxId = useId()
110
+
111
+ // Re-run search on every query change. Cheap for our doc-sized
112
+ // catalogues; if the result set grows past 1000 entries, debounce
113
+ // here.
114
+ const hits = useMemo(() => (query.trim() ? search(query) : []), [query, search])
115
+
116
+ // Reset highlight + scroll position when results change.
117
+ useEffect(() => { setActiveIdx(0) }, [query])
118
+
119
+ // Global ⌘K / Ctrl+K toggle.
120
+ useEffect(() => {
121
+ const handler = (e: globalThis.KeyboardEvent): void => {
122
+ const k = e.key.toLowerCase()
123
+ if (k === 'k' && (e.metaKey || e.ctrlKey)) {
124
+ e.preventDefault()
125
+ setOpen(!open)
126
+ }
127
+ }
128
+ window.addEventListener('keydown', handler)
129
+ return () => window.removeEventListener('keydown', handler)
130
+ }, [open, setOpen])
131
+
132
+ // Focus the input + reset state on open.
133
+ useEffect(() => {
134
+ if (!open) return
135
+ setQuery('')
136
+ setActiveIdx(0)
137
+ // RAF lets the modal mount before we grab focus, so the autofocus
138
+ // ring doesn't paint twice.
139
+ const id = requestAnimationFrame(() => inputRef.current?.focus())
140
+ return () => cancelAnimationFrame(id)
141
+ }, [open])
142
+
143
+ const close = useCallback((): void => setOpen(false), [setOpen])
144
+
145
+ const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
146
+ if (e.key === 'Escape') { e.preventDefault(); close(); return }
147
+ if (e.key === 'ArrowDown') {
148
+ e.preventDefault()
149
+ setActiveIdx((i) => Math.min(i + 1, Math.max(hits.length - 1, 0)))
150
+ return
151
+ }
152
+ if (e.key === 'ArrowUp') {
153
+ e.preventDefault()
154
+ setActiveIdx((i) => Math.max(i - 1, 0))
155
+ return
156
+ }
157
+ if (e.key === 'Enter') {
158
+ const hit = hits[activeIdx]
159
+ if (hit) { e.preventDefault(); onSelect(hit); close() }
160
+ }
161
+ }
162
+
163
+ if (!open) return null
164
+
165
+ return (
166
+ <div
167
+ role="dialog"
168
+ aria-modal="true"
169
+ aria-label={t.dialog}
170
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] px-4"
171
+ >
172
+ {/* Backdrop — click closes */}
173
+ <button
174
+ type="button"
175
+ aria-label={t.close}
176
+ onClick={close}
177
+ className="absolute inset-0 bg-background/70 backdrop-blur-sm cursor-default"
178
+ />
179
+ {/* Modal card */}
180
+ <div
181
+ className={cn(
182
+ 'relative w-full max-w-xl rounded-xl border border-border bg-card shadow-2xl overflow-hidden',
183
+ 'animate-in fade-in slide-in-from-top-4 duration-150',
184
+ )}
185
+ >
186
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-border">
187
+ <SearchIcon className="w-4 h-4 text-muted-foreground shrink-0" />
188
+ <input
189
+ ref={inputRef}
190
+ type="text"
191
+ value={query}
192
+ onChange={(e) => setQuery(e.target.value)}
193
+ onKeyDown={onKeyDown}
194
+ placeholder={placeholder}
195
+ aria-autocomplete="list"
196
+ aria-controls={listboxId}
197
+ aria-activedescendant={hits[activeIdx] ? `${listboxId}-${itemKey(hits[activeIdx]!)}` : undefined}
198
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/70"
199
+ />
200
+ <kbd className="text-[0.65rem] text-muted-foreground border border-border rounded px-1.5 py-0.5 font-mono">
201
+ Esc
202
+ </kbd>
203
+ </div>
204
+
205
+ <div
206
+ id={listboxId}
207
+ role="listbox"
208
+ className="max-h-[60vh] overflow-y-auto py-1.5"
209
+ >
210
+ {query.trim() === '' ? (
211
+ empty ?? (
212
+ <div className="px-4 py-8 text-center text-sm text-muted-foreground">
213
+ {t.startTyping}
214
+ </div>
215
+ )
216
+ ) : hits.length === 0 ? (
217
+ <div className="px-4 py-8 text-center text-sm text-muted-foreground">
218
+ {emptyLabel}
219
+ </div>
220
+ ) : (
221
+ <ul className="px-1.5">
222
+ {hits.map((hit, i) => {
223
+ const isActive = i === activeIdx
224
+ const key = itemKey(hit)
225
+ return (
226
+ <li
227
+ key={key}
228
+ id={`${listboxId}-${key}`}
229
+ role="option"
230
+ aria-selected={isActive}
231
+ >
232
+ <button
233
+ type="button"
234
+ onClick={() => { onSelect(hit); close() }}
235
+ onMouseMove={() => setActiveIdx(i)}
236
+ className={cn(
237
+ 'w-full text-left rounded-md px-3 py-2 transition-colors',
238
+ isActive
239
+ ? 'bg-accent text-accent-foreground'
240
+ : 'text-foreground hover:bg-accent/50',
241
+ )}
242
+ >
243
+ {renderItem(hit, isActive)}
244
+ </button>
245
+ </li>
246
+ )
247
+ })}
248
+ </ul>
249
+ )}
250
+ </div>
251
+
252
+ <div className="flex items-center justify-between gap-4 px-4 py-2 border-t border-border bg-card/40 text-[0.7rem] text-muted-foreground">
253
+ <div className="inline-flex items-center gap-3">
254
+ <span className="inline-flex items-center gap-1">
255
+ <kbd className="border border-border rounded px-1 py-0.5 font-mono">↑</kbd>
256
+ <kbd className="border border-border rounded px-1 py-0.5 font-mono">↓</kbd>
257
+ {t.navigate}
258
+ </span>
259
+ <span className="inline-flex items-center gap-1">
260
+ <ArrowReturnIcon className="w-3 h-3" /> {t.select}
261
+ </span>
262
+ </div>
263
+ <span className="inline-flex items-center gap-1">
264
+ <kbd className="border border-border rounded px-1 py-0.5 font-mono">⌘K</kbd>
265
+ {t.toggle}
266
+ </span>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ )
271
+ }
272
+
273
+ // ---- SearchTrigger — drop-in button for top bars ----
274
+ //
275
+ // Renders a fake "input" that looks like the search field but is a
276
+ // real button. Click → caller toggles the modal. Keeps the topbar
277
+ // visually consistent across breakpoints while making the modal the
278
+ // single search surface.
279
+
280
+ interface SearchTriggerProps {
281
+ readonly onClick: () => void
282
+ readonly placeholder?: string
283
+ /** `aria-label` for the trigger button. Default `'Open search'`. */
284
+ readonly openLabel?: string
285
+ readonly className?: string
286
+ }
287
+
288
+ export const SearchTrigger = ({
289
+ onClick, placeholder = 'Search docs…', openLabel = 'Open search', className,
290
+ }: SearchTriggerProps): ReactNode => (
291
+ <button
292
+ type="button"
293
+ onClick={onClick}
294
+ aria-label={openLabel}
295
+ className={cn(
296
+ 'inline-flex items-center gap-2 rounded-md border border-border bg-card/40 px-2.5 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:border-input transition-colors w-56 lg:w-64',
297
+ className,
298
+ )}
299
+ >
300
+ <SearchIcon className="w-4 h-4 shrink-0" />
301
+ <span className="flex-1 text-left truncate">{placeholder}</span>
302
+ <kbd className="text-[0.65rem] border border-border rounded px-1 py-0.5 font-mono hidden lg:inline-block">⌘K</kbd>
303
+ </button>
304
+ )
@@ -0,0 +1,24 @@
1
+ // shadcn Select — native <select> styled with theme tokens.
2
+ // Drops Radix Select's custom menu in V1 for zero JS + native a11y;
3
+ // upgrade to Radix when you need search-within-options or rich
4
+ // option content.
5
+
6
+ import type { SelectHTMLAttributes, ReactNode } from 'react'
7
+ import { cn } from '../cn'
8
+
9
+ export const Select = ({ className, children, ...props }: SelectHTMLAttributes<HTMLSelectElement>): ReactNode => (
10
+ <select
11
+ data-slot="select"
12
+ className={cn(
13
+ 'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-xs',
14
+ 'transition-[color,box-shadow] outline-none cursor-pointer',
15
+ 'dark:bg-input/30',
16
+ 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
17
+ 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
18
+ className,
19
+ )}
20
+ {...props}
21
+ >
22
+ {children}
23
+ </select>
24
+ )
@@ -0,0 +1,24 @@
1
+ // shadcn Separator — horizontal / vertical rule.
2
+
3
+ import type { HTMLAttributes, ReactNode } from 'react'
4
+ import { cn } from '../cn'
5
+
6
+ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
7
+ readonly orientation?: 'horizontal' | 'vertical'
8
+ }
9
+
10
+ export const Separator = ({
11
+ className, orientation = 'horizontal', ...props
12
+ }: SeparatorProps): ReactNode => (
13
+ <div
14
+ data-slot="separator"
15
+ role="separator"
16
+ aria-orientation={orientation}
17
+ className={cn(
18
+ 'bg-border shrink-0',
19
+ orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
20
+ className,
21
+ )}
22
+ {...props}
23
+ />
24
+ )
@@ -0,0 +1,12 @@
1
+ // shadcn Skeleton — pulsing placeholder block for loading states.
2
+
3
+ import type { HTMLAttributes, ReactNode } from 'react'
4
+ import { cn } from '../cn'
5
+
6
+ export const Skeleton = ({ className, ...props }: HTMLAttributes<HTMLDivElement>): ReactNode => (
7
+ <div
8
+ data-slot="skeleton"
9
+ className={cn('animate-pulse rounded-md bg-muted', className)}
10
+ {...props}
11
+ />
12
+ )