@maestro-js/components 1.0.0-alpha.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 (89) hide show
  1. package/commands/add.ts +41 -0
  2. package/commands/index.ts +7 -0
  3. package/commands/list.ts +9 -0
  4. package/dist/components.json +1 -0
  5. package/dist/index.d.ts +14 -0
  6. package/dist/index.js +48 -0
  7. package/package.json +49 -0
  8. package/registry.json +1445 -0
  9. package/scripts/build.ts +44 -0
  10. package/src/components/alert-dialog.tsx +150 -0
  11. package/src/components/autocomplete.tsx +288 -0
  12. package/src/components/autosuggest.tsx +314 -0
  13. package/src/components/avatar.tsx +54 -0
  14. package/src/components/boolean-select.tsx +48 -0
  15. package/src/components/button-group.tsx +75 -0
  16. package/src/components/button-link.tsx +71 -0
  17. package/src/components/button.tsx +132 -0
  18. package/src/components/checkbox-group.tsx +172 -0
  19. package/src/components/checkbox.tsx +207 -0
  20. package/src/components/chip.tsx +158 -0
  21. package/src/components/container.tsx +82 -0
  22. package/src/components/currency-field.tsx +183 -0
  23. package/src/components/date-input.tsx +189 -0
  24. package/src/components/date-picker.tsx +211 -0
  25. package/src/components/date-range-picker.tsx +290 -0
  26. package/src/components/date-time-picker.tsx +196 -0
  27. package/src/components/dialog.tsx +97 -0
  28. package/src/components/disclosure.tsx +114 -0
  29. package/src/components/drawer.tsx +78 -0
  30. package/src/components/enum-chip.tsx +30 -0
  31. package/src/components/file-input.tsx +245 -0
  32. package/src/components/form.tsx +82 -0
  33. package/src/components/headless-file-input.tsx +362 -0
  34. package/src/components/helpers/animated-popover.tsx +24 -0
  35. package/src/components/helpers/button-context.ts +10 -0
  36. package/src/components/helpers/calendar-month-year-picker.tsx +280 -0
  37. package/src/components/helpers/form-field.tsx +229 -0
  38. package/src/components/helpers/get-button-classes.ts +138 -0
  39. package/src/components/helpers/headless-button.tsx +36 -0
  40. package/src/components/helpers/pdf-dist.client.ts +6 -0
  41. package/src/components/icon.tsx +26 -0
  42. package/src/components/image-input.tsx +265 -0
  43. package/src/components/img.tsx +46 -0
  44. package/src/components/inline-alert.tsx +54 -0
  45. package/src/components/labeled-value.tsx +480 -0
  46. package/src/components/link-tabs.tsx +118 -0
  47. package/src/components/menu.tsx +152 -0
  48. package/src/components/month-day-input.tsx +176 -0
  49. package/src/components/multi-file-input.tsx +244 -0
  50. package/src/components/multi-image-input.tsx +389 -0
  51. package/src/components/multicomplete.tsx +322 -0
  52. package/src/components/multiselect.tsx +325 -0
  53. package/src/components/multisuggest.tsx +357 -0
  54. package/src/components/number-field.tsx +143 -0
  55. package/src/components/numeric-tag-field.tsx +271 -0
  56. package/src/components/pdf-input.tsx +249 -0
  57. package/src/components/pdf.tsx +86 -0
  58. package/src/components/percentage-field.tsx +187 -0
  59. package/src/components/phone-number-field.tsx +166 -0
  60. package/src/components/radio-group.tsx +112 -0
  61. package/src/components/radio.tsx +91 -0
  62. package/src/components/select.tsx +215 -0
  63. package/src/components/spinner.tsx +16 -0
  64. package/src/components/stepper.tsx +181 -0
  65. package/src/components/switch.tsx +186 -0
  66. package/src/components/tabs.tsx +151 -0
  67. package/src/components/tag-field.tsx +250 -0
  68. package/src/components/text-area.tsx +148 -0
  69. package/src/components/text-field.tsx +144 -0
  70. package/src/components/time-input.tsx +198 -0
  71. package/src/components/toast.tsx +176 -0
  72. package/src/components/toggle-button-group.tsx +94 -0
  73. package/src/components/year-month-input.tsx +187 -0
  74. package/src/utils/colors.ts +44 -0
  75. package/src/utils/compose-refs.ts +32 -0
  76. package/src/utils/enum-colors.ts +1 -0
  77. package/src/utils/file-input.ts +49 -0
  78. package/src/utils/icons.d.ts +20 -0
  79. package/src/utils/tw.ts +13 -0
  80. package/src/utils/use-element-size.ts +35 -0
  81. package/src/utils/use-number-input.ts +143 -0
  82. package/src/utils/use-pagination.ts +38 -0
  83. package/src/utils/use-prevent-default.ts +27 -0
  84. package/src/utils/use-render-props.ts +39 -0
  85. package/src/utils/use-spin-delay.ts +24 -0
  86. package/src/utils/use-stable-accessor.ts +11 -0
  87. package/src/utils/use-tab-indicator.ts +106 -0
  88. package/tests/commands.test.ts +81 -0
  89. package/tsconfig.json +27 -0
@@ -0,0 +1,187 @@
1
+ import * as React from 'react'
2
+ import { HeadlessForm } from '@maestro-js/form'
3
+ import type { ValidationFunction } from '@maestro-js/form'
4
+ import { FormFieldComponents } from './helpers/form-field'
5
+ import { useNumberInput } from '../utils/use-number-input'
6
+
7
+ export type PercentageFieldProps = {
8
+ // Form identification
9
+ name?: string
10
+ form?: string
11
+
12
+ // Display elements
13
+ label?: React.ReactNode
14
+ cornerHint?: React.ReactNode
15
+ helpText?: React.ReactNode
16
+ placeholder?: string
17
+ warningMessage?: React.ReactNode
18
+
19
+ // Validation and state
20
+ isRequired?: boolean
21
+ isDisabled?: boolean
22
+ isReadonly?: boolean
23
+ isInvalid?: boolean
24
+ validate?: ValidationFunction<number>
25
+ errorMessage?: React.ReactNode | ((v: import('@maestro-js/form').ValidationResult) => React.ReactNode)
26
+
27
+ // Value management (in the unit specified by `units`)
28
+ value?: number | null
29
+ defaultValue?: number | null
30
+ onChange?: (value: number | null) => void
31
+
32
+ // Number specific
33
+ min?: number | null
34
+ max?: number | null
35
+ autoComplete?: string | null
36
+ autoFocus?: boolean
37
+
38
+ // Percentage specific
39
+ units: 'integer' | 'decimal'
40
+ minimumFractionDigits?: number
41
+ maximumFractionDigits?: number
42
+ shape?: 'rectangle' | 'oval'
43
+ }
44
+
45
+ export function PercentageField(props: PercentageFieldProps) {
46
+ const inputRef = React.useRef<HTMLInputElement>(null)
47
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
48
+
49
+ const helpTextId = React.useId()
50
+ const inputId = React.useId()
51
+
52
+ const isInteger = props.units === 'integer'
53
+
54
+ // Stable ref for onChange to avoid unnecessary re-renders in useNumberInput
55
+ const onChangeRef = React.useRef(props.onChange)
56
+ onChangeRef.current = props.onChange
57
+
58
+ const handleChange = React.useCallback(
59
+ (newValue: number | null) => {
60
+ onChangeRef.current?.(isInteger && newValue !== null ? decimalToInteger(newValue) : newValue)
61
+ },
62
+ [isInteger]
63
+ )
64
+
65
+ const formatOptions = React.useMemo<Intl.NumberFormatOptions>(
66
+ () => ({
67
+ style: 'percent',
68
+ minimumFractionDigits: props.minimumFractionDigits,
69
+ maximumFractionDigits: props.maximumFractionDigits
70
+ }),
71
+ [props.minimumFractionDigits, props.maximumFractionDigits]
72
+ )
73
+
74
+ const displayValue = isInteger && props.value != null ? integerToDecimal(props.value) : props.value
75
+ const displayDefaultValue =
76
+ isInteger && props.defaultValue != null ? integerToDecimal(props.defaultValue) : props.defaultValue
77
+ const displayMin = isInteger && props.min != null ? integerToDecimal(props.min) : props.min
78
+ const displayMax = isInteger && props.max != null ? integerToDecimal(props.max) : props.max
79
+
80
+ const numberInput = useNumberInput({
81
+ value: displayValue ?? undefined,
82
+ defaultValue: displayDefaultValue ?? undefined,
83
+ onChange: handleChange,
84
+ minValue: displayMin ?? undefined,
85
+ maxValue: displayMax ?? undefined,
86
+ formatOptions,
87
+ locale: 'en-US',
88
+ isDisabled: props.isDisabled,
89
+ isReadOnly: props.isReadonly ?? false
90
+ })
91
+
92
+ HeadlessForm.useReset(inputRef, numberInput.numberValue, numberInput.setNumberValue)
93
+
94
+ // Validation value should be in the original unit
95
+ const validationValue = isInteger ? decimalToInteger(numberInput.numberValue) : numberInput.numberValue
96
+
97
+ const {
98
+ validationMessage,
99
+ isInvalid: validationInvalid,
100
+ commitValidation
101
+ } = HeadlessForm.useValidation(
102
+ {
103
+ validate: props.validate,
104
+ value: validationValue as number,
105
+ name: props.name,
106
+ isRequired: props.isRequired,
107
+ form: props.form,
108
+ focus() {
109
+ inputRef.current?.focus()
110
+ }
111
+ },
112
+ hiddenInputRef
113
+ )
114
+
115
+ const isInvalid = props.isInvalid || validationInvalid
116
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
117
+
118
+ // Hidden input value in the original unit
119
+ const hiddenValue = isInteger ? decimalToInteger(numberInput.numberValue) : numberInput.numberValue
120
+
121
+ const handleBlur = () => {
122
+ numberInput.commit(inputRef.current?.value)
123
+ commitValidation()
124
+ }
125
+
126
+ return (
127
+ <div className="relative">
128
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
129
+
130
+ {/* Hidden input for form submission */}
131
+ {props.isDisabled ? null : (
132
+ <HeadlessForm.HiddenInput
133
+ ref={hiddenInputRef}
134
+ form={props.form}
135
+ name={props.name}
136
+ value={hiddenValue ?? ''}
137
+ type="number"
138
+ min={numberInput.minValue}
139
+ max={numberInput.maxValue}
140
+ />
141
+ )}
142
+
143
+ <FormFieldComponents.FieldGroup
144
+ isDisabled={props.isDisabled}
145
+ isReadonly={props.isReadonly}
146
+ isInvalid={isInvalid}
147
+ hasWarning={hasWarning}
148
+ shape={props.shape}
149
+ className="flex"
150
+ >
151
+ <FormFieldComponents.FieldInput
152
+ ref={inputRef}
153
+ id={inputId}
154
+ type="text"
155
+ value={numberInput.inputValue}
156
+ onChange={(e) => numberInput.setInputValue(e.currentTarget.value)}
157
+ onBlur={handleBlur}
158
+ placeholder={props.placeholder}
159
+ disabled={props.isDisabled}
160
+ readOnly={props.isReadonly}
161
+ autoComplete={props.autoComplete ?? undefined}
162
+ autoFocus={props.autoFocus}
163
+ aria-invalid={isInvalid || undefined}
164
+ aria-describedby={helpTextId}
165
+ />
166
+ </FormFieldComponents.FieldGroup>
167
+
168
+ <FormFieldComponents.FormFieldHelpText
169
+ id={helpTextId}
170
+ isInvalid={isInvalid}
171
+ validationMessage={validationMessage}
172
+ hasWarning={hasWarning}
173
+ warningMessage={props.warningMessage}
174
+ helpText={props.helpText}
175
+ />
176
+ </div>
177
+ )
178
+ }
179
+
180
+ function integerToDecimal(value: number): number {
181
+ return value / 100
182
+ }
183
+
184
+ function decimalToInteger(value: number | null): number | null {
185
+ if (value == null) return null
186
+ return Math.round(value * 100)
187
+ }
@@ -0,0 +1,166 @@
1
+ import * as React from 'react'
2
+ import { HeadlessForm } from '@maestro-js/form'
3
+ import type { ValidationFunction } from '@maestro-js/form'
4
+ import { FormFieldComponents } from './helpers/form-field'
5
+
6
+ export type PhoneNumberFieldProps = {
7
+ // Form identification
8
+ name?: string
9
+ form?: string
10
+
11
+ // Display elements
12
+ label?: React.ReactNode
13
+ cornerHint?: React.ReactNode
14
+ helpText?: React.ReactNode
15
+ placeholder?: string
16
+ warningMessage?: React.ReactNode
17
+
18
+ // Validation and state
19
+ isRequired?: boolean
20
+ isDisabled?: boolean
21
+ isReadonly?: boolean
22
+ isInvalid?: boolean
23
+ validate?: ValidationFunction<string>
24
+
25
+ // Value management
26
+ value?: string
27
+ defaultValue?: string
28
+ onChange?: (value: string) => void
29
+
30
+ // Phone specific
31
+ country?: 'US' | 'CA' | 'international'
32
+ autoFormat?: boolean
33
+ autoComplete?: string
34
+ autoFocus?: boolean
35
+ shape?: 'rectangle' | 'oval'
36
+ }
37
+
38
+ export function PhoneNumberField(props: PhoneNumberFieldProps) {
39
+ const inputRef = React.useRef<HTMLInputElement>(null)
40
+ const helpTextId = React.useId()
41
+ const inputId = React.useId()
42
+
43
+ // Controlled state management
44
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? '', props.onChange)
45
+
46
+ // Form validation
47
+ const {
48
+ validationMessage,
49
+ isInvalid: validationInvalid,
50
+ commitValidation
51
+ } = HeadlessForm.useValidation(
52
+ {
53
+ validate: props.validate,
54
+ value,
55
+ name: props.name,
56
+ isRequired: props.isRequired,
57
+ form: props.form,
58
+ focus() {
59
+ inputRef.current?.focus()
60
+ }
61
+ },
62
+ inputRef
63
+ )
64
+
65
+ const isInvalid = props.isInvalid || validationInvalid
66
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
67
+
68
+ // Track whether field is focused for formatting toggle
69
+ const [isFocused, setIsFocused] = React.useState(false)
70
+
71
+ // Display value - formatted when not focused, raw when focused
72
+ const displayValue = React.useMemo(() => {
73
+ if (props.autoFormat === false) return value
74
+ if (isFocused) return value
75
+ return formatPhoneNumber(value, props.country)
76
+ }, [value, props.autoFormat, props.country, isFocused])
77
+
78
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
79
+ if (props.autoFormat !== false) {
80
+ setValue(getDigits(e.target.value))
81
+ } else {
82
+ setValue(e.target.value)
83
+ }
84
+ }
85
+
86
+ const handleFocus = () => {
87
+ setIsFocused(true)
88
+ }
89
+
90
+ const handleBlur = () => {
91
+ setIsFocused(false)
92
+ commitValidation()
93
+ }
94
+
95
+ return (
96
+ <div className="relative">
97
+ {/* Hidden input for form submission */}
98
+ <HeadlessForm.HiddenInput ref={inputRef} name={props.name} value={value} form={props.form} />
99
+
100
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
101
+
102
+ <FormFieldComponents.FieldGroup
103
+ isDisabled={props.isDisabled}
104
+ isReadonly={props.isReadonly}
105
+ isInvalid={isInvalid}
106
+ hasWarning={hasWarning}
107
+ shape={props.shape}
108
+ className="flex"
109
+ >
110
+ <FormFieldComponents.FieldInput
111
+ id={inputId}
112
+ type="tel"
113
+ value={displayValue}
114
+ onChange={handleChange}
115
+ onFocus={handleFocus}
116
+ onBlur={handleBlur}
117
+ placeholder={props.placeholder ?? (props.country === 'international' ? '+1234567890' : '(555) 555-5555')}
118
+ disabled={props.isDisabled}
119
+ readOnly={props.isReadonly}
120
+ autoComplete={props.autoComplete ?? 'tel'}
121
+ autoFocus={props.autoFocus}
122
+ inputMode="tel"
123
+ aria-invalid={isInvalid || undefined}
124
+ aria-describedby={helpTextId}
125
+ />
126
+ </FormFieldComponents.FieldGroup>
127
+
128
+ <FormFieldComponents.FormFieldHelpText
129
+ id={helpTextId}
130
+ isInvalid={isInvalid}
131
+ validationMessage={validationMessage}
132
+ hasWarning={hasWarning}
133
+ warningMessage={props.warningMessage}
134
+ helpText={props.helpText}
135
+ />
136
+ </div>
137
+ )
138
+ }
139
+
140
+ // Format phone number for display
141
+ function formatPhoneNumber(value: string, country: 'US' | 'CA' | 'international' = 'US'): string {
142
+ const digits = value.replace(/\D/g, '')
143
+
144
+ if (country === 'US' || country === 'CA') {
145
+ if (digits.length === 0) return ''
146
+ if (digits.length <= 3) return `(${digits}`
147
+ if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`
148
+ if (digits.length <= 10) return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
149
+ // Handle country code
150
+ if (digits.length === 11 && digits[0] === '1') {
151
+ return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`
152
+ }
153
+ return value
154
+ }
155
+
156
+ // International - just add + prefix for readability
157
+ if (digits.length > 0 && !value.startsWith('+')) {
158
+ return '+' + digits
159
+ }
160
+ return value
161
+ }
162
+
163
+ // Get raw digits from formatted phone
164
+ function getDigits(value: string): string {
165
+ return value.replace(/\D/g, '')
166
+ }
@@ -0,0 +1,112 @@
1
+ import * as React from 'react'
2
+ import { RadioGroup as AriaRadioGroup } from 'react-aria-components'
3
+ import { HeadlessForm } from '@maestro-js/form'
4
+ import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
5
+ import { tw } from '../utils/tw'
6
+ import { FormFieldComponents } from './helpers/form-field'
7
+
8
+ export type RadioGroupProps = {
9
+ name?: string
10
+ form?: string
11
+
12
+ label?: React.ReactNode
13
+ cornerHint?: React.ReactNode
14
+ helpText?: React.ReactNode
15
+ warningMessage?: React.ReactNode
16
+
17
+ isRequired?: boolean
18
+ isDisabled?: boolean
19
+ isInvalid?: boolean
20
+ validate?: ValidationFunction<string | null>
21
+ errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
22
+
23
+ value?: string | null
24
+ defaultValue?: string | null
25
+ onChange?: (value: string | null) => void
26
+
27
+ children?: React.ReactNode
28
+ orientation?: 'horizontal' | 'vertical' | 'grid'
29
+ className?: string
30
+ }
31
+
32
+ export function RadioGroup(props: RadioGroupProps) {
33
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
34
+ const helpTextId = React.useId()
35
+ const labelId = React.useId()
36
+
37
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? null, props.onChange)
38
+
39
+ const {
40
+ validationMessage,
41
+ isInvalid: validationInvalid,
42
+ commitValidation
43
+ } = HeadlessForm.useValidation(
44
+ {
45
+ validate: props.validate,
46
+ value,
47
+ name: props.name,
48
+ isRequired: props.isRequired,
49
+ form: props.form,
50
+ focus() {
51
+ // Focus is handled by AriaRadioGroup
52
+ }
53
+ },
54
+ hiddenInputRef
55
+ )
56
+
57
+ const isInvalid = props.isInvalid || validationInvalid
58
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
59
+ const orientation = props.orientation ?? 'vertical'
60
+
61
+ return (
62
+ <div className="relative">
63
+ <HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value={value ?? ''} form={props.form} />
64
+
65
+ {props.label && (
66
+ <div className="flex justify-between items-center mb-2">
67
+ <FormFieldComponents.Label as="span" id={labelId}>
68
+ {props.label}
69
+ </FormFieldComponents.Label>
70
+ {props.cornerHint && <div className="text-sm text-neutral-500">{props.cornerHint}</div>}
71
+ </div>
72
+ )}
73
+
74
+ <AriaRadioGroup
75
+ aria-labelledby={props.label ? labelId : undefined}
76
+ aria-describedby={helpTextId}
77
+ value={value}
78
+ onChange={(newValue) => {
79
+ setValue(newValue)
80
+ commitValidation()
81
+ }}
82
+ isDisabled={props.isDisabled}
83
+ orientation={orientation === 'grid' ? undefined : orientation}
84
+ >
85
+ <div
86
+ className={tw(
87
+ 'flex',
88
+ orientation === 'vertical'
89
+ ? 'flex-col space-y-2'
90
+ : orientation === 'horizontal'
91
+ ? 'flex-row flex-wrap gap-4'
92
+ : 'grid gap-4',
93
+ props.className
94
+ )}
95
+ >
96
+ {props.children}
97
+ </div>
98
+ </AriaRadioGroup>
99
+
100
+ <FormFieldComponents.FormFieldHelpText
101
+ id={helpTextId}
102
+ isInvalid={isInvalid}
103
+ validationMessage={validationMessage}
104
+ hasWarning={hasWarning}
105
+ warningMessage={props.warningMessage}
106
+ helpText={props.helpText}
107
+ />
108
+ </div>
109
+ )
110
+ }
111
+
112
+ RadioGroup.displayName = 'RadioGroup'
@@ -0,0 +1,91 @@
1
+ import * as React from 'react'
2
+ import { Radio as AriaRadio } from 'react-aria-components'
3
+ import { tw } from '../utils/tw'
4
+
5
+ // ── Props ─────────────────────────────────────────────────────────────────────
6
+
7
+ export type PlainRadioProps = {
8
+ value: string
9
+ isDisabled?: boolean
10
+ 'aria-label'?: string
11
+ }
12
+
13
+ export type LabeledRadioProps = {
14
+ value: string
15
+ label?: React.ReactNode
16
+ description?: React.ReactNode
17
+ isDisabled?: boolean
18
+ }
19
+
20
+ // ── Plain Radio ──────────────────────────────────────────────────────────────
21
+
22
+ export function PlainRadio(props: PlainRadioProps) {
23
+ return (
24
+ <AriaRadio
25
+ value={props.value}
26
+ isDisabled={props.isDisabled}
27
+ aria-label={props['aria-label']}
28
+ className="group inline-flex items-center cursor-pointer data-[disabled]:cursor-not-allowed focus:outline-none [&>div]:focus-visible:ring-2 [&>div]:focus-visible:ring-primary-500/20 [&>div]:focus-visible:ring-offset-2"
29
+ >
30
+ {({ isSelected, isDisabled }) => <RadioCircle isSelected={isSelected} isDisabled={isDisabled} />}
31
+ </AriaRadio>
32
+ )
33
+ }
34
+
35
+ PlainRadio.displayName = 'PlainRadio'
36
+
37
+ // ── Labeled Radio ────────────────────────────────────────────────────────────
38
+
39
+ export function LabeledRadio(props: LabeledRadioProps) {
40
+ return (
41
+ <AriaRadio
42
+ value={props.value}
43
+ isDisabled={props.isDisabled}
44
+ className="group flex items-start gap-3 cursor-pointer data-[disabled]:cursor-not-allowed select-none focus:outline-none [&>div:first-child>div]:focus-visible:ring-2 [&>div:first-child>div]:focus-visible:ring-primary-500/20 [&>div:first-child>div]:focus-visible:ring-offset-2"
45
+ >
46
+ {({ isSelected, isDisabled }) => (
47
+ <>
48
+ <div className="flex-shrink-0">
49
+ <RadioCircle isSelected={isSelected} isDisabled={isDisabled} />
50
+ </div>
51
+ {props.label && (
52
+ <div className="flex flex-col">
53
+ <span className={tw('text-sm font-medium', isDisabled ? 'text-neutral-400' : 'text-neutral-700')}>
54
+ {props.label}
55
+ </span>
56
+ {props.description && (
57
+ <span className={tw('text-xs mt-0.5', isDisabled ? 'text-neutral-400' : 'text-neutral-500')}>
58
+ {props.description}
59
+ </span>
60
+ )}
61
+ </div>
62
+ )}
63
+ </>
64
+ )}
65
+ </AriaRadio>
66
+ )
67
+ }
68
+
69
+ LabeledRadio.displayName = 'LabeledRadio'
70
+
71
+ // ── Helper components ─────────────────────────────────────────────────────────
72
+
73
+ function RadioCircle({ isSelected, isDisabled }: { isSelected: boolean; isDisabled: boolean }) {
74
+ return (
75
+ <div
76
+ className={tw(
77
+ 'h-5 w-5 rounded-full border-2 transition-all duration-50 flex items-center justify-center',
78
+ isSelected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 bg-white',
79
+ !isDisabled && !isSelected && 'group-hovered:border-neutral-400',
80
+ isDisabled && 'opacity-50 cursor-not-allowed'
81
+ )}
82
+ >
83
+ <div
84
+ className={tw(
85
+ 'h-2 w-2 rounded-full bg-white transition-all duration-50',
86
+ isSelected ? 'scale-100 opacity-100' : 'scale-0 opacity-0'
87
+ )}
88
+ />
89
+ </div>
90
+ )
91
+ }