@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,215 @@
1
+ import * as React from 'react'
2
+ import { Select as AriaSelect, Button, ListBoxItem } from 'react-aria-components'
3
+ import { AnimatedPopover } from './helpers/animated-popover'
4
+ import { HeadlessForm } from '@maestro-js/form'
5
+ import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
6
+ import { tw } from '../utils/tw'
7
+ import { FormFieldComponents } from './helpers/form-field'
8
+ import { Icon } from './icon'
9
+ import { getColor, stringToNumber } from '../utils/colors'
10
+
11
+ // ── Types ──────────────────────────────────────────────────────────────────────
12
+
13
+ export type SelectOption = {
14
+ value: string | number | null
15
+ label: string
16
+ secondary?: string
17
+ isDisabled?: boolean
18
+ }
19
+
20
+ export type SelectProps<T extends SelectOption = SelectOption> = {
21
+ // Form identification
22
+ name?: string
23
+ form?: string
24
+
25
+ // Display elements
26
+ label?: React.ReactNode
27
+ cornerHint?: React.ReactNode
28
+ helpText?: React.ReactNode
29
+ placeholder?: string
30
+ warningMessage?: React.ReactNode
31
+
32
+ // Validation and state
33
+ isRequired?: boolean
34
+ isDisabled?: boolean
35
+ isReadonly?: boolean
36
+ isInvalid?: boolean
37
+ validate?: ValidationFunction<T['value'] | null>
38
+ errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
39
+
40
+ // Value management
41
+ value?: T['value'] | null
42
+ defaultValue?: T['value'] | null
43
+ onChange?: (value: T['value'] | null) => void
44
+
45
+ // Options
46
+ options: T[]
47
+ renderOption?: (props: { option: T; colored: boolean; index: number }) => React.ReactNode
48
+
49
+ colored?: boolean
50
+ shape?: 'rectangle' | 'oval'
51
+ }
52
+
53
+ // ── Component ──────────────────────────────────────────────────────────────────
54
+
55
+ export function Select<T extends SelectOption = SelectOption>(props: SelectProps<T>) {
56
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
57
+ const buttonRef = React.useRef<HTMLButtonElement>(null)
58
+ const helpTextId = React.useId()
59
+ const labelId = React.useId()
60
+
61
+ // Controlled state management
62
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? null, props.onChange)
63
+
64
+ // Form validation
65
+ const {
66
+ validationMessage,
67
+ isInvalid: validationInvalid,
68
+ commitValidation
69
+ } = HeadlessForm.useValidation(
70
+ {
71
+ validate: props.validate,
72
+ value,
73
+ name: props.name,
74
+ isRequired: props.isRequired,
75
+ form: props.form,
76
+ focus() {
77
+ buttonRef.current?.focus()
78
+ }
79
+ },
80
+ hiddenInputRef
81
+ )
82
+
83
+ const isInvalid = props.isInvalid || validationInvalid
84
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
85
+
86
+ // Find the selected option for display
87
+ const selectedOption = React.useMemo(() => {
88
+ return props.options.find((option) => option.value === value) ?? null
89
+ }, [props.options, value])
90
+
91
+ const selectedKey = value != null ? toKey(value) : null
92
+
93
+ return (
94
+ <div className="relative">
95
+ {/* Hidden input for form submission */}
96
+ <HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value={value ?? ''} form={props.form} />
97
+
98
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
99
+
100
+ <AriaSelect
101
+ aria-labelledby={props.label ? labelId : undefined}
102
+ aria-label={!props.label ? 'Select' : undefined}
103
+ selectedKey={selectedKey}
104
+ onSelectionChange={(key) => {
105
+ const newValue = fromKey(key, props.options)
106
+ setValue(newValue)
107
+ commitValidation()
108
+ }}
109
+ isDisabled={props.isDisabled || props.isReadonly}
110
+ isInvalid={isInvalid}
111
+ isRequired={props.isRequired}
112
+ >
113
+ {/* Select button */}
114
+ <Button
115
+ ref={buttonRef}
116
+ className={tw(
117
+ 'relative overflow-hidden border transition-all duration-200 max-w-96',
118
+ props.shape === 'oval' ? 'rounded-full' : 'rounded',
119
+ 'focus:outline-none focus:ring-1',
120
+ (props.isDisabled || props.isReadonly) && 'border-neutral-100 bg-neutral-50 text-neutral-400 cursor-not-allowed',
121
+ isInvalid && 'border-negative-200 bg-negative-50 focus:border-negative-500 focus:ring-negative-500/20',
122
+ hasWarning && 'border-notice-200 bg-notice-50 focus:border-notice-500 focus:ring-notice-500/20',
123
+ !isInvalid &&
124
+ !hasWarning &&
125
+ !props.isDisabled &&
126
+ !props.isReadonly &&
127
+ 'border-neutral-200 hover:border-neutral-300 focus:border-primary-500 focus:ring-primary-500/20',
128
+ 'w-full cursor-default py-2 pl-3 pr-10 text-left placeholder:text-neutral-400'
129
+ )}
130
+ aria-invalid={isInvalid || undefined}
131
+ aria-describedby={helpTextId}
132
+ >
133
+ {props.colored && selectedOption ? (
134
+ <div
135
+ className="absolute left-0 top-0 w-[3px] h-full"
136
+ style={{ backgroundColor: getColor(stringToNumber(String(selectedOption.value))) }}
137
+ />
138
+ ) : null}
139
+ {selectedOption ? (
140
+ <span className="block truncate text-sm min-h-5">{selectedOption.label}</span>
141
+ ) : (
142
+ <span className="block truncate text-sm min-h-5 text-neutral-300 italic">
143
+ {props.placeholder ?? 'Select an option'}
144
+ </span>
145
+ )}
146
+ <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
147
+ <Icon name="chevron-up-down" className="h-4 w-4 text-neutral-400" />
148
+ </span>
149
+ </Button>
150
+
151
+ {/* Options dropdown */}
152
+ <AnimatedPopover offset={4} className="w-(--trigger-width)">
153
+ <FormFieldComponents.DropdownListBox
154
+ items={props.options.map((option, index) => ({ ...option, index, id: toKey(option.value) }))}
155
+ renderEmptyState={() => <div className="px-3 py-2 text-neutral-500 text-sm">No options available</div>}
156
+ >
157
+ {(item) => {
158
+ const option = item as T & { index: number; id: string }
159
+ if (props.renderOption) {
160
+ return (
161
+ <ListBoxItem
162
+ id={option.id}
163
+ textValue={option.label}
164
+ isDisabled={option.isDisabled}
165
+ className="outline-none"
166
+ >
167
+ {props.renderOption({ option, colored: props.colored ?? false, index: option.index })}
168
+ </ListBoxItem>
169
+ )
170
+ }
171
+
172
+ const color = props.colored ? getColor(stringToNumber(String(option.value))) : null
173
+ return (
174
+ <FormFieldComponents.DropdownItem
175
+ id={option.id}
176
+ textValue={option.label}
177
+ label={option.label}
178
+ secondary={option.secondary}
179
+ isDisabled={option.isDisabled}
180
+ color={color}
181
+ />
182
+ )
183
+ }}
184
+ </FormFieldComponents.DropdownListBox>
185
+ </AnimatedPopover>
186
+ </AriaSelect>
187
+
188
+ <FormFieldComponents.FormFieldHelpText
189
+ id={helpTextId}
190
+ isInvalid={isInvalid}
191
+ validationMessage={validationMessage}
192
+ hasWarning={hasWarning}
193
+ warningMessage={props.warningMessage}
194
+ helpText={props.helpText}
195
+ />
196
+ </div>
197
+ )
198
+ }
199
+
200
+ // ── Helpers ─────────────────────────────────────────────────────────────────────
201
+
202
+ const NULL_KEY = '__select_null__'
203
+
204
+ function toKey(value: string | number | null): string {
205
+ if (value === null) return NULL_KEY
206
+ return String(value)
207
+ }
208
+
209
+ function fromKey<T extends SelectOption>(key: React.Key | null, options: T[]): T['value'] | null {
210
+ if (key === null || key === undefined) return null
211
+ const keyStr = String(key)
212
+ if (keyStr === NULL_KEY) return null
213
+ const option = options.find((o) => toKey(o.value) === keyStr)
214
+ return option?.value ?? null
215
+ }
@@ -0,0 +1,16 @@
1
+ import { ProgressBar } from 'react-aria-components'
2
+ import { Icon } from './icon'
3
+ import { tw } from '../utils/tw'
4
+
5
+ interface SpinnerProps {
6
+ className?: string
7
+ 'aria-label'?: string
8
+ }
9
+
10
+ export function Spinner({ className, 'aria-label': ariaLabel = 'Loading…' }: SpinnerProps) {
11
+ return (
12
+ <ProgressBar isIndeterminate aria-label={ariaLabel}>
13
+ <Icon name="arrow-path" className={tw('animate-spin', className)} />
14
+ </ProgressBar>
15
+ )
16
+ }
@@ -0,0 +1,181 @@
1
+ import * as React from 'react'
2
+ import { Icon } from './icon'
3
+ import { tw } from '../utils/tw'
4
+
5
+ // ── Types ────────────────────────────────────────────────────────────────────────
6
+
7
+ export type StepperGroupProps = {
8
+ children: React.ReactNode
9
+ className?: string
10
+ activeStep?: number
11
+ orientation?: 'horizontal' | 'vertical'
12
+ }
13
+
14
+ export type StepProps = {
15
+ children: React.ReactNode
16
+ className?: string
17
+ completed?: boolean
18
+ error?: boolean
19
+ }
20
+
21
+ export type StepLabelProps = {
22
+ children: React.ReactNode
23
+ className?: string
24
+ }
25
+
26
+ // ── Context ─────────────────────────────────────────────────────────────────────
27
+
28
+ const StepperContext = React.createContext<{
29
+ activeStep: number
30
+ orientation: 'horizontal' | 'vertical'
31
+ totalSteps: number
32
+ }>({
33
+ activeStep: 0,
34
+ orientation: 'horizontal',
35
+ totalSteps: 0
36
+ })
37
+
38
+ const StepContext = React.createContext<{
39
+ completed: boolean
40
+ active: boolean
41
+ error: boolean
42
+ index: number
43
+ }>({
44
+ completed: false,
45
+ active: false,
46
+ error: false,
47
+ index: 0
48
+ })
49
+
50
+ // ── StepperGroup ────────────────────────────────────────────────────────────────
51
+
52
+ function StepperGroup({ children, className, activeStep = 0, orientation = 'horizontal' }: StepperGroupProps) {
53
+ const steps = React.Children.toArray(children).filter((child) => React.isValidElement(child))
54
+ const totalSteps = steps.length
55
+
56
+ return (
57
+ <StepperContext.Provider value={{ activeStep, orientation, totalSteps }}>
58
+ <div
59
+ className={tw('flex', orientation === 'horizontal' ? 'flex-row' : 'flex-col', className)}
60
+ role="group"
61
+ aria-label="Progress"
62
+ >
63
+ {steps.map((step, idx) => {
64
+ if (React.isValidElement<StepProps & { index?: number; isLast?: boolean }>(step)) {
65
+ return React.cloneElement(step, {
66
+ ...step.props,
67
+ index: idx,
68
+ isLast: idx === totalSteps - 1,
69
+ key: idx
70
+ })
71
+ }
72
+ return step
73
+ })}
74
+ </div>
75
+ </StepperContext.Provider>
76
+ )
77
+ }
78
+
79
+ StepperGroup.displayName = 'StepperGroup'
80
+
81
+ // ── Step ────────────────────────────────────────────────────────────────────────
82
+
83
+ function Step({
84
+ children,
85
+ className,
86
+ completed = false,
87
+ error = false,
88
+ index = 0,
89
+ isLast
90
+ }: StepProps & { index?: number; isLast?: boolean }) {
91
+ const { activeStep, orientation } = React.useContext(StepperContext)
92
+ const active = activeStep === index
93
+
94
+ const firstConnectorColor = error ? 'bg-negative-500' : completed || active ? 'bg-primary-500' : 'bg-neutral-300'
95
+ const lastConnectorColor = error ? 'bg-negative-500' : completed ? 'bg-primary-500' : 'bg-neutral-300'
96
+
97
+ if (orientation === 'horizontal') {
98
+ return (
99
+ <StepContext.Provider value={{ completed, active, error, index }}>
100
+ <div className={tw('flex flex-row items-start flex-1 relative', className)}>
101
+ {index !== 0 ? (
102
+ <div className="flex flex-1 items-center pt-4 w-1/2 absolute left-0">
103
+ <div className={tw('h-[2px] w-full transition-colors', firstConnectorColor)} />
104
+ </div>
105
+ ) : null}
106
+ {!isLast ? (
107
+ <div className="flex flex-1 items-center pt-4 w-1/2 absolute right-0">
108
+ <div className={tw('h-[2px] w-full transition-colors', lastConnectorColor)} />
109
+ </div>
110
+ ) : null}
111
+ <div className="flex flex-col items-center w-full relative">{children}</div>
112
+ </div>
113
+ </StepContext.Provider>
114
+ )
115
+ }
116
+
117
+ return (
118
+ <StepContext.Provider value={{ completed, active, error, index }}>
119
+ <div className={tw('relative flex flex-col min-h-10', className)}>
120
+ {index !== 0 ? (
121
+ <div className="flex flex-1 justify-center pl-4 h-1/2 absolute top-0">
122
+ <div className={tw('w-[2px] h-full transition-colors', firstConnectorColor)} />
123
+ </div>
124
+ ) : null}
125
+ {!isLast ? (
126
+ <div className="flex flex-1 items-center pl-4 h-1/2 absolute bottom-0">
127
+ <div className={tw('w-[2px] h-full transition-colors', lastConnectorColor)} />
128
+ </div>
129
+ ) : null}
130
+ <div className="flex flex-row w-full relative">{children}</div>
131
+ </div>
132
+ </StepContext.Provider>
133
+ )
134
+ }
135
+
136
+ Step.displayName = 'Step'
137
+
138
+ // ── StepLabel ───────────────────────────────────────────────────────────────────
139
+
140
+ function StepLabel({ children, className }: StepLabelProps) {
141
+ const { completed, active, error, index } = React.useContext(StepContext)
142
+ const { orientation } = React.useContext(StepperContext)
143
+
144
+ const iconBg = error
145
+ ? 'bg-negative-500 border-negative-500 text-white'
146
+ : completed
147
+ ? 'bg-primary-500 border-primary-500 text-white'
148
+ : active
149
+ ? 'bg-primary-500 border-primary-500 text-white'
150
+ : 'bg-white border-neutral-300 text-neutral-600'
151
+
152
+ const textColor = error ? 'text-negative-600' : active ? 'text-neutral-900' : 'text-neutral-500'
153
+
154
+ return (
155
+ <div className={tw('flex items-center', orientation === 'horizontal' ? 'flex-col text-center' : 'gap-3', className)}>
156
+ <div
157
+ className={tw('flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 transition-colors', iconBg)}
158
+ aria-current={active ? 'step' : undefined}
159
+ >
160
+ {completed ? (
161
+ <Icon name="check" className="h-4 w-4" aria-hidden="true" />
162
+ ) : (
163
+ <span className="text-sm font-semibold">{index + 1}</span>
164
+ )}
165
+ </div>
166
+ <div className={tw('flex flex-col', orientation === 'horizontal' ? 'mt-2' : '')}>
167
+ <span className={tw('text-sm font-medium', textColor)}>{children}</span>
168
+ </div>
169
+ </div>
170
+ )
171
+ }
172
+
173
+ StepLabel.displayName = 'StepLabel'
174
+
175
+ // ── Export ───────────────────────────────────────────────────────────────────────
176
+
177
+ export const Stepper = {
178
+ Group: StepperGroup,
179
+ Step,
180
+ Label: StepLabel
181
+ }
@@ -0,0 +1,186 @@
1
+ import * as React from 'react'
2
+ import { Switch as AriaSwitch } 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
+ // ── Props ──────────────────────────────────────────────────────────────────────
9
+
10
+ export type PlainSwitchProps = {
11
+ name?: string
12
+ form?: string
13
+
14
+ isDisabled?: boolean
15
+
16
+ value?: boolean
17
+ defaultValue?: boolean
18
+ onChange?: (value: boolean) => void
19
+
20
+ 'aria-label'?: string
21
+ }
22
+
23
+ export type LabeledSwitchProps = {
24
+ name?: string
25
+ form?: string
26
+
27
+ label?: React.ReactNode
28
+ description?: React.ReactNode
29
+ cornerHint?: React.ReactNode
30
+ helpText?: React.ReactNode
31
+ warningMessage?: React.ReactNode
32
+
33
+ isRequired?: boolean
34
+ isDisabled?: boolean
35
+ isInvalid?: boolean
36
+ validate?: ValidationFunction<boolean>
37
+ errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
38
+
39
+ value?: boolean
40
+ defaultValue?: boolean
41
+ onChange?: (value: boolean) => void
42
+
43
+ labelPosition?: 'left' | 'right'
44
+ }
45
+
46
+ // ── Plain Switch ───────────────────────────────────────────────────────────────
47
+
48
+ export function PlainSwitch(props: PlainSwitchProps) {
49
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
50
+
51
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? false, props.onChange)
52
+
53
+ return (
54
+ <AriaSwitch
55
+ isSelected={value}
56
+ onChange={setValue}
57
+ isDisabled={props.isDisabled}
58
+ aria-label={props['aria-label']}
59
+ 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 [&>div]:focus-visible:rounded-full"
60
+ >
61
+ {({ isSelected, isDisabled }) => (
62
+ <>
63
+ {props.isDisabled || !props.name ? null : (
64
+ <HeadlessForm.HiddenInput
65
+ ref={hiddenInputRef}
66
+ name={props.name}
67
+ form={props.form}
68
+ value={value ? 'true' : 'false'}
69
+ />
70
+ )}
71
+ <SwitchTrack isSelected={isSelected} isDisabled={isDisabled} />
72
+ </>
73
+ )}
74
+ </AriaSwitch>
75
+ )
76
+ }
77
+
78
+ PlainSwitch.displayName = 'PlainSwitch'
79
+
80
+ // ── Labeled Switch ─────────────────────────────────────────────────────────────
81
+
82
+ export function LabeledSwitch(props: LabeledSwitchProps) {
83
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
84
+ const helpTextId = React.useId()
85
+
86
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? false, props.onChange)
87
+
88
+ const {
89
+ validationMessage,
90
+ isInvalid: validationInvalid,
91
+ commitValidation
92
+ } = HeadlessForm.useValidation(
93
+ {
94
+ validate: props.validate,
95
+ value,
96
+ name: props.name,
97
+ isRequired: props.isRequired,
98
+ form: props.form,
99
+ focus() {
100
+ hiddenInputRef.current?.closest('label')?.focus()
101
+ }
102
+ },
103
+ hiddenInputRef
104
+ )
105
+
106
+ const isInvalid = props.isInvalid || validationInvalid
107
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
108
+ const labelPosition = props.labelPosition ?? 'right'
109
+
110
+ const labelElement = props.label && (
111
+ <div className="flex flex-col text-left">
112
+ <FormFieldComponents.Label as="span">{props.label}</FormFieldComponents.Label>
113
+ {props.description && (
114
+ <span className={tw('text-xs mt-0.5', props.isDisabled ? 'text-neutral-400' : 'text-neutral-500')}>
115
+ {props.description}
116
+ </span>
117
+ )}
118
+ </div>
119
+ )
120
+
121
+ return (
122
+ <div className="relative">
123
+ <AriaSwitch
124
+ isSelected={value}
125
+ onChange={(checked) => {
126
+ setValue(checked)
127
+ commitValidation()
128
+ }}
129
+ isDisabled={props.isDisabled}
130
+ aria-describedby={helpTextId}
131
+ className="group flex items-center 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 [&>div:first-child>div]:focus-visible:rounded-full"
132
+ >
133
+ {({ isSelected, isDisabled }) => (
134
+ <>
135
+ {props.isDisabled || !props.name ? null : (
136
+ <HeadlessForm.HiddenInput
137
+ ref={hiddenInputRef}
138
+ name={props.name}
139
+ form={props.form}
140
+ value={value ? 'true' : 'false'}
141
+ />
142
+ )}
143
+ {labelPosition === 'left' && labelElement}
144
+ <div className="flex-shrink-0 flex items-center">
145
+ <SwitchTrack isSelected={isSelected} isDisabled={isDisabled} />
146
+ </div>
147
+ {labelPosition === 'right' && labelElement}
148
+ {props.cornerHint && props.label && <div className="ml-auto text-sm text-neutral-500">{props.cornerHint}</div>}
149
+ </>
150
+ )}
151
+ </AriaSwitch>
152
+
153
+ <FormFieldComponents.FormFieldHelpText
154
+ id={helpTextId}
155
+ isInvalid={isInvalid}
156
+ validationMessage={validationMessage}
157
+ hasWarning={hasWarning}
158
+ warningMessage={props.warningMessage}
159
+ helpText={props.helpText}
160
+ />
161
+ </div>
162
+ )
163
+ }
164
+
165
+ LabeledSwitch.displayName = 'LabeledSwitch'
166
+
167
+ // ── Helpers ────────────────────────────────────────────────────────────────────
168
+
169
+ function SwitchTrack({ isSelected, isDisabled }: { isSelected: boolean; isDisabled: boolean }) {
170
+ return (
171
+ <div
172
+ className={tw(
173
+ 'relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
174
+ isSelected ? 'bg-primary-500' : 'bg-neutral-200',
175
+ isDisabled && 'opacity-50 cursor-not-allowed'
176
+ )}
177
+ >
178
+ <span
179
+ className={tw(
180
+ 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
181
+ isSelected ? 'translate-x-5' : 'translate-x-0'
182
+ )}
183
+ />
184
+ </div>
185
+ )
186
+ }