@lovett/ui 0.0.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 (102) hide show
  1. package/README.md +28 -0
  2. package/package.json +53 -0
  3. package/src/accordion.tsx +153 -0
  4. package/src/atoms.tsx +197 -0
  5. package/src/badge.tsx +91 -0
  6. package/src/brand-logo-tile.tsx +200 -0
  7. package/src/bulk-action-bar.tsx +104 -0
  8. package/src/button-group.tsx +46 -0
  9. package/src/button.tsx +93 -0
  10. package/src/calculator-shell-v2.tsx +380 -0
  11. package/src/calculator-shell.tsx +310 -0
  12. package/src/card.tsx +369 -0
  13. package/src/checkbox.tsx +114 -0
  14. package/src/chip-nav.tsx +347 -0
  15. package/src/choropleth.tsx +62 -0
  16. package/src/code-block.tsx +235 -0
  17. package/src/completion-ring.tsx +119 -0
  18. package/src/copy-field.tsx +169 -0
  19. package/src/data-grid/column-options-menu.tsx +127 -0
  20. package/src/data-grid/data-grid.tsx +391 -0
  21. package/src/data-grid/drawer-panel.tsx +146 -0
  22. package/src/data-grid/index.ts +29 -0
  23. package/src/data-grid/sortable-parts.tsx +215 -0
  24. package/src/data-grid/table-body.tsx +238 -0
  25. package/src/data-grid/table-elements.tsx +135 -0
  26. package/src/data-grid/table-header.tsx +95 -0
  27. package/src/data-grid/table-overlays.tsx +78 -0
  28. package/src/data-grid/table-parts.tsx +15 -0
  29. package/src/data-grid/table-summary-footer.tsx +101 -0
  30. package/src/data-grid/table-view.tsx +176 -0
  31. package/src/data-grid/types.ts +141 -0
  32. package/src/data-grid/use-grid-columns.ts +311 -0
  33. package/src/data-grid/use-grid-editing.ts +154 -0
  34. package/src/device-frame.tsx +274 -0
  35. package/src/dropdown-button.tsx +221 -0
  36. package/src/dropdown-menu.tsx +450 -0
  37. package/src/empty-state.tsx +85 -0
  38. package/src/file-upload/file-drop-zone.tsx +156 -0
  39. package/src/file-upload/file-preview-grid.tsx +39 -0
  40. package/src/file-upload/file-preview-item.tsx +175 -0
  41. package/src/file-upload/file-thumbnail.tsx +98 -0
  42. package/src/file-upload/file-upload-button.tsx +95 -0
  43. package/src/file-upload/index.ts +13 -0
  44. package/src/file-upload/use-file-upload.ts +371 -0
  45. package/src/filter-bar.tsx +125 -0
  46. package/src/filter-dropdown.tsx +289 -0
  47. package/src/floating-drawer.tsx +420 -0
  48. package/src/folder-card.tsx +188 -0
  49. package/src/folder-tree-picker.tsx +511 -0
  50. package/src/format.ts +117 -0
  51. package/src/frame-stack.tsx +73 -0
  52. package/src/hero-form-card.tsx +471 -0
  53. package/src/index.ts +372 -0
  54. package/src/input.tsx +130 -0
  55. package/src/kbd.tsx +41 -0
  56. package/src/lib/layout.ts +22 -0
  57. package/src/lib/utils.ts +6 -0
  58. package/src/markdown.tsx +214 -0
  59. package/src/menu-button.tsx +142 -0
  60. package/src/meta-previews/CreativeMedia.tsx +119 -0
  61. package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
  62. package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
  63. package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
  64. package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
  65. package/src/meta-previews/MetaFeedPreview.tsx +294 -0
  66. package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
  67. package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
  68. package/src/meta-previews/MetaReelPreview.tsx +750 -0
  69. package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
  70. package/src/meta-previews/MetaSearchPreview.tsx +113 -0
  71. package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
  72. package/src/meta-previews/ProfileAvatar.tsx +79 -0
  73. package/src/meta-previews/defaults.ts +27 -0
  74. package/src/meta-previews/index.ts +37 -0
  75. package/src/meta-previews/types.ts +219 -0
  76. package/src/metric-card.tsx +117 -0
  77. package/src/modal.tsx +131 -0
  78. package/src/option-tile.tsx +252 -0
  79. package/src/page-hero.tsx +78 -0
  80. package/src/page-shell.tsx +440 -0
  81. package/src/pagination.tsx +86 -0
  82. package/src/pill-button.tsx +103 -0
  83. package/src/profile-section.tsx +389 -0
  84. package/src/progress-bar.tsx +84 -0
  85. package/src/radio.tsx +172 -0
  86. package/src/range-slider.tsx +161 -0
  87. package/src/resizable.tsx +282 -0
  88. package/src/search-bar.tsx +81 -0
  89. package/src/segmented-pill.tsx +143 -0
  90. package/src/shell.tsx +464 -0
  91. package/src/slider.tsx +107 -0
  92. package/src/sortable-table.tsx +198 -0
  93. package/src/sortable.tsx +137 -0
  94. package/src/stat-row.tsx +132 -0
  95. package/src/stats-grid.tsx +38 -0
  96. package/src/step-loader.tsx +141 -0
  97. package/src/styles.css +562 -0
  98. package/src/tabs.tsx +143 -0
  99. package/src/tag-chip-input.tsx +394 -0
  100. package/src/textarea.tsx +39 -0
  101. package/src/toast.tsx +59 -0
  102. package/src/tokens.css +579 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * ProfileSection — section primitive with the head/tray/foot anatomy
3
+ * used by the redesigned Brand Profile page (and reusable on any
4
+ * settings-style page with multiple labelled, scrollable sections).
5
+ *
6
+ * Built on `ds-card-surface` with `data-framed="true"` so it inherits
7
+ * the same chrome as Card / MediaCard / StatCard — slate frame around
8
+ * a bright focal tray, with the foot sitting back on the frame.
9
+ *
10
+ * Two head modes:
11
+ * • Static (default) — pass `actions` (e.g. Edit / Generate buttons).
12
+ * • Editable — pass `editable` props (onSave / onCancel / isSaving)
13
+ * and the head shows an Edit pencil in view mode, swapping to
14
+ * Cancel + Save buttons in edit mode. Tray content can branch on
15
+ * the render-prop child: `{(isEditing) => isEditing ? <Form/> : <View/>}`.
16
+ *
17
+ * Sections expose `data-section-id="<id>"` so ChipNav can scroll-spy.
18
+ */
19
+
20
+ import {
21
+ cloneElement,
22
+ isValidElement,
23
+ useState,
24
+ type ButtonHTMLAttributes,
25
+ type ReactElement,
26
+ type ReactNode,
27
+ } from 'react'
28
+ import { Check, Loader2, Pencil, X } from 'lucide-react'
29
+ import { cn } from './lib/utils'
30
+
31
+ // Canonical size for the head icon. Centralised so every section uses
32
+ // the same glyph size — no more `<Compass className="h-[17px] w-[17px]" />`
33
+ // at seven callsites.
34
+ const HEAD_ICON_SIZE = 17
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Section head — icon + title + subtitle + actions
38
+ // ---------------------------------------------------------------------------
39
+
40
+ interface SectionHeadProps {
41
+ icon?: ReactNode | undefined
42
+ title: string
43
+ subtitle?: string | undefined
44
+ actions?: ReactNode | undefined
45
+ }
46
+
47
+ function SectionHead({ icon, title, subtitle, actions }: SectionHeadProps) {
48
+ const sizedIcon = isValidElement(icon)
49
+ ? cloneElement(
50
+ icon as ReactElement<{ className?: string; size?: number | string }>,
51
+ {
52
+ size: HEAD_ICON_SIZE,
53
+ className: cn(
54
+ (icon.props as { className?: string }).className,
55
+ `h-[${HEAD_ICON_SIZE}px] w-[${HEAD_ICON_SIZE}px]`,
56
+ ),
57
+ },
58
+ )
59
+ : icon
60
+
61
+ return (
62
+ <div className="flex items-center gap-3 px-4 pt-4 pb-3">
63
+ {icon && (
64
+ <span
65
+ className="shrink-0 inline-flex items-center justify-center w-[34px] h-[34px] rounded-[10px]"
66
+ style={{
67
+ background: 'rgb(var(--accent-muted))',
68
+ color: 'rgb(var(--accent))',
69
+ }}
70
+ >
71
+ {sizedIcon}
72
+ </span>
73
+ )}
74
+ <div className="flex-1 min-w-0">
75
+ <div
76
+ className="text-[14.5px] font-semibold leading-tight tracking-[-0.01em] truncate"
77
+ style={{ color: 'rgb(var(--foreground))' }}
78
+ >
79
+ {title}
80
+ </div>
81
+ {subtitle && (
82
+ <div
83
+ className="text-[12px] mt-[3px] leading-snug"
84
+ style={{ color: 'rgb(var(--text-tertiary))' }}
85
+ >
86
+ {subtitle}
87
+ </div>
88
+ )}
89
+ </div>
90
+ {actions && (
91
+ <div className="shrink-0 flex items-center gap-1">{actions}</div>
92
+ )}
93
+ </div>
94
+ )
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Action button — small icon button used in the section head
99
+ // ---------------------------------------------------------------------------
100
+
101
+ interface ActionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
102
+ label: string
103
+ }
104
+
105
+ function ActionButton({
106
+ label,
107
+ className,
108
+ children,
109
+ ...rest
110
+ }: ActionButtonProps) {
111
+ return (
112
+ <button
113
+ type="button"
114
+ title={label}
115
+ aria-label={label}
116
+ className={cn(
117
+ 'inline-flex items-center justify-center w-[30px] h-[30px] rounded-[8px] transition-colors cursor-pointer',
118
+ className,
119
+ )}
120
+ style={{
121
+ background: 'transparent',
122
+ color: 'rgb(var(--text-tertiary))',
123
+ border: '1px solid transparent',
124
+ }}
125
+ onMouseEnter={(e) => {
126
+ e.currentTarget.style.background = 'rgb(var(--surface-overlay))'
127
+ e.currentTarget.style.color = 'rgb(var(--foreground))'
128
+ e.currentTarget.style.borderColor = 'rgb(var(--border))'
129
+ }}
130
+ onMouseLeave={(e) => {
131
+ e.currentTarget.style.background = 'transparent'
132
+ e.currentTarget.style.color = 'rgb(var(--text-tertiary))'
133
+ e.currentTarget.style.borderColor = 'transparent'
134
+ }}
135
+ {...rest}
136
+ >
137
+ {children}
138
+ </button>
139
+ )
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Foot — meta items + optional AI-action link
144
+ // ---------------------------------------------------------------------------
145
+
146
+ interface FootProps {
147
+ meta?: ReactNode
148
+ link?: { label: string; onClick: () => void; icon?: ReactNode }
149
+ }
150
+
151
+ function Foot({ meta, link }: FootProps) {
152
+ if (!meta && !link) return null
153
+ return (
154
+ <div className="flex items-center justify-between gap-3 px-4 pt-3 pb-4 text-[12px]">
155
+ <div
156
+ className="flex items-center gap-3.5 flex-wrap"
157
+ style={{ color: 'rgb(var(--text-tertiary))' }}
158
+ >
159
+ {meta}
160
+ </div>
161
+ {link && (
162
+ <button
163
+ type="button"
164
+ onClick={link.onClick}
165
+ className="inline-flex items-center gap-1 text-[11.5px] font-medium bg-transparent border-0 cursor-pointer p-0 transition-opacity"
166
+ style={{
167
+ color: 'rgb(var(--accent))',
168
+ fontFamily: 'var(--font-sans)',
169
+ }}
170
+ onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
171
+ onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
172
+ >
173
+ {link.label}
174
+ {link.icon}
175
+ </button>
176
+ )}
177
+ </div>
178
+ )
179
+ }
180
+
181
+ /** FootItem — a single icon+text item inside the foot. */
182
+ function FootItem({ children }: { children: ReactNode }) {
183
+ return <span className="inline-flex items-center gap-1.5">{children}</span>
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Editable head — pencil → cancel/save controls
188
+ // ---------------------------------------------------------------------------
189
+
190
+ interface EditableHeadActionsProps {
191
+ isEditing: boolean
192
+ isSaving: boolean
193
+ saveDisabled?: boolean | undefined
194
+ onEdit: () => void
195
+ onCancel: () => void
196
+ onSave: () => void
197
+ extraViewActions?: ReactNode | undefined
198
+ }
199
+
200
+ function EditableHeadActions({
201
+ isEditing,
202
+ isSaving,
203
+ saveDisabled,
204
+ onEdit,
205
+ onCancel,
206
+ onSave,
207
+ extraViewActions,
208
+ }: EditableHeadActionsProps) {
209
+ if (isEditing) {
210
+ return (
211
+ <>
212
+ <ActionButton
213
+ label="Cancel"
214
+ onClick={onCancel}
215
+ disabled={isSaving}
216
+ style={
217
+ isSaving
218
+ ? { opacity: 0.5, pointerEvents: 'none' }
219
+ : undefined
220
+ }
221
+ >
222
+ <X className="h-4 w-4" />
223
+ </ActionButton>
224
+ <ActionButton
225
+ label={isSaving ? 'Saving…' : 'Save'}
226
+ onClick={onSave}
227
+ disabled={isSaving || saveDisabled}
228
+ style={
229
+ isSaving || saveDisabled
230
+ ? { opacity: 0.5, pointerEvents: 'none' }
231
+ : { color: 'rgb(var(--accent))' }
232
+ }
233
+ >
234
+ {isSaving ? (
235
+ <Loader2 className="h-4 w-4 animate-spin" />
236
+ ) : (
237
+ <Check className="h-4 w-4" />
238
+ )}
239
+ </ActionButton>
240
+ </>
241
+ )
242
+ }
243
+ return (
244
+ <>
245
+ {extraViewActions}
246
+ <ActionButton label="Edit" onClick={onEdit}>
247
+ <Pencil className="h-[14px] w-[14px]" />
248
+ </ActionButton>
249
+ </>
250
+ )
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // ProfileSection root
255
+ // ---------------------------------------------------------------------------
256
+
257
+ interface BaseProps {
258
+ /** DOM id and `data-section-id` for ChipNav scroll-spy. */
259
+ id: string
260
+ /** Lucide icon (rendered ~17px). */
261
+ icon?: ReactNode
262
+ /** Section title. */
263
+ title: string
264
+ /** Section subtitle / description. */
265
+ subtitle?: string
266
+ /** Optional foot meta + AI-action link. */
267
+ foot?: FootProps
268
+ /** Extra padding tweak. */
269
+ className?: string
270
+ }
271
+
272
+ interface StaticSectionProps extends BaseProps {
273
+ /** Right-side header actions (override default pencil/generate). */
274
+ actions?: ReactNode
275
+ children: ReactNode
276
+ editable?: undefined
277
+ }
278
+
279
+ interface EditableSectionProps extends BaseProps {
280
+ editable: {
281
+ onSave: () => void | Promise<void>
282
+ onCancel?: () => void
283
+ isSaving?: boolean
284
+ saveDisabled?: boolean
285
+ /** Controlled editing state. If omitted, state is internal. */
286
+ isEditing?: boolean
287
+ onEditingChange?: (editing: boolean) => void
288
+ /** Extra actions before the pencil in view mode (e.g. Generate). */
289
+ extraViewActions?: ReactNode
290
+ }
291
+ /** Render-prop child receives the current editing state. */
292
+ children: (isEditing: boolean) => ReactNode
293
+ actions?: undefined
294
+ }
295
+
296
+ type ProfileSectionProps = StaticSectionProps | EditableSectionProps
297
+
298
+ function ProfileSection(props: ProfileSectionProps) {
299
+ const { id, icon, title, subtitle, foot, className } = props
300
+ const [internalEditing, setInternalEditing] = useState(false)
301
+
302
+ const editable = 'editable' in props ? props.editable : undefined
303
+ const isEditing = editable
304
+ ? editable.isEditing ?? internalEditing
305
+ : false
306
+
307
+ const setEditing = (v: boolean) => {
308
+ if (editable?.onEditingChange) editable.onEditingChange(v)
309
+ else setInternalEditing(v)
310
+ }
311
+
312
+ const handleCancel = () => {
313
+ editable?.onCancel?.()
314
+ setEditing(false)
315
+ }
316
+
317
+ const handleSave = async () => {
318
+ if (!editable) return
319
+ try {
320
+ await editable.onSave()
321
+ setEditing(false)
322
+ } catch {
323
+ // Caller surfaces the error via toast / mutation state.
324
+ }
325
+ }
326
+
327
+ const headActions = editable ? (
328
+ <EditableHeadActions
329
+ isEditing={isEditing}
330
+ isSaving={!!editable.isSaving}
331
+ saveDisabled={editable.saveDisabled}
332
+ onEdit={() => setEditing(true)}
333
+ onCancel={handleCancel}
334
+ onSave={handleSave}
335
+ extraViewActions={editable.extraViewActions}
336
+ />
337
+ ) : (
338
+ props.actions ?? (
339
+ <ActionButton label="Edit">
340
+ <Pencil className="h-[14px] w-[14px]" />
341
+ </ActionButton>
342
+ )
343
+ )
344
+
345
+ const children = editable
346
+ ? (props.children as (e: boolean) => ReactNode)(isEditing)
347
+ : (props.children as ReactNode)
348
+
349
+ return (
350
+ <section
351
+ id={id}
352
+ data-section-id={id}
353
+ data-framed="true"
354
+ className={cn('ds-card-surface flex flex-col', className)}
355
+ style={{ scrollMarginTop: 'var(--section-scroll-offset)' }}
356
+ >
357
+ <SectionHead
358
+ icon={icon}
359
+ title={title}
360
+ subtitle={subtitle}
361
+ actions={headActions}
362
+ />
363
+ {/* flex:1 lets the tray fill remaining height when the section is
364
+ stretched (e.g. side-by-side in an equal-height grid), so trays
365
+ bottom-align instead of leaving a gap under a shorter-header card.
366
+ No effect on un-stretched (vertically-stacked) sections. */}
367
+ <div className="ds-card-tray p-5" style={{ flex: '1 1 auto' }}>
368
+ {children}
369
+ </div>
370
+ {foot && <Foot {...foot} />}
371
+ </section>
372
+ )
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Compose
377
+ // ---------------------------------------------------------------------------
378
+
379
+ type ProfileSectionComponent = typeof ProfileSection & {
380
+ ActionButton: typeof ActionButton
381
+ FootItem: typeof FootItem
382
+ }
383
+
384
+ const PS = ProfileSection as ProfileSectionComponent
385
+ PS.ActionButton = ActionButton
386
+ PS.FootItem = FootItem
387
+
388
+ export default PS
389
+ export type { ProfileSectionProps }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ProgressBar — labeled fill bar.
3
+ *
4
+ * Promoted in ADR-020 (Phase 3a Discovery report generation, first
5
+ * committed consumer). Cross-batch consumers: Phase 8 (AI-suggest
6
+ * progress if surfaced), Phase 12 (generation step in wizard).
7
+ *
8
+ * Indeterminate variant for unknown-duration progress (animated
9
+ * stripe sweep); determinate variant for known-percent progress.
10
+ *
11
+ * Token discipline: all colors via public tokens.
12
+ */
13
+
14
+ export interface ProgressBarProps {
15
+ /** Optional label above the bar. */
16
+ label?: string
17
+ /** 0-100 percent (determinate). Omit for indeterminate animation. */
18
+ percent?: number
19
+ /** Optional secondary text shown right of the label. Often "X%" or a status message. */
20
+ status?: string
21
+ /** Track height in px (default 8). */
22
+ height?: number
23
+ }
24
+
25
+ export function ProgressBar({ label, percent, status, height = 8 }: ProgressBarProps) {
26
+ const determinate = typeof percent === 'number'
27
+ const clamped = determinate ? Math.max(0, Math.min(100, percent)) : 0
28
+
29
+ return (
30
+ <div className="flex flex-col gap-1.5">
31
+ {(label || status) && (
32
+ <div className="flex items-center justify-between">
33
+ {label && (
34
+ <span
35
+ className="text-[12px] font-medium"
36
+ style={{ color: 'rgb(var(--text-secondary))' }}
37
+ >
38
+ {label}
39
+ </span>
40
+ )}
41
+ {status && (
42
+ <span
43
+ className="text-[11px] tabular-nums"
44
+ style={{ color: 'rgb(var(--text-tertiary))' }}
45
+ >
46
+ {status}
47
+ </span>
48
+ )}
49
+ </div>
50
+ )}
51
+ <div
52
+ role="progressbar"
53
+ aria-valuenow={determinate ? clamped : undefined}
54
+ aria-valuemin={0}
55
+ aria-valuemax={100}
56
+ className="w-full overflow-hidden"
57
+ style={{
58
+ height,
59
+ background: 'rgb(var(--surface-overlay))',
60
+ borderRadius: 'var(--radius-full)',
61
+ }}
62
+ >
63
+ {determinate ? (
64
+ <div
65
+ className="h-full transition-[width] duration-300 ease-out"
66
+ style={{
67
+ width: `${clamped}%`,
68
+ background: 'rgb(var(--accent))',
69
+ borderRadius: 'var(--radius-full)',
70
+ }}
71
+ />
72
+ ) : (
73
+ <div
74
+ className="h-full progress-indeterminate"
75
+ style={{
76
+ background: 'rgb(var(--accent))',
77
+ borderRadius: 'var(--radius-full)',
78
+ }}
79
+ />
80
+ )}
81
+ </div>
82
+ </div>
83
+ )
84
+ }
package/src/radio.tsx ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Radio + RadioGroup primitives (ADR-029 D3).
3
+ *
4
+ * Stand-alone radio inputs for cases where the OptionTile pattern is
5
+ * too visual — inline form labels, compact mode pickers. For tinted-
6
+ * card mode pickers use <OptionTileGroup type="radio"> instead.
7
+ *
8
+ * API mirrors RadioGroup conventions:
9
+ * <RadioGroup name="case" value={mode} onChange={setMode}>
10
+ * <Radio value="lower" label="lowercase" />
11
+ * <Radio value="upper" label="UPPERCASE" description="all caps" />
12
+ * </RadioGroup>
13
+ *
14
+ * Token discipline: all public tokens (--accent, --border, --foreground,
15
+ * --text-tertiary, --ring-focus). Composable inside <Field>.
16
+ */
17
+
18
+ import {
19
+ createContext,
20
+ forwardRef,
21
+ useContext,
22
+ useId,
23
+ type ReactNode,
24
+ } from 'react'
25
+ import { cn } from './lib/utils'
26
+
27
+ export interface RadioGroupProps<T extends string = string> {
28
+ /** Form name — required for proper radio semantics + form submission. */
29
+ name: string
30
+ /** Currently selected value. */
31
+ value: T | null
32
+ /** Fires when the user picks a different option. */
33
+ onChange: (next: T) => void
34
+ /** <Radio> children. */
35
+ children: ReactNode
36
+ /** Visually-hidden label for screen readers. Renders as <legend>. */
37
+ ariaLabel?: string
38
+ /** Layout direction. Defaults to vertical. */
39
+ orientation?: 'horizontal' | 'vertical'
40
+ /** Extra classes on the wrapping <fieldset>. */
41
+ className?: string
42
+ /** Disable every child radio. */
43
+ disabled?: boolean
44
+ }
45
+
46
+ interface RadioGroupContextValue {
47
+ name: string
48
+ value: string | null
49
+ onChange: (next: string) => void
50
+ disabled: boolean
51
+ }
52
+
53
+ const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
54
+
55
+ export function RadioGroup<T extends string = string>({
56
+ name,
57
+ value,
58
+ onChange,
59
+ children,
60
+ ariaLabel,
61
+ orientation = 'vertical',
62
+ className,
63
+ disabled = false,
64
+ }: RadioGroupProps<T>) {
65
+ const ctx: RadioGroupContextValue = {
66
+ name,
67
+ value,
68
+ onChange: onChange as (next: string) => void,
69
+ disabled,
70
+ }
71
+ return (
72
+ <RadioGroupContext.Provider value={ctx}>
73
+ <fieldset
74
+ className={cn(
75
+ 'flex',
76
+ orientation === 'horizontal' ? 'flex-row gap-4' : 'flex-col gap-2',
77
+ className,
78
+ )}
79
+ >
80
+ {ariaLabel && <legend className="sr-only">{ariaLabel}</legend>}
81
+ {children}
82
+ </fieldset>
83
+ </RadioGroupContext.Provider>
84
+ )
85
+ }
86
+
87
+ export interface RadioProps {
88
+ /** Value emitted to the group's onChange when picked. */
89
+ value: string
90
+ /** Visible label. */
91
+ label: ReactNode
92
+ /** Optional secondary text (smaller, --text-tertiary). */
93
+ description?: ReactNode
94
+ /** Disable this specific radio (overrides group default). */
95
+ disabled?: boolean
96
+ /** Extra classes on the outer <label>. */
97
+ className?: string
98
+ }
99
+
100
+ export const Radio = forwardRef<HTMLInputElement, RadioProps>(function Radio(
101
+ { value, label, description, disabled, className },
102
+ ref,
103
+ ) {
104
+ const ctx = useContext(RadioGroupContext)
105
+ if (!ctx) {
106
+ throw new Error('<Radio> must be rendered inside <RadioGroup>')
107
+ }
108
+ const inputId = useId()
109
+ const isSelected = ctx.value === value
110
+ const isDisabled = disabled ?? ctx.disabled
111
+
112
+ return (
113
+ <label
114
+ htmlFor={inputId}
115
+ className={cn(
116
+ 'inline-flex items-start gap-2.5 cursor-pointer select-none group',
117
+ isDisabled && 'cursor-not-allowed opacity-50',
118
+ className,
119
+ )}
120
+ >
121
+ <input
122
+ ref={ref}
123
+ id={inputId}
124
+ type="radio"
125
+ name={ctx.name}
126
+ value={value}
127
+ checked={isSelected}
128
+ disabled={isDisabled}
129
+ onChange={() => ctx.onChange(value)}
130
+ className="peer sr-only"
131
+ />
132
+ {/* Visual dot — 16px outer ring, 8px inner fill. */}
133
+ <span
134
+ aria-hidden
135
+ className={cn(
136
+ 'mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full transition-colors',
137
+ 'peer-focus-visible:[box-shadow:var(--ring-focus)]',
138
+ )}
139
+ style={{
140
+ border: `1.5px solid rgb(var(${
141
+ isSelected ? '--accent' : '--border-strong'
142
+ }))`,
143
+ background: 'transparent',
144
+ }}
145
+ >
146
+ <span
147
+ className={cn(
148
+ 'h-2 w-2 rounded-full transition-opacity',
149
+ isSelected ? 'opacity-100' : 'opacity-0',
150
+ )}
151
+ style={{ background: 'rgb(var(--accent))' }}
152
+ />
153
+ </span>
154
+ <span className="flex flex-col gap-0.5 min-w-0">
155
+ <span
156
+ className="text-[13px] font-medium"
157
+ style={{ color: 'rgb(var(--foreground))' }}
158
+ >
159
+ {label}
160
+ </span>
161
+ {description && (
162
+ <span
163
+ className="text-[12px]"
164
+ style={{ color: 'rgb(var(--text-tertiary))' }}
165
+ >
166
+ {description}
167
+ </span>
168
+ )}
169
+ </span>
170
+ </label>
171
+ )
172
+ })