@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,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
|
+
}
|