@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,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
|
+
)
|