@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,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'