@cosxai/ui 0.25.0 → 1.0.0-alpha.1

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 (165) hide show
  1. package/CHANGELOG.md +597 -0
  2. package/NOTICE +11 -0
  3. package/README.md +52 -0
  4. package/package.json +57 -7
  5. package/src/brand.css +13 -0
  6. package/src/components/Badge.tsx +68 -0
  7. package/src/components/Button.tsx +178 -0
  8. package/src/components/Card.tsx +42 -0
  9. package/src/components/Checkbox.tsx +67 -0
  10. package/src/components/Dialog.tsx +111 -0
  11. package/src/components/Field.tsx +66 -0
  12. package/src/components/Figure.tsx +30 -0
  13. package/src/components/Icon.tsx +32 -0
  14. package/src/components/IconButton.tsx +55 -0
  15. package/src/components/Input.tsx +74 -0
  16. package/src/components/Logo.tsx +62 -0
  17. package/src/components/Marker.tsx +26 -0
  18. package/src/components/MetaLabel.tsx +38 -0
  19. package/src/components/Radio.tsx +72 -0
  20. package/src/components/Select.tsx +351 -0
  21. package/src/components/Spinner.tsx +20 -0
  22. package/src/components/Switch.tsx +67 -0
  23. package/src/components/Table.tsx +196 -0
  24. package/src/components/Tabs.tsx +124 -0
  25. package/src/components/Tag.tsx +35 -0
  26. package/src/components/Textarea.tsx +46 -0
  27. package/src/components/ThemeProvider.tsx +80 -0
  28. package/src/components/Toast.tsx +159 -0
  29. package/src/components/Tooltip.tsx +57 -0
  30. package/src/components/core.test.tsx +242 -0
  31. package/src/components/forms-control.ts +36 -0
  32. package/src/components/forms.test.tsx +280 -0
  33. package/src/components/logo-art.ts +4 -0
  34. package/src/components/overlays.test.tsx +245 -0
  35. package/src/components/useButtonAction.ts +57 -0
  36. package/src/fonts.css +20 -0
  37. package/src/index.ts +34 -24
  38. package/src/lib/cn.ts +23 -6
  39. package/src/modes.css +10 -0
  40. package/src/styles.css +19 -0
  41. package/src/theme.css +143 -0
  42. package/src/tokens/colors.css +112 -0
  43. package/src/tokens/compat.css +36 -0
  44. package/src/tokens/elevation.css +14 -0
  45. package/src/tokens/motion.css +42 -0
  46. package/src/tokens/spacing.css +55 -0
  47. package/src/tokens/typography-cjk.css +42 -0
  48. package/src/tokens/typography.css +81 -0
  49. package/src/actionbar/ActionBar.tsx +0 -837
  50. package/src/actionbar/ActionBarButton.tsx +0 -111
  51. package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
  52. package/src/actionbar/ActionBarModeHandle.tsx +0 -171
  53. package/src/actionbar/ActionBarProvider.tsx +0 -91
  54. package/src/actionbar/actionbar-context.ts +0 -45
  55. package/src/actionbar/index.ts +0 -28
  56. package/src/actionbar/types.ts +0 -147
  57. package/src/actionbar/useActionBarItems.ts +0 -60
  58. package/src/actionbar/useActionBarMode.ts +0 -144
  59. package/src/actionbar/useActionBarPanel.ts +0 -56
  60. package/src/actionbar/useActionBarStatusDot.ts +0 -49
  61. package/src/actionbar/useActionBarToast.ts +0 -33
  62. package/src/ambient/AmbientBackdrop.tsx +0 -74
  63. package/src/ambient/CommandInput.tsx +0 -107
  64. package/src/ambient/SuperbarStrip.tsx +0 -36
  65. package/src/ambient/index.ts +0 -6
  66. package/src/bento/BentoCell.tsx +0 -66
  67. package/src/bento/BentoGrid.tsx +0 -42
  68. package/src/bento/index.ts +0 -2
  69. package/src/command/CommandPalette.tsx +0 -329
  70. package/src/command/CommandProvider.tsx +0 -57
  71. package/src/command/command-context.ts +0 -12
  72. package/src/command/index.ts +0 -6
  73. package/src/command/rank.ts +0 -50
  74. package/src/command/types.ts +0 -37
  75. package/src/command/useCommandSource.ts +0 -37
  76. package/src/dialogs/DialogsProvider.tsx +0 -216
  77. package/src/dialogs/Modal.tsx +0 -260
  78. package/src/dialogs/Toast.tsx +0 -85
  79. package/src/dialogs/dialogs-context.ts +0 -6
  80. package/src/dialogs/index.ts +0 -10
  81. package/src/dialogs/types.ts +0 -37
  82. package/src/dialogs/useDialogs.ts +0 -8
  83. package/src/editorial/EditorialSpotlight.tsx +0 -63
  84. package/src/editorial/Folio.tsx +0 -52
  85. package/src/editorial/PlateMarker.tsx +0 -33
  86. package/src/editorial/RomanSection.tsx +0 -65
  87. package/src/editorial/RunningMarginalia.tsx +0 -65
  88. package/src/editorial/index.ts +0 -10
  89. package/src/hooks/index.ts +0 -5
  90. package/src/hooks/useKeyboardHotkey.ts +0 -93
  91. package/src/hooks/useReducedMotion.ts +0 -20
  92. package/src/hooks/useViewport.ts +0 -61
  93. package/src/layout/Breadcrumb.tsx +0 -74
  94. package/src/layout/LeftNavRail.tsx +0 -139
  95. package/src/layout/MobileTabBar.tsx +0 -101
  96. package/src/layout/NavItem.tsx +0 -128
  97. package/src/layout/NavSearchTrigger.tsx +0 -88
  98. package/src/layout/NavSection.tsx +0 -40
  99. package/src/layout/RightSidebarPanel.tsx +0 -111
  100. package/src/layout/Shell.tsx +0 -91
  101. package/src/layout/SidePanel.tsx +0 -316
  102. package/src/layout/StickyBanner.tsx +0 -83
  103. package/src/layout/Topbar.tsx +0 -68
  104. package/src/layout/index.ts +0 -24
  105. package/src/layout/useNavRailState.ts +0 -69
  106. package/src/lib/time-utils.ts +0 -44
  107. package/src/neobrutalism/Marquee.tsx +0 -81
  108. package/src/neobrutalism/Sticker.tsx +0 -71
  109. package/src/neobrutalism/index.ts +0 -4
  110. package/src/primitives/Avatar.tsx +0 -53
  111. package/src/primitives/Button.tsx +0 -67
  112. package/src/primitives/Card.tsx +0 -41
  113. package/src/primitives/Checkbox.tsx +0 -87
  114. package/src/primitives/Chip.tsx +0 -191
  115. package/src/primitives/Combobox.tsx +0 -507
  116. package/src/primitives/CopyField.tsx +0 -139
  117. package/src/primitives/CountBadge.tsx +0 -50
  118. package/src/primitives/Input.tsx +0 -204
  119. package/src/primitives/Kbd.tsx +0 -45
  120. package/src/primitives/MentionCombobox.tsx +0 -612
  121. package/src/primitives/MultiCombobox.tsx +0 -327
  122. package/src/primitives/PageHeader.tsx +0 -77
  123. package/src/primitives/Radio.tsx +0 -131
  124. package/src/primitives/SegmentedControl.tsx +0 -135
  125. package/src/primitives/Select.tsx +0 -779
  126. package/src/primitives/SignaturePad.tsx +0 -461
  127. package/src/primitives/Tag.tsx +0 -56
  128. package/src/primitives/Textarea.tsx +0 -62
  129. package/src/primitives/ToggleSwitch.tsx +0 -79
  130. package/src/primitives/Tooltip.tsx +0 -213
  131. package/src/primitives/index.ts +0 -53
  132. package/src/pwa/InstallPromptBanner.tsx +0 -132
  133. package/src/pwa/index.ts +0 -4
  134. package/src/pwa/manifest.template.json +0 -20
  135. package/src/pwa/registerSW.ts +0 -55
  136. package/src/riso/Halftone.tsx +0 -85
  137. package/src/riso/Misregister.tsx +0 -63
  138. package/src/riso/RisoStamp.tsx +0 -76
  139. package/src/riso/index.ts +0 -3
  140. package/src/sketch/HandUnderline.tsx +0 -53
  141. package/src/sketch/RoughArrow.tsx +0 -91
  142. package/src/sketch/RoughBox.tsx +0 -73
  143. package/src/sketch/StickyNote.tsx +0 -56
  144. package/src/sketch/index.ts +0 -4
  145. package/src/styles/base.css +0 -186
  146. package/src/styles/chrome-ambient.css +0 -235
  147. package/src/styles/chrome-bento.css +0 -192
  148. package/src/styles/chrome-editorial.css +0 -168
  149. package/src/styles/chrome-neobrutalism.css +0 -326
  150. package/src/styles/chrome-riso.css +0 -339
  151. package/src/styles/chrome-sketch.css +0 -362
  152. package/src/styles/chrome-swiss.css +0 -265
  153. package/src/styles/chrome-terminal.css +0 -272
  154. package/src/styles/fonts.css +0 -1034
  155. package/src/styles/index.css +0 -343
  156. package/src/styles/tokens.css +0 -941
  157. package/src/terminal/AsciiBox.tsx +0 -65
  158. package/src/terminal/BrailleSpinner.tsx +0 -46
  159. package/src/terminal/index.ts +0 -4
  160. package/src/theme/ThemeProvider.tsx +0 -99
  161. package/src/theme/index.ts +0 -6
  162. package/src/theme/inline-script.ts +0 -40
  163. package/src/theme/theme-context.ts +0 -7
  164. package/src/theme/types.ts +0 -39
  165. package/src/theme/useTheme.ts +0 -8
@@ -0,0 +1,32 @@
1
+ import type { LucideIcon, LucideProps } from 'lucide-react';
2
+
3
+ import { cn } from '../lib/cn';
4
+
5
+ export type IconProps = Omit<LucideProps, 'ref'> & {
6
+ /** A Lucide icon component: `import { Share2 } from 'lucide-react'`. */
7
+ icon: LucideIcon;
8
+ /** Accessible name. Without it the icon is decorative (aria-hidden). */
9
+ label?: string | undefined;
10
+ /** px. 14–16 inside controls, 16–20 on its own. @default 16 */
11
+ size?: number | undefined;
12
+ };
13
+
14
+ /**
15
+ * Icon — a Lucide line icon in currentColor. Lucide is the system's icon
16
+ * set (3.0 flagged it as a substitute; confirmed for 1.0). Status is never
17
+ * an icon — it is a Badge.
18
+ */
19
+ export function Icon({ icon: Glyph, label, size = 16, className, strokeWidth = 1.75, ...rest }: IconProps) {
20
+ return (
21
+ <Glyph
22
+ size={size}
23
+ strokeWidth={strokeWidth}
24
+ aria-hidden={label ? undefined : true}
25
+ aria-label={label}
26
+ role={label ? 'img' : undefined}
27
+ focusable="false"
28
+ className={cn('shrink-0', className)}
29
+ {...rest}
30
+ />
31
+ );
32
+ }
@@ -0,0 +1,55 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { LucideIcon } from 'lucide-react';
3
+ import type { ComponentProps } from 'react';
4
+
5
+ import { cn } from '../lib/cn';
6
+ import { formatCount } from './Button';
7
+ import { Icon } from './Icon';
8
+
9
+ const iconButton = cva(
10
+ [
11
+ 'relative inline-grid shrink-0 place-items-center rounded-md border p-0',
12
+ 'transition-[background-color,border-color] duration-[120ms] ease-standard',
13
+ 'cursor-pointer outline-none focus-visible:shadow-(--focus-ring)',
14
+ 'disabled:cursor-not-allowed disabled:opacity-40',
15
+ ],
16
+ {
17
+ variants: {
18
+ variant: {
19
+ outline: 'border-rule bg-transparent text-fg hover:border-fg',
20
+ ghost: 'border-transparent bg-transparent text-fg hover:bg-hover',
21
+ solid: 'border-ink bg-ink text-linen hover:bg-ink-raised ink:border-yellow-accent ink:bg-yellow-accent ink:text-ink ink:hover:bg-yellow-accent-hover',
22
+ },
23
+ size: { sm: 'size-8', md: 'size-[38px]', lg: 'size-11' },
24
+ },
25
+ defaultVariants: { variant: 'ghost', size: 'md' },
26
+ },
27
+ );
28
+
29
+ export type IconButtonProps = Omit<ComponentProps<'button'>, 'children' | 'aria-label'> & {
30
+ icon: LucideIcon;
31
+ /** Required: the accessible name (and the hover title). */
32
+ label: string;
33
+ /** outline (--rule edge) · ghost (hover wash) · solid (ink). @default "ghost" */
34
+ variant?: 'outline' | 'ghost' | 'solid' | undefined;
35
+ /** 32 · 38 · 44. @default "md" */
36
+ size?: 'sm' | 'md' | 'lg' | undefined;
37
+ /** A count badge on the corner; hidden at zero, 99+ above 99. */
38
+ count?: number | undefined;
39
+ };
40
+
41
+ /** IconButton — a square icon-only button. It always carries a label. */
42
+ export function IconButton({ icon, label, variant, size, count, className, ...rest }: IconButtonProps) {
43
+ const shown = count === undefined ? null : formatCount(count);
44
+ const glyph = size === 'sm' ? 16 : size === 'lg' ? 20 : 18;
45
+ return (
46
+ <button type="button" aria-label={shown ? `${label} (${shown})` : label} title={label} className={cn(iconButton({ variant, size }), className)} {...rest}>
47
+ <Icon icon={icon} size={glyph} />
48
+ {shown && (
49
+ <span aria-hidden className="absolute -top-1.5 -right-1.5 min-w-[18px] rounded-pill bg-brand-mark px-1 text-[11px] leading-[18px] font-semibold text-ink tabular-nums">
50
+ {shown}
51
+ </span>
52
+ )}
53
+ </button>
54
+ );
55
+ }
@@ -0,0 +1,74 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { Field, useField } from './Field';
5
+ import { controlFrame, controlInner } from './forms-control';
6
+
7
+ export type InputProps = Omit<ComponentProps<'input'>, 'size' | 'prefix'> & {
8
+ /** Label above the field; with it (or hint/error) the input renders its own Field. */
9
+ label?: ReactNode;
10
+ hint?: ReactNode;
11
+ /** The error in words; sets invalid. */
12
+ error?: ReactNode;
13
+ /** Error state without a message of its own (the message lives elsewhere). */
14
+ invalid?: boolean | undefined;
15
+ /** Inside, before the text — a currency, an icon. */
16
+ prefix?: ReactNode;
17
+ /** Inside, after the text — a domain, a unit. */
18
+ suffix?: ReactNode;
19
+ /** 32 · 38 · 44 (mobile). @default "md" */
20
+ size?: 'sm' | 'md' | 'lg' | undefined;
21
+ /** default (sunk) · borderless (inline editing: no chrome until hover/focus). */
22
+ variant?: 'default' | 'borderless' | undefined;
23
+ /** Class for the outer frame (className goes to the <input>). */
24
+ frameClassName?: string | undefined;
25
+ };
26
+
27
+ function Control({
28
+ prefix,
29
+ suffix,
30
+ size,
31
+ variant,
32
+ invalid,
33
+ frameClassName,
34
+ className,
35
+ disabled,
36
+ readOnly,
37
+ id,
38
+ ...rest
39
+ }: Omit<InputProps, 'label' | 'hint' | 'error'>) {
40
+ const field = useField();
41
+ const bad = Boolean(invalid || field?.invalid);
42
+ const off = Boolean(disabled || field?.disabled);
43
+ return (
44
+ <div className={cn(controlFrame({ size, variant, invalid: bad, disabled: off, readOnly: Boolean(readOnly) && !off }), frameClassName)}>
45
+ {prefix && <span className="flex shrink-0 text-fg-secondary">{prefix}</span>}
46
+ <input
47
+ {...rest}
48
+ id={id ?? field?.id}
49
+ disabled={off}
50
+ readOnly={readOnly}
51
+ aria-invalid={bad || undefined}
52
+ aria-describedby={cn(rest['aria-describedby'], field?.describedBy) || undefined}
53
+ className={cn(controlInner, className)}
54
+ />
55
+ {suffix && <span className="flex shrink-0 text-fg-secondary">{suffix}</span>}
56
+ </div>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Input — a single-line text field. Sinks to linen and takes an ink edge
62
+ * on focus. Always labelled: pass label here, or wrap in a Field. Errors
63
+ * say how to fix it; validate on blur.
64
+ */
65
+ export function Input({ label, hint, error, required, ...rest }: InputProps) {
66
+ if (label !== undefined || hint !== undefined || error !== undefined) {
67
+ return (
68
+ <Field label={label} hint={hint} error={error} required={required} disabled={rest.disabled} id={rest.id}>
69
+ <Control {...rest} required={required} />
70
+ </Field>
71
+ );
72
+ }
73
+ return <Control {...rest} required={required} />;
74
+ }
@@ -0,0 +1,62 @@
1
+ import type { ComponentProps } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+ import { LOOP, WORDMARK } from './logo-art';
5
+
6
+ export type LogoProps = Omit<ComponentProps<'span'>, 'children'> & {
7
+ /** wordmark · icon (the loop) · tile (the loop on the yellow square). @default "wordmark" */
8
+ variant?: 'wordmark' | 'icon' | 'tile' | undefined;
9
+ /** ink on paper, linen and the yellow; white on ink. Never recoloured beyond these. Unset: ink, white in ink mode. */
10
+ tone?: 'ink' | 'white' | undefined;
11
+ /** The standard lockup: a 4px accent-yellow rule beneath the wordmark. */
12
+ rule?: boolean | undefined;
13
+ /** px height. @default 28 */
14
+ height?: number | undefined;
15
+ /** Accessible name. @default "COSX" */
16
+ alt?: string | undefined;
17
+ };
18
+
19
+ function Art({ art, height, title }: { art: typeof WORDMARK | typeof LOOP; height: number; title: string }) {
20
+ const [, , w, h] = art.viewBox.split(' ').map(Number) as [number, number, number, number];
21
+ return (
22
+ <svg viewBox={art.viewBox} height={height} width={Math.round((height * w) / h)} role="img" aria-label={title} fill="currentColor" className="block shrink-0">
23
+ {art.paths.map((d) => (
24
+ <path key={d.slice(0, 24)} d={d} />
25
+ ))}
26
+ </svg>
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Logo — the COSX wordmark, the CO loop, or the yellow tile (app icon,
32
+ * avatar, favicon). Colour comes from the ground, never from the mark: the
33
+ * mark is ink or white, never yellow, never two-tone.
34
+ */
35
+ export function Logo({ variant = 'wordmark', tone, rule = false, height = 28, alt = 'COSX', className, ...rest }: LogoProps) {
36
+ if (variant === 'tile') {
37
+ return (
38
+ <span
39
+ className={cn('inline-grid shrink-0 place-items-center bg-yellow text-ink', className)}
40
+ style={{ width: height, height, borderRadius: Math.round(height * 0.22) }}
41
+ {...rest}
42
+ >
43
+ <Art art={LOOP} height={height} title={alt} />
44
+ </span>
45
+ );
46
+ }
47
+ const colour = tone === 'white' ? 'text-white' : tone === 'ink' ? 'text-ink' : 'text-ink ink:text-white';
48
+ const art = <Art art={variant === 'icon' ? LOOP : WORDMARK} height={height} title={alt} />;
49
+ if (!rule || variant === 'icon') {
50
+ return (
51
+ <span className={cn('inline-flex', colour, className)} {...rest}>
52
+ {art}
53
+ </span>
54
+ );
55
+ }
56
+ return (
57
+ <span className={cn('inline-flex flex-col items-start', colour, className)} style={{ gap: Math.round(height * 0.45) }} {...rest}>
58
+ {art}
59
+ <span aria-hidden className="h-1 w-full rounded-[2px] bg-yellow-accent" />
60
+ </span>
61
+ );
62
+ }
@@ -0,0 +1,26 @@
1
+ import type { ComponentProps, ElementType, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+
5
+ export type MarkerProps = Omit<ComponentProps<'span'>, 'children'> & {
6
+ children: ReactNode;
7
+ /** On an ink ground there is no band: the phrase is set in accent yellow. */
8
+ onInk?: boolean | undefined;
9
+ /** Draw the band in after the headline lands (motion.css; still with reduced motion). */
10
+ draw?: boolean | undefined;
11
+ as?: ElementType | undefined;
12
+ };
13
+
14
+ /**
15
+ * Marker — the highlighter behind the phrase that carries the finding.
16
+ * One per headline. The band sits in the lower part of the line and is
17
+ * cloned across line breaks; Chinese starts it lower (typography-cjk.css).
18
+ * Styling comes from the design system's own .marker rules.
19
+ */
20
+ export function Marker({ children, onInk = false, draw = false, as: Tag = 'span', className, ...rest }: MarkerProps) {
21
+ return (
22
+ <Tag className={cn('marker', onInk && 'on-ink', draw && !onInk && 'draw', className)} {...rest}>
23
+ {children}
24
+ </Tag>
25
+ );
26
+ }
@@ -0,0 +1,38 @@
1
+ import type { ComponentProps, ElementType, ReactNode } from 'react';
2
+
3
+ import { cn } from '../lib/cn';
4
+
5
+ export type MetaLabelProps = Omit<ComponentProps<'div'>, 'children'> & {
6
+ children: ReactNode;
7
+ /** grey (default) · ink · inverse (grey on ink) · linen · yellow (legal only on an ink ground). */
8
+ tone?: 'grey' | 'ink' | 'inverse' | 'linen' | 'yellow' | undefined;
9
+ /** 11.5 · 12 · 13.5px. @default "md" */
10
+ size?: 'sm' | 'md' | 'lg' | undefined;
11
+ /** A rule beneath: hair (1px) or yellow (4px). */
12
+ rule?: 'hair' | 'yellow' | undefined;
13
+ as?: ElementType | undefined;
14
+ };
15
+
16
+ const TONE = { grey: 'text-fg-secondary', ink: 'text-fg', inverse: 'text-grey-inverse', linen: 'text-linen', yellow: 'text-yellow-accent' };
17
+ const SIZE = { sm: 'text-[11.5px]', md: 'text-meta', lg: 'text-small' };
18
+
19
+ /** MetaLabel — eyebrows, metadata, page numbers: 12px 500 grey, sentence
20
+ * case, no caps, no tracking. */
21
+ export function MetaLabel({ children, tone = 'grey', size = 'md', rule, as: Tag = 'div', className, ...rest }: MetaLabelProps) {
22
+ return (
23
+ <Tag
24
+ className={cn(
25
+ 'font-sans leading-[1.4] font-medium',
26
+ TONE[tone],
27
+ SIZE[size],
28
+ rule && 'pb-2.5',
29
+ rule === 'hair' && 'border-b border-rule',
30
+ rule === 'yellow' && 'border-b-4 border-yellow-accent',
31
+ className,
32
+ )}
33
+ {...rest}
34
+ >
35
+ {children}
36
+ </Tag>
37
+ );
38
+ }
@@ -0,0 +1,72 @@
1
+ import * as RadioPrimitive from '@radix-ui/react-radio-group';
2
+ import { useId, type ComponentProps, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+
6
+ export type RadioOption = {
7
+ value: string;
8
+ label: ReactNode;
9
+ /** A line below the label. */
10
+ description?: ReactNode;
11
+ disabled?: boolean | undefined;
12
+ };
13
+
14
+ export type RadioGroupProps = Omit<ComponentProps<typeof RadioPrimitive.Root>, 'value' | 'onValueChange' | 'onChange' | 'children' | 'orientation'> & {
15
+ /** Two to five exclusive options, all visible (more: a Select). */
16
+ options: Array<string | RadioOption>;
17
+ value?: string | undefined;
18
+ onChange?: ((value: string) => void) | undefined;
19
+ /** column (default) or row. Arrow keys move between options either way. */
20
+ direction?: 'column' | 'row' | undefined;
21
+ };
22
+
23
+ /**
24
+ * RadioGroup — round sunk controls, an ink dot on the chosen one. Arrow
25
+ * keys move and choose; the group is one tab stop.
26
+ */
27
+ export function RadioGroup({ options, value, onChange, direction = 'column', disabled, className, ...rest }: RadioGroupProps) {
28
+ const auto = useId();
29
+ const items = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
30
+ return (
31
+ <RadioPrimitive.Root
32
+ {...(value !== undefined && { value })}
33
+ onValueChange={(v) => onChange?.(v)}
34
+ disabled={disabled}
35
+ orientation={direction === 'row' ? 'horizontal' : 'vertical'}
36
+ className={cn('flex font-sans', direction === 'row' ? 'flex-row flex-wrap gap-[22px]' : 'flex-col gap-2.5', className)}
37
+ {...rest}
38
+ >
39
+ {items.map((o) => {
40
+ const id = `rd${auto}-${o.value}`;
41
+ const off = Boolean(disabled || o.disabled);
42
+ return (
43
+ <div key={o.value} className={cn('inline-flex items-start gap-2.5', off && 'opacity-40')}>
44
+ <RadioPrimitive.Item
45
+ id={id}
46
+ value={o.value}
47
+ disabled={o.disabled}
48
+ aria-describedby={o.description ? `${id}-desc` : undefined}
49
+ className={cn(
50
+ 'mt-0.5 grid size-4 shrink-0 cursor-pointer place-items-center rounded-pill border border-fg-secondary bg-sunk',
51
+ 'transition-[border-color] duration-[120ms] ease-standard outline-none focus-visible:shadow-(--focus-ring)',
52
+ 'data-[state=checked]:border-fg disabled:cursor-not-allowed',
53
+ )}
54
+ >
55
+ <RadioPrimitive.Indicator className="block size-2 rounded-pill bg-fg" />
56
+ </RadioPrimitive.Item>
57
+ <span className="flex flex-col gap-0.5">
58
+ <label htmlFor={id} className={cn('text-small leading-[1.45] text-fg', off ? 'cursor-not-allowed' : 'cursor-pointer')}>
59
+ {o.label}
60
+ </label>
61
+ {o.description && (
62
+ <span id={`${id}-desc`} className="text-meta leading-normal text-fg-secondary">
63
+ {o.description}
64
+ </span>
65
+ )}
66
+ </span>
67
+ </div>
68
+ );
69
+ })}
70
+ </RadioPrimitive.Root>
71
+ );
72
+ }