@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,105 @@
|
|
|
1
|
+
// Sparkles — restrained particle field for hero / opener backdrops.
|
|
2
|
+
//
|
|
3
|
+
// Design goals:
|
|
4
|
+
// - Professional, NOT playful. No rainbow, no big glittery stars,
|
|
5
|
+
// no chaotic motion. Tiny brand-tinted dots that fade in and out
|
|
6
|
+
// out of phase — reads as "atmosphere", not "Christmas tree".
|
|
7
|
+
// - SSR-safe: positions / sizes / delays derive deterministically
|
|
8
|
+
// from the index, so server-rendered HTML matches the first
|
|
9
|
+
// client render (no hydration mismatch, no re-shuffle on each
|
|
10
|
+
// render).
|
|
11
|
+
// - Pointer-events off, aria-hidden — purely decorative.
|
|
12
|
+
// - Respects prefers-reduced-motion: the `motion-safe:` variant on
|
|
13
|
+
// the twinkle animation means each dot freezes at opacity 0 when
|
|
14
|
+
// the user has reduced motion enabled.
|
|
15
|
+
//
|
|
16
|
+
// Drop this absolute-fill inside any container (hero, section, card)
|
|
17
|
+
// and it covers the parent. Tune via `count`, `tone`, `intensity`.
|
|
18
|
+
|
|
19
|
+
import type { ReactNode } from 'react'
|
|
20
|
+
import { cn } from '../cn'
|
|
21
|
+
|
|
22
|
+
interface SparklesProps {
|
|
23
|
+
/** How many dots to render. Default 22. Sweet spot is 16–28 —
|
|
24
|
+
* fewer feels accidental, more starts to feel busy. */
|
|
25
|
+
readonly count?: number
|
|
26
|
+
/** Brand mode. Cool = violet/cyan accents (default); warm = amber/rose. */
|
|
27
|
+
readonly tone?: 'cool' | 'warm'
|
|
28
|
+
/** Density tier. `'subtle'` is the default and what most heros want;
|
|
29
|
+
* `'normal'` bumps the max-opacity peak a bit; `'strong'` makes
|
|
30
|
+
* the field more obvious. Each tier still respects the
|
|
31
|
+
* "professional, not playful" budget. */
|
|
32
|
+
readonly intensity?: 'subtle' | 'normal' | 'strong'
|
|
33
|
+
readonly className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Two prime-ish multipliers per axis. Spreads positions evenly across
|
|
37
|
+
// the container without lining up on a grid. Picked by eye — change
|
|
38
|
+
// only if you want a different distribution.
|
|
39
|
+
const FREQ_X = 137.5
|
|
40
|
+
const FREQ_Y = 73.13
|
|
41
|
+
|
|
42
|
+
const INTENSITY_OPACITY: Record<NonNullable<SparklesProps['intensity']>, string> = {
|
|
43
|
+
subtle: 'opacity-50',
|
|
44
|
+
normal: 'opacity-75',
|
|
45
|
+
strong: 'opacity-100',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Sparkles = ({
|
|
49
|
+
count = 22, tone = 'cool', intensity = 'subtle', className,
|
|
50
|
+
}: SparklesProps): ReactNode => {
|
|
51
|
+
// Dot CORE is near-white so it pops against the brand-tinted
|
|
52
|
+
// MeshBackdrop blobs (those use the same violet/cyan and would
|
|
53
|
+
// swallow same-coloured dots). The HALO is brand-tinted — that's
|
|
54
|
+
// what visually anchors the sparkle to the rest of the palette
|
|
55
|
+
// without coloring the dot itself.
|
|
56
|
+
const haloColors = tone === 'warm'
|
|
57
|
+
? ['oklch(0.78 0.16 60)', 'oklch(0.72 0.18 25)']
|
|
58
|
+
: ['oklch(0.78 0.18 290)', 'oklch(0.74 0.16 220)']
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
className={cn(
|
|
64
|
+
'absolute inset-0 overflow-hidden pointer-events-none',
|
|
65
|
+
INTENSITY_OPACITY[intensity],
|
|
66
|
+
className,
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
{Array.from({ length: count }, (_, i) => {
|
|
70
|
+
// Deterministic pseudo-random spread — golden-ratio-ish step
|
|
71
|
+
// around 0..100% per axis.
|
|
72
|
+
const x = (i * FREQ_X) % 100
|
|
73
|
+
const y = (i * FREQ_Y) % 100
|
|
74
|
+
// 2-4px diameter — small enough to read as a particle, not as
|
|
75
|
+
// a hit-target.
|
|
76
|
+
const size = 2 + (i % 3)
|
|
77
|
+
// Stagger the twinkle so the field never blinks in unison.
|
|
78
|
+
const delay = (i * 0.42) % 6
|
|
79
|
+
const halo = haloColors[i % haloColors.length]!
|
|
80
|
+
return (
|
|
81
|
+
<span
|
|
82
|
+
key={i}
|
|
83
|
+
className="absolute rounded-full motion-safe:animate-twinkle"
|
|
84
|
+
style={{
|
|
85
|
+
left: `${x}%`,
|
|
86
|
+
top: `${y}%`,
|
|
87
|
+
width: `${size}px`,
|
|
88
|
+
height: `${size}px`,
|
|
89
|
+
// Near-white core gives the dot contrast against any
|
|
90
|
+
// backdrop colour — that's what makes it actually read
|
|
91
|
+
// as a sparkle and not vanish into the mesh blobs.
|
|
92
|
+
backgroundColor: 'oklch(0.98 0 0)',
|
|
93
|
+
// Two-stop halo: tight bright ring at 1× size, soft
|
|
94
|
+
// brand-tinted bloom at 4× size. The double shadow is
|
|
95
|
+
// what gives sparkles their characteristic "spark"
|
|
96
|
+
// glow vs a flat dot with a single blur.
|
|
97
|
+
boxShadow: `0 0 ${size}px oklch(0.98 0 0), 0 0 ${size * 4}px ${halo}`,
|
|
98
|
+
animationDelay: `${delay}s`,
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
)
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Steps — numbered vertical list for tutorials / install guides.
|
|
2
|
+
// Renders children as a column with a connecting line on the left
|
|
3
|
+
// and an auto-incrementing badge for each direct child.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// <Steps>
|
|
7
|
+
// <Step title="Install the CLI">
|
|
8
|
+
// <CodeBlock code="pnpm add -g voltro" />
|
|
9
|
+
// </Step>
|
|
10
|
+
// <Step title="Create a project">
|
|
11
|
+
// …
|
|
12
|
+
// </Step>
|
|
13
|
+
// </Steps>
|
|
14
|
+
|
|
15
|
+
import { Children, isValidElement, type ReactNode } from 'react'
|
|
16
|
+
import { cn } from '../cn'
|
|
17
|
+
|
|
18
|
+
interface StepsProps {
|
|
19
|
+
readonly children: ReactNode
|
|
20
|
+
readonly className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface StepProps {
|
|
24
|
+
readonly title?: ReactNode
|
|
25
|
+
readonly children: ReactNode
|
|
26
|
+
readonly className?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Steps = ({ children, className }: StepsProps): ReactNode => {
|
|
30
|
+
// Inject 1-based index into each <Step> via a positional prop. We
|
|
31
|
+
// could clone with React.cloneElement, but a CSS counter is simpler
|
|
32
|
+
// + survives Suspense boundaries.
|
|
33
|
+
const items = Children.toArray(children).filter(isValidElement)
|
|
34
|
+
return (
|
|
35
|
+
<ol className={cn('not-prose my-6 ml-3 space-y-6 border-l border-border', className)}>
|
|
36
|
+
{items.map((child, i) => (
|
|
37
|
+
<li key={i} className="relative pl-8">
|
|
38
|
+
<div className="absolute left-0 top-0 -translate-x-1/2 w-6 h-6 rounded-full bg-card border border-border text-xs font-semibold text-foreground inline-flex items-center justify-center">
|
|
39
|
+
{i + 1}
|
|
40
|
+
</div>
|
|
41
|
+
{child}
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ol>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Step = ({ title, children, className }: StepProps): ReactNode => (
|
|
49
|
+
<div className={cn('-mt-1', className)}>
|
|
50
|
+
{title ? <h3 className="text-base font-semibold text-foreground mb-2">{title}</h3> : null}
|
|
51
|
+
<div className="text-sm text-muted-foreground leading-relaxed space-y-2">
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// shadcn Tabs — CSS-only with the :target trick OR a tiny client
|
|
2
|
+
// state hook. V1 ships a controlled component using React state;
|
|
3
|
+
// island-eligible. Apps wanting zero-JS tabs use the :target form
|
|
4
|
+
// (anchor-based) which we don't ship as a primitive yet.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createContext, useContext, useState,
|
|
8
|
+
type HTMLAttributes, type ReactNode,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import { cn } from '../cn'
|
|
11
|
+
|
|
12
|
+
interface TabsContextValue {
|
|
13
|
+
readonly value: string
|
|
14
|
+
readonly setValue: (v: string) => void
|
|
15
|
+
}
|
|
16
|
+
const TabsContext = createContext<TabsContextValue | null>(null)
|
|
17
|
+
|
|
18
|
+
interface TabsProps extends HTMLAttributes<HTMLDivElement> {
|
|
19
|
+
readonly defaultValue: string
|
|
20
|
+
readonly value?: string
|
|
21
|
+
readonly onValueChange?: (v: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const Tabs = ({
|
|
25
|
+
defaultValue, value, onValueChange, className, children, ...props
|
|
26
|
+
}: TabsProps): ReactNode => {
|
|
27
|
+
const [uncontrolled, setUncontrolled] = useState(defaultValue)
|
|
28
|
+
const current = value ?? uncontrolled
|
|
29
|
+
const setValue = (v: string): void => {
|
|
30
|
+
if (value === undefined) setUncontrolled(v)
|
|
31
|
+
onValueChange?.(v)
|
|
32
|
+
}
|
|
33
|
+
return (
|
|
34
|
+
<TabsContext.Provider value={{ value: current, setValue }}>
|
|
35
|
+
<div data-slot="tabs" className={cn('flex flex-col gap-2', className)} {...props}>
|
|
36
|
+
{children}
|
|
37
|
+
</div>
|
|
38
|
+
</TabsContext.Provider>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const TabsList = ({ className, ...props }: HTMLAttributes<HTMLDivElement>): ReactNode => (
|
|
43
|
+
<div
|
|
44
|
+
data-slot="tabs-list"
|
|
45
|
+
role="tablist"
|
|
46
|
+
className={cn(
|
|
47
|
+
'inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted text-muted-foreground p-1',
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
interface TabsTriggerProps extends HTMLAttributes<HTMLButtonElement> {
|
|
55
|
+
readonly value: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const TabsTrigger = ({ value, className, children, ...props }: TabsTriggerProps): ReactNode => {
|
|
59
|
+
const ctx = useContext(TabsContext)
|
|
60
|
+
if (!ctx) throw new Error('TabsTrigger must be inside <Tabs>')
|
|
61
|
+
const active = ctx.value === value
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
data-slot="tabs-trigger"
|
|
65
|
+
role="tab"
|
|
66
|
+
type="button"
|
|
67
|
+
aria-selected={active}
|
|
68
|
+
onClick={() => ctx.setValue(value)}
|
|
69
|
+
className={cn(
|
|
70
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all',
|
|
71
|
+
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
72
|
+
active
|
|
73
|
+
? 'bg-background text-foreground shadow-sm'
|
|
74
|
+
: 'hover:text-foreground',
|
|
75
|
+
className,
|
|
76
|
+
)}
|
|
77
|
+
{...props}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</button>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface TabsContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
85
|
+
readonly value: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const TabsContent = ({ value, className, children, ...props }: TabsContentProps): ReactNode => {
|
|
89
|
+
const ctx = useContext(TabsContext)
|
|
90
|
+
if (!ctx) throw new Error('TabsContent must be inside <Tabs>')
|
|
91
|
+
if (ctx.value !== value) return null
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
data-slot="tabs-content"
|
|
95
|
+
role="tabpanel"
|
|
96
|
+
className={cn('flex-1 outline-none', className)}
|
|
97
|
+
{...props}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// shadcn-style Textarea — multi-line sibling of <Input>. Used by the
|
|
2
|
+
// data viewer's JSON cell editor (pass `className="font-mono"` for code).
|
|
3
|
+
|
|
4
|
+
import type { TextareaHTMLAttributes, ReactNode } from 'react'
|
|
5
|
+
import { cn } from '../cn'
|
|
6
|
+
|
|
7
|
+
export const Textarea = ({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>): ReactNode => (
|
|
8
|
+
<textarea
|
|
9
|
+
data-slot="textarea"
|
|
10
|
+
className={cn(
|
|
11
|
+
'flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs',
|
|
12
|
+
'transition-[color,box-shadow] outline-none',
|
|
13
|
+
'placeholder:text-muted-foreground',
|
|
14
|
+
'selection:bg-primary selection:text-primary-foreground',
|
|
15
|
+
'dark:bg-input/30',
|
|
16
|
+
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
|
17
|
+
'md:text-sm',
|
|
18
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
19
|
+
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// shadcn Toast — minimal CSS-driven; appears bottom-right when the
|
|
2
|
+
// `[data-state="open"]` attribute is on the root. Apps set + clear
|
|
3
|
+
// the attribute via tiny client JS or via form-redirect query
|
|
4
|
+
// params (`?toast=saved`). Sonner is the canonical shadcn dep —
|
|
5
|
+
// drop in when richer behaviour matters.
|
|
6
|
+
|
|
7
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
8
|
+
import { cn } from '../cn'
|
|
9
|
+
|
|
10
|
+
interface ToastProps extends HTMLAttributes<HTMLDivElement> {
|
|
11
|
+
readonly variant?: 'default' | 'destructive' | 'success'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const variantClasses = {
|
|
15
|
+
default: 'bg-card text-card-foreground border',
|
|
16
|
+
destructive: 'bg-destructive text-white border-destructive',
|
|
17
|
+
success: 'bg-emerald-600 text-white border-emerald-600',
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
export const Toast = ({ variant = 'default', className, children, ...props }: ToastProps): ReactNode => (
|
|
21
|
+
<div
|
|
22
|
+
data-slot="toast"
|
|
23
|
+
role="status"
|
|
24
|
+
aria-live="polite"
|
|
25
|
+
className={cn(
|
|
26
|
+
'fixed bottom-4 right-4 z-50 max-w-md rounded-md border shadow-lg p-4',
|
|
27
|
+
'data-[state=closed]:hidden',
|
|
28
|
+
'data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom-2 data-[state=open]:fade-in-0',
|
|
29
|
+
variantClasses[variant],
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
{...props}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export const ToastTitle = ({ className, ...props }: HTMLAttributes<HTMLParagraphElement>): ReactNode => (
|
|
39
|
+
<p data-slot="toast-title" className={cn('text-sm font-semibold', className)} {...props} />
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
export const ToastDescription = ({ className, ...props }: HTMLAttributes<HTMLParagraphElement>): ReactNode => (
|
|
43
|
+
<p data-slot="toast-description" className={cn('text-sm opacity-90', className)} {...props} />
|
|
44
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// TocScrollSpy — small client-side island that highlights the active
|
|
2
|
+
// TOC link as the user scrolls past the corresponding heading.
|
|
3
|
+
//
|
|
4
|
+
// The DocsLayout renders a static TOC; this island runs in the
|
|
5
|
+
// browser, watches the current h2/h3 headings in the page content,
|
|
6
|
+
// and toggles `data-active` on the matching `[data-toc-link]`.
|
|
7
|
+
// Pure side-effect — no UI of its own.
|
|
8
|
+
//
|
|
9
|
+
// Why this lives as a separate primitive: the DocsLayout itself
|
|
10
|
+
// stays SSR-safe and stateless; the scroll spy needs window APIs.
|
|
11
|
+
// Apps render this once near the top of their page tree (inside the
|
|
12
|
+
// layout, but conditionally — only mount when there IS a TOC).
|
|
13
|
+
|
|
14
|
+
import { useEffect, type ReactNode } from 'react'
|
|
15
|
+
|
|
16
|
+
interface TocScrollSpyProps {
|
|
17
|
+
/** CSS selector for the content container holding the headings.
|
|
18
|
+
* Defaults to `main` since the layout's content lives there. */
|
|
19
|
+
readonly contentSelector?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const TocScrollSpy = ({ contentSelector = 'main' }: TocScrollSpyProps): ReactNode => {
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const main = document.querySelector<HTMLElement>(contentSelector)
|
|
25
|
+
if (!main) return
|
|
26
|
+
|
|
27
|
+
let headings: HTMLElement[] = []
|
|
28
|
+
let frame: number | null = null
|
|
29
|
+
|
|
30
|
+
let active: string | null = null
|
|
31
|
+
const tocLinks = (): HTMLElement[] =>
|
|
32
|
+
Array.from(document.querySelectorAll<HTMLElement>('[data-toc-link]'))
|
|
33
|
+
|
|
34
|
+
const tocLinkFor = (id: string): HTMLElement | null =>
|
|
35
|
+
tocLinks().find((link) => link.dataset.tocLink === id) ?? null
|
|
36
|
+
|
|
37
|
+
const setActive = (id: string | null): void => {
|
|
38
|
+
if (id === active) return
|
|
39
|
+
|
|
40
|
+
for (const prev of tocLinks()) {
|
|
41
|
+
prev?.removeAttribute('data-active')
|
|
42
|
+
prev?.removeAttribute('aria-current')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
active = id
|
|
46
|
+
if (id) {
|
|
47
|
+
const next = tocLinkFor(id)
|
|
48
|
+
next?.setAttribute('data-active', 'true')
|
|
49
|
+
next?.setAttribute('aria-current', 'location')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const updateActive = (): void => {
|
|
54
|
+
frame = null
|
|
55
|
+
if (headings.length === 0) {
|
|
56
|
+
setActive(null)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A stable reading line just below the sticky header. Pick the
|
|
61
|
+
// last heading that has crossed it; before the first heading, keep
|
|
62
|
+
// the first TOC item active so the sidebar never looks inert.
|
|
63
|
+
const readingLine = Math.min(180, Math.max(96, window.innerHeight * 0.24))
|
|
64
|
+
let current = headings[0]?.id ?? null
|
|
65
|
+
|
|
66
|
+
for (const heading of headings) {
|
|
67
|
+
if (heading.getBoundingClientRect().top <= readingLine) {
|
|
68
|
+
current = heading.id
|
|
69
|
+
} else {
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const scrollBottom = window.scrollY + window.innerHeight
|
|
75
|
+
const docBottom = document.documentElement.scrollHeight
|
|
76
|
+
if (docBottom - scrollBottom < 2) current = headings[headings.length - 1]?.id ?? current
|
|
77
|
+
|
|
78
|
+
setActive(current)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const scheduleUpdate = (): void => {
|
|
82
|
+
if (frame !== null) return
|
|
83
|
+
frame = window.requestAnimationFrame(updateActive)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const collectHeadings = (): void => {
|
|
87
|
+
headings = Array.from(main.querySelectorAll<HTMLElement>('h2[id], h3[id]'))
|
|
88
|
+
active = null
|
|
89
|
+
scheduleUpdate()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
collectHeadings()
|
|
93
|
+
|
|
94
|
+
const observer = new MutationObserver(collectHeadings)
|
|
95
|
+
observer.observe(main, { childList: true, subtree: true })
|
|
96
|
+
window.addEventListener('scroll', scheduleUpdate, { passive: true })
|
|
97
|
+
window.addEventListener('resize', scheduleUpdate)
|
|
98
|
+
window.addEventListener('hashchange', scheduleUpdate)
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
observer.disconnect()
|
|
102
|
+
window.removeEventListener('scroll', scheduleUpdate)
|
|
103
|
+
window.removeEventListener('resize', scheduleUpdate)
|
|
104
|
+
window.removeEventListener('hashchange', scheduleUpdate)
|
|
105
|
+
if (frame !== null) window.cancelAnimationFrame(frame)
|
|
106
|
+
}
|
|
107
|
+
}, [contentSelector])
|
|
108
|
+
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// shadcn Toggle — Radix wrapper, token-styled. Building block for
|
|
2
|
+
// <ToggleGroup>; usable standalone as a 2-state press button (e.g.
|
|
3
|
+
// "Bold" / "Italic" formatting toggles).
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
forwardRef,
|
|
7
|
+
type ComponentPropsWithoutRef,
|
|
8
|
+
type ComponentRef,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import * as RT from '@radix-ui/react-toggle'
|
|
11
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
12
|
+
import { cn } from '../cn'
|
|
13
|
+
|
|
14
|
+
export const toggleVariants = cva(
|
|
15
|
+
// Base shared with shadcn's official toggle: same focus + disabled
|
|
16
|
+
// + svg sizing tokens as <Button>.
|
|
17
|
+
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium " +
|
|
18
|
+
"transition-colors hover:bg-muted hover:text-muted-foreground " +
|
|
19
|
+
"disabled:pointer-events-none disabled:opacity-50 " +
|
|
20
|
+
"data-[state=on]:bg-accent data-[state=on]:text-accent-foreground " +
|
|
21
|
+
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 " +
|
|
22
|
+
"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring " +
|
|
23
|
+
"cursor-pointer",
|
|
24
|
+
{
|
|
25
|
+
variants: {
|
|
26
|
+
variant: {
|
|
27
|
+
default: 'bg-transparent',
|
|
28
|
+
outline: 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
|
29
|
+
},
|
|
30
|
+
size: {
|
|
31
|
+
default: 'h-9 px-2 min-w-9',
|
|
32
|
+
sm: 'h-8 px-2 min-w-8',
|
|
33
|
+
lg: 'h-10 px-2.5 min-w-10',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
defaultVariants: { variant: 'default', size: 'default' },
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
export const Toggle = forwardRef<
|
|
41
|
+
ComponentRef<typeof RT.Root>,
|
|
42
|
+
ComponentPropsWithoutRef<typeof RT.Root> & VariantProps<typeof toggleVariants>
|
|
43
|
+
>(({ className, variant, size, ...props }, ref) => (
|
|
44
|
+
<RT.Root
|
|
45
|
+
ref={ref}
|
|
46
|
+
className={cn(toggleVariants({ variant, size, className }))}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
))
|
|
50
|
+
Toggle.displayName = 'Toggle'
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// shadcn ToggleGroup — Radix wrapper. Group of <Toggle>s sharing a
|
|
2
|
+
// single (`type="single"`) or multiple-selection (`type="multiple"`)
|
|
3
|
+
// state. Used in ProfileMenu for the inline theme + language chip
|
|
4
|
+
// rows; usable anywhere a small chip-radio is the right primitive.
|
|
5
|
+
//
|
|
6
|
+
// Variants + size flow down via React Context so individual items
|
|
7
|
+
// don't need to re-declare them — mirrors shadcn's reference.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createContext, forwardRef, useContext,
|
|
11
|
+
type ComponentPropsWithoutRef,
|
|
12
|
+
type ComponentRef,
|
|
13
|
+
} from 'react'
|
|
14
|
+
import * as RTG from '@radix-ui/react-toggle-group'
|
|
15
|
+
import { type VariantProps } from 'class-variance-authority'
|
|
16
|
+
import { cn } from '../cn'
|
|
17
|
+
import { toggleVariants } from './toggle'
|
|
18
|
+
|
|
19
|
+
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
|
|
20
|
+
|
|
21
|
+
const ToggleGroupContext = createContext<ToggleGroupVariants>({
|
|
22
|
+
variant: 'default', size: 'default',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export const ToggleGroup = forwardRef<
|
|
26
|
+
ComponentRef<typeof RTG.Root>,
|
|
27
|
+
ComponentPropsWithoutRef<typeof RTG.Root> & ToggleGroupVariants
|
|
28
|
+
>(({ className, variant, size, children, ...props }, ref) => (
|
|
29
|
+
<RTG.Root
|
|
30
|
+
ref={ref}
|
|
31
|
+
className={cn('inline-flex items-center gap-1', className)}
|
|
32
|
+
{...props}
|
|
33
|
+
>
|
|
34
|
+
<ToggleGroupContext.Provider value={{ variant, size }}>
|
|
35
|
+
{children}
|
|
36
|
+
</ToggleGroupContext.Provider>
|
|
37
|
+
</RTG.Root>
|
|
38
|
+
))
|
|
39
|
+
ToggleGroup.displayName = 'ToggleGroup'
|
|
40
|
+
|
|
41
|
+
export const ToggleGroupItem = forwardRef<
|
|
42
|
+
ComponentRef<typeof RTG.Item>,
|
|
43
|
+
ComponentPropsWithoutRef<typeof RTG.Item> & ToggleGroupVariants
|
|
44
|
+
>(({ className, variant, size, children, ...props }, ref) => {
|
|
45
|
+
const ctx = useContext(ToggleGroupContext)
|
|
46
|
+
return (
|
|
47
|
+
<RTG.Item
|
|
48
|
+
ref={ref}
|
|
49
|
+
className={cn(
|
|
50
|
+
toggleVariants({
|
|
51
|
+
variant: variant ?? ctx.variant,
|
|
52
|
+
size: size ?? ctx.size,
|
|
53
|
+
}),
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</RTG.Item>
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
ToggleGroupItem.displayName = 'ToggleGroupItem'
|