@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.
- package/commands/add.ts +41 -0
- package/commands/index.ts +7 -0
- package/commands/list.ts +9 -0
- package/dist/components.json +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +48 -0
- package/package.json +49 -0
- package/registry.json +1445 -0
- package/scripts/build.ts +44 -0
- package/src/components/alert-dialog.tsx +150 -0
- package/src/components/autocomplete.tsx +288 -0
- package/src/components/autosuggest.tsx +314 -0
- package/src/components/avatar.tsx +54 -0
- package/src/components/boolean-select.tsx +48 -0
- package/src/components/button-group.tsx +75 -0
- package/src/components/button-link.tsx +71 -0
- package/src/components/button.tsx +132 -0
- package/src/components/checkbox-group.tsx +172 -0
- package/src/components/checkbox.tsx +207 -0
- package/src/components/chip.tsx +158 -0
- package/src/components/container.tsx +82 -0
- package/src/components/currency-field.tsx +183 -0
- package/src/components/date-input.tsx +189 -0
- package/src/components/date-picker.tsx +211 -0
- package/src/components/date-range-picker.tsx +290 -0
- package/src/components/date-time-picker.tsx +196 -0
- package/src/components/dialog.tsx +97 -0
- package/src/components/disclosure.tsx +114 -0
- package/src/components/drawer.tsx +78 -0
- package/src/components/enum-chip.tsx +30 -0
- package/src/components/file-input.tsx +245 -0
- package/src/components/form.tsx +82 -0
- package/src/components/headless-file-input.tsx +362 -0
- package/src/components/helpers/animated-popover.tsx +24 -0
- package/src/components/helpers/button-context.ts +10 -0
- package/src/components/helpers/calendar-month-year-picker.tsx +280 -0
- package/src/components/helpers/form-field.tsx +229 -0
- package/src/components/helpers/get-button-classes.ts +138 -0
- package/src/components/helpers/headless-button.tsx +36 -0
- package/src/components/helpers/pdf-dist.client.ts +6 -0
- package/src/components/icon.tsx +26 -0
- package/src/components/image-input.tsx +265 -0
- package/src/components/img.tsx +46 -0
- package/src/components/inline-alert.tsx +54 -0
- package/src/components/labeled-value.tsx +480 -0
- package/src/components/link-tabs.tsx +118 -0
- package/src/components/menu.tsx +152 -0
- package/src/components/month-day-input.tsx +176 -0
- package/src/components/multi-file-input.tsx +244 -0
- package/src/components/multi-image-input.tsx +389 -0
- package/src/components/multicomplete.tsx +322 -0
- package/src/components/multiselect.tsx +325 -0
- package/src/components/multisuggest.tsx +357 -0
- package/src/components/number-field.tsx +143 -0
- package/src/components/numeric-tag-field.tsx +271 -0
- package/src/components/pdf-input.tsx +249 -0
- package/src/components/pdf.tsx +86 -0
- package/src/components/percentage-field.tsx +187 -0
- package/src/components/phone-number-field.tsx +166 -0
- package/src/components/radio-group.tsx +112 -0
- package/src/components/radio.tsx +91 -0
- package/src/components/select.tsx +215 -0
- package/src/components/spinner.tsx +16 -0
- package/src/components/stepper.tsx +181 -0
- package/src/components/switch.tsx +186 -0
- package/src/components/tabs.tsx +151 -0
- package/src/components/tag-field.tsx +250 -0
- package/src/components/text-area.tsx +148 -0
- package/src/components/text-field.tsx +144 -0
- package/src/components/time-input.tsx +198 -0
- package/src/components/toast.tsx +176 -0
- package/src/components/toggle-button-group.tsx +94 -0
- package/src/components/year-month-input.tsx +187 -0
- package/src/utils/colors.ts +44 -0
- package/src/utils/compose-refs.ts +32 -0
- package/src/utils/enum-colors.ts +1 -0
- package/src/utils/file-input.ts +49 -0
- package/src/utils/icons.d.ts +20 -0
- package/src/utils/tw.ts +13 -0
- package/src/utils/use-element-size.ts +35 -0
- package/src/utils/use-number-input.ts +143 -0
- package/src/utils/use-pagination.ts +38 -0
- package/src/utils/use-prevent-default.ts +27 -0
- package/src/utils/use-render-props.ts +39 -0
- package/src/utils/use-spin-delay.ts +24 -0
- package/src/utils/use-stable-accessor.ts +11 -0
- package/src/utils/use-tab-indicator.ts +106 -0
- package/tests/commands.test.ts +81 -0
- 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
|
+
}
|