@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,357 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { ComboBox as AriaComboBox, Input, ListBox, ListBoxItem } from 'react-aria-components'
|
|
3
|
+
import { AnimatedPopover } from './helpers/animated-popover'
|
|
4
|
+
import type { Key } from 'react-aria-components'
|
|
5
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
6
|
+
import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
|
|
7
|
+
import { tw } from '../utils/tw'
|
|
8
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
9
|
+
import { matchSorter } from 'match-sorter'
|
|
10
|
+
import { Icon } from './icon'
|
|
11
|
+
import { useElementSize } from '../utils/use-element-size'
|
|
12
|
+
import { composeRefs } from '../utils/compose-refs'
|
|
13
|
+
|
|
14
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export type MultisuggestOption = {
|
|
17
|
+
value: string
|
|
18
|
+
label: string
|
|
19
|
+
secondary?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MultisuggestProps<T extends MultisuggestOption = MultisuggestOption> = {
|
|
23
|
+
// Form identification
|
|
24
|
+
name?: string
|
|
25
|
+
form?: string
|
|
26
|
+
|
|
27
|
+
// Display elements
|
|
28
|
+
label?: React.ReactNode
|
|
29
|
+
cornerHint?: React.ReactNode
|
|
30
|
+
helpText?: React.ReactNode
|
|
31
|
+
placeholder?: string
|
|
32
|
+
warningMessage?: React.ReactNode
|
|
33
|
+
|
|
34
|
+
// Validation and state
|
|
35
|
+
isRequired?: boolean
|
|
36
|
+
isDisabled?: boolean
|
|
37
|
+
isReadonly?: boolean
|
|
38
|
+
isInvalid?: boolean
|
|
39
|
+
validate?: ValidationFunction<string[]>
|
|
40
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
41
|
+
|
|
42
|
+
// Value management
|
|
43
|
+
value?: string[]
|
|
44
|
+
defaultValue?: string[]
|
|
45
|
+
onChange?: (value: string[]) => void
|
|
46
|
+
onCustomValue?: (value: string) => void
|
|
47
|
+
|
|
48
|
+
// Options
|
|
49
|
+
options: T[]
|
|
50
|
+
renderOption?: (props: { option: T; isCustom: boolean; index: number }) => React.ReactNode
|
|
51
|
+
|
|
52
|
+
// Filtering
|
|
53
|
+
filterFunction?: ((items: readonly T[], value: string) => T[]) | (keyof T)[]
|
|
54
|
+
|
|
55
|
+
// Loading
|
|
56
|
+
isLoading?: boolean
|
|
57
|
+
|
|
58
|
+
// Input
|
|
59
|
+
inputAutocomplete?: React.ComponentProps<'input'>['autoComplete']
|
|
60
|
+
id?: string
|
|
61
|
+
shape?: 'rectangle' | 'oval'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Component ──────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const CUSTOM_VALUE_KEY = '__multisuggest_custom__'
|
|
67
|
+
const EMPTY_ARR: never[] = []
|
|
68
|
+
|
|
69
|
+
export function Multisuggest<T extends MultisuggestOption = MultisuggestOption>(props: MultisuggestProps<T>) {
|
|
70
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
71
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
72
|
+
const fieldGroupRef = React.useRef<HTMLDivElement>(null)
|
|
73
|
+
const [fieldGroupEl, setFieldGroupEl] = React.useState<HTMLDivElement | null>(null)
|
|
74
|
+
const fieldGroupSize = useElementSize(fieldGroupEl)
|
|
75
|
+
const helpTextId = React.useId()
|
|
76
|
+
const labelId = React.useId()
|
|
77
|
+
const [query, setQuery] = React.useState('')
|
|
78
|
+
const queryRef = React.useRef(query)
|
|
79
|
+
queryRef.current = query
|
|
80
|
+
|
|
81
|
+
// Controlled state management (array of values)
|
|
82
|
+
const [selectedValues, setSelectedValues] = HeadlessForm.useControlledState(
|
|
83
|
+
props.value,
|
|
84
|
+
props.defaultValue ?? EMPTY_ARR,
|
|
85
|
+
props.onChange
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Form validation
|
|
89
|
+
const {
|
|
90
|
+
validationMessage,
|
|
91
|
+
isInvalid: validationInvalid,
|
|
92
|
+
commitValidation
|
|
93
|
+
} = HeadlessForm.useValidation(
|
|
94
|
+
{
|
|
95
|
+
validate: props.validate,
|
|
96
|
+
value: selectedValues,
|
|
97
|
+
name: props.name,
|
|
98
|
+
isRequired: props.isRequired,
|
|
99
|
+
form: props.form,
|
|
100
|
+
focus() {
|
|
101
|
+
inputRef.current?.focus()
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
hiddenInputRef
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
108
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
109
|
+
const isInteractive = !props.isDisabled && !props.isReadonly
|
|
110
|
+
|
|
111
|
+
// Find selected options (both from options list and custom values)
|
|
112
|
+
const selectedOptions = React.useMemo(() => {
|
|
113
|
+
return selectedValues.map((val) => {
|
|
114
|
+
const existingOption = props.options.find((opt) => opt.value === val)
|
|
115
|
+
if (existingOption) return existingOption
|
|
116
|
+
return { value: val, label: val } as T
|
|
117
|
+
})
|
|
118
|
+
}, [props.options, selectedValues])
|
|
119
|
+
|
|
120
|
+
// Filter unselected options based on query
|
|
121
|
+
const availableOptions = React.useMemo(() => {
|
|
122
|
+
if (props.isLoading) return []
|
|
123
|
+
|
|
124
|
+
const unselected = props.options.filter((option) => !selectedValues.includes(option.value))
|
|
125
|
+
const filterFn = props.filterFunction
|
|
126
|
+
|
|
127
|
+
if (typeof filterFn === 'function') {
|
|
128
|
+
return filterFn(unselected, query).slice(0, 50)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (Array.isArray(filterFn)) {
|
|
132
|
+
return matchSorter(unselected, query, {
|
|
133
|
+
keys: filterFn as string[]
|
|
134
|
+
}).slice(0, 50)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return defaultFilterFunction(unselected, query).slice(0, 50)
|
|
138
|
+
}, [props.options, selectedValues, query, props.filterFunction, props.isLoading])
|
|
139
|
+
|
|
140
|
+
// Check if we should show a custom value option
|
|
141
|
+
const showCustomOption = React.useMemo(() => {
|
|
142
|
+
if (!query || props.isLoading) return false
|
|
143
|
+
const queryNotInOptions = !availableOptions.some((option) => option.label === query || option.value === query)
|
|
144
|
+
const queryNotSelected = !selectedValues.includes(query)
|
|
145
|
+
return queryNotInOptions && queryNotSelected
|
|
146
|
+
}, [query, availableOptions, selectedValues, props.isLoading])
|
|
147
|
+
|
|
148
|
+
// Items for React Aria (with id for key management), including custom option
|
|
149
|
+
const items = React.useMemo(() => {
|
|
150
|
+
const mapped = availableOptions.map((option, index) => ({
|
|
151
|
+
...option,
|
|
152
|
+
index,
|
|
153
|
+
id: option.value,
|
|
154
|
+
isCustom: false
|
|
155
|
+
}))
|
|
156
|
+
|
|
157
|
+
if (showCustomOption) {
|
|
158
|
+
return [
|
|
159
|
+
{ value: query, label: query, index: -1, id: CUSTOM_VALUE_KEY, isCustom: true } as T & {
|
|
160
|
+
index: number
|
|
161
|
+
id: string
|
|
162
|
+
isCustom: boolean
|
|
163
|
+
},
|
|
164
|
+
...mapped
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return mapped
|
|
169
|
+
}, [availableOptions, showCustomOption, query])
|
|
170
|
+
|
|
171
|
+
const handleRemoveOption = (value: string) => {
|
|
172
|
+
const newValues = selectedValues.filter((v) => v !== value)
|
|
173
|
+
setSelectedValues(newValues)
|
|
174
|
+
commitValidation()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const addValue = (val: string, isCustom: boolean) => {
|
|
178
|
+
if (selectedValues.includes(val)) return
|
|
179
|
+
setSelectedValues([...selectedValues, val])
|
|
180
|
+
if (isCustom) props.onCustomValue?.(val)
|
|
181
|
+
setQuery('')
|
|
182
|
+
commitValidation()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handleSelectionChange = (key: Key | null) => {
|
|
186
|
+
if (key === null) return
|
|
187
|
+
const keyStr = String(key)
|
|
188
|
+
|
|
189
|
+
if (keyStr === CUSTOM_VALUE_KEY) {
|
|
190
|
+
addValue(queryRef.current, true)
|
|
191
|
+
} else {
|
|
192
|
+
const option = props.options.find((o) => o.value === keyStr)
|
|
193
|
+
if (option && !selectedValues.includes(option.value)) {
|
|
194
|
+
addValue(option.value, false)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
200
|
+
if (e.key === 'Backspace' && !query && selectedOptions.length > 0) {
|
|
201
|
+
e.preventDefault()
|
|
202
|
+
handleRemoveOption(selectedOptions[selectedOptions.length - 1]!.value)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className="relative">
|
|
208
|
+
{/* Hidden inputs for form submission */}
|
|
209
|
+
<HeadlessForm.HiddenInput
|
|
210
|
+
ref={hiddenInputRef}
|
|
211
|
+
name={props.name}
|
|
212
|
+
value={JSON.stringify(selectedValues)}
|
|
213
|
+
form={props.form}
|
|
214
|
+
/>
|
|
215
|
+
|
|
216
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
|
|
217
|
+
|
|
218
|
+
<AriaComboBox
|
|
219
|
+
aria-labelledby={props.label ? labelId : undefined}
|
|
220
|
+
aria-label={!props.label ? 'Multisuggest' : undefined}
|
|
221
|
+
selectedKey={null}
|
|
222
|
+
onSelectionChange={handleSelectionChange}
|
|
223
|
+
inputValue={query}
|
|
224
|
+
onInputChange={setQuery}
|
|
225
|
+
items={items}
|
|
226
|
+
isDisabled={props.isDisabled || props.isReadonly}
|
|
227
|
+
isInvalid={isInvalid}
|
|
228
|
+
allowsEmptyCollection
|
|
229
|
+
allowsCustomValue
|
|
230
|
+
menuTrigger="focus"
|
|
231
|
+
onBlur={() => {
|
|
232
|
+
if (queryRef.current && showCustomOption) {
|
|
233
|
+
addValue(queryRef.current, true)
|
|
234
|
+
}
|
|
235
|
+
commitValidation()
|
|
236
|
+
}}
|
|
237
|
+
onKeyDown={handleKeyDown}
|
|
238
|
+
>
|
|
239
|
+
{/* Input container with chips */}
|
|
240
|
+
<FormFieldComponents.FieldGroup
|
|
241
|
+
ref={composeRefs(fieldGroupRef, setFieldGroupEl)}
|
|
242
|
+
isDisabled={props.isDisabled}
|
|
243
|
+
isReadonly={props.isReadonly}
|
|
244
|
+
isInvalid={isInvalid}
|
|
245
|
+
hasWarning={hasWarning}
|
|
246
|
+
shape={props.shape}
|
|
247
|
+
className="flex"
|
|
248
|
+
>
|
|
249
|
+
<div className="flex flex-wrap items-center gap-1.5 px-3 py-1.5 w-full">
|
|
250
|
+
{/* Display selected chips */}
|
|
251
|
+
{selectedOptions.map((option) => (
|
|
252
|
+
<span
|
|
253
|
+
key={option.value}
|
|
254
|
+
className={tw(
|
|
255
|
+
'inline-flex items-center rounded-full font-normal transition-colors max-w-full px-2 py-0.5 text-xs',
|
|
256
|
+
'bg-primary-500/10 text-primary-500',
|
|
257
|
+
props.isDisabled && 'opacity-50'
|
|
258
|
+
)}
|
|
259
|
+
>
|
|
260
|
+
<span className="truncate">{option.label}</span>
|
|
261
|
+
{isInteractive && (
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={(e) => {
|
|
265
|
+
e.stopPropagation()
|
|
266
|
+
handleRemoveOption(option.value)
|
|
267
|
+
}}
|
|
268
|
+
className="inline-flex items-center justify-center rounded-full transition-colors translate-x-1 h-4 w-4 -m-0.5 p-0.5 hover:bg-black/10"
|
|
269
|
+
aria-label={`Remove ${option.label}`}
|
|
270
|
+
tabIndex={-1}
|
|
271
|
+
>
|
|
272
|
+
<Icon name="x-mark" className="h-2 w-2" />
|
|
273
|
+
</button>
|
|
274
|
+
)}
|
|
275
|
+
</span>
|
|
276
|
+
))}
|
|
277
|
+
|
|
278
|
+
{/* Search input */}
|
|
279
|
+
<Input
|
|
280
|
+
ref={inputRef}
|
|
281
|
+
autoComplete={props.inputAutocomplete ?? 'off'}
|
|
282
|
+
className="flex-1 min-w-[120px] px-0 bg-transparent border-0 focus:ring-0 focus:outline-none text-sm text-neutral-900 placeholder:text-neutral-400 py-0.5 disabled:cursor-not-allowed"
|
|
283
|
+
placeholder={selectedOptions.length === 0 ? (props.placeholder ?? 'Search or type to add...') : ''}
|
|
284
|
+
aria-describedby={helpTextId}
|
|
285
|
+
id={props.id}
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
<FormFieldComponents.ChevronButton className="py-0" />
|
|
289
|
+
</FormFieldComponents.FieldGroup>
|
|
290
|
+
|
|
291
|
+
{/* Options dropdown */}
|
|
292
|
+
<AnimatedPopover
|
|
293
|
+
triggerRef={fieldGroupRef}
|
|
294
|
+
offset={4}
|
|
295
|
+
style={{ width: fieldGroupSize.width, minWidth: fieldGroupSize.width }}
|
|
296
|
+
>
|
|
297
|
+
<div
|
|
298
|
+
className={tw(
|
|
299
|
+
'overflow-hidden rounded bg-white border border-neutral-200 shadow-lg text-sm w-full focus:outline-none'
|
|
300
|
+
)}
|
|
301
|
+
>
|
|
302
|
+
<ListBox
|
|
303
|
+
aria-label={props.label ? String(props.label) : 'Multisuggest'}
|
|
304
|
+
className="max-h-60 overflow-y-auto focus:outline-none"
|
|
305
|
+
renderEmptyState={() => (
|
|
306
|
+
<div className="px-3 py-2 text-neutral-500 text-sm">
|
|
307
|
+
{props.isLoading ? 'Loading...' : query ? 'No matching suggestions' : 'No options available'}
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
>
|
|
311
|
+
{(item) => {
|
|
312
|
+
const option = item as T & { index: number; id: string; isCustom: boolean }
|
|
313
|
+
|
|
314
|
+
if (props.renderOption) {
|
|
315
|
+
return (
|
|
316
|
+
<ListBoxItem id={option.id} textValue={option.label} className="outline-none">
|
|
317
|
+
{props.renderOption({ option, isCustom: option.isCustom, index: option.index })}
|
|
318
|
+
</ListBoxItem>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<FormFieldComponents.DropdownItem
|
|
324
|
+
id={option.id}
|
|
325
|
+
textValue={option.label}
|
|
326
|
+
label={option.label}
|
|
327
|
+
secondary={option.secondary}
|
|
328
|
+
showCheckIcon={false}
|
|
329
|
+
customValuePrefix={option.isCustom ? 'Add' : undefined}
|
|
330
|
+
/>
|
|
331
|
+
)
|
|
332
|
+
}}
|
|
333
|
+
</ListBox>
|
|
334
|
+
</div>
|
|
335
|
+
</AnimatedPopover>
|
|
336
|
+
</AriaComboBox>
|
|
337
|
+
|
|
338
|
+
<FormFieldComponents.FormFieldHelpText
|
|
339
|
+
id={helpTextId}
|
|
340
|
+
isInvalid={isInvalid}
|
|
341
|
+
validationMessage={validationMessage}
|
|
342
|
+
hasWarning={hasWarning}
|
|
343
|
+
warningMessage={props.warningMessage}
|
|
344
|
+
helpText={props.helpText}
|
|
345
|
+
/>
|
|
346
|
+
</div>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
function defaultFilterFunction<T extends MultisuggestOption>(options: readonly T[], inputValue: string): T[] {
|
|
353
|
+
if (!inputValue) return [...options]
|
|
354
|
+
return matchSorter(options, inputValue, {
|
|
355
|
+
keys: ['label', 'secondary']
|
|
356
|
+
})
|
|
357
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
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 NumberFieldProps = {
|
|
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
|
|
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
|
+
step?: number | null
|
|
36
|
+
formatOptions?: Intl.NumberFormatOptions
|
|
37
|
+
autoComplete?: string | null
|
|
38
|
+
autoFocus?: boolean
|
|
39
|
+
shape?: 'rectangle' | 'oval'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function NumberField(props: NumberFieldProps) {
|
|
43
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
44
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
45
|
+
|
|
46
|
+
const helpTextId = React.useId()
|
|
47
|
+
const inputId = React.useId()
|
|
48
|
+
|
|
49
|
+
const numberInput = useNumberInput({
|
|
50
|
+
value: props.value ?? undefined,
|
|
51
|
+
defaultValue: props.defaultValue ?? undefined,
|
|
52
|
+
onChange: props.onChange,
|
|
53
|
+
minValue: props.min ?? undefined,
|
|
54
|
+
maxValue: props.max ?? undefined,
|
|
55
|
+
step: props.step ?? undefined,
|
|
56
|
+
formatOptions: props.formatOptions,
|
|
57
|
+
locale: 'en-US',
|
|
58
|
+
isDisabled: props.isDisabled,
|
|
59
|
+
isReadOnly: props.isReadonly ?? false
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
HeadlessForm.useReset(inputRef, numberInput.numberValue, numberInput.setNumberValue)
|
|
63
|
+
|
|
64
|
+
// Form validation
|
|
65
|
+
const {
|
|
66
|
+
validationMessage,
|
|
67
|
+
isInvalid: validationInvalid,
|
|
68
|
+
commitValidation
|
|
69
|
+
} = HeadlessForm.useValidation(
|
|
70
|
+
{
|
|
71
|
+
value: numberInput.numberValue as number,
|
|
72
|
+
validate: props.validate,
|
|
73
|
+
name: props.name,
|
|
74
|
+
isRequired: props.isRequired,
|
|
75
|
+
form: props.form,
|
|
76
|
+
focus() {
|
|
77
|
+
inputRef.current?.focus()
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
hiddenInputRef
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
84
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
85
|
+
|
|
86
|
+
const handleBlur = () => {
|
|
87
|
+
numberInput.commit(inputRef.current?.value)
|
|
88
|
+
commitValidation()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="relative">
|
|
93
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
|
|
94
|
+
|
|
95
|
+
{/* Hidden input for form submission */}
|
|
96
|
+
{props.isDisabled ? null : (
|
|
97
|
+
<HeadlessForm.HiddenInput
|
|
98
|
+
ref={hiddenInputRef}
|
|
99
|
+
form={props.form}
|
|
100
|
+
name={props.name}
|
|
101
|
+
value={numberInput.numberValue ?? ''}
|
|
102
|
+
type="number"
|
|
103
|
+
min={numberInput.minValue}
|
|
104
|
+
max={numberInput.maxValue}
|
|
105
|
+
/>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<FormFieldComponents.FieldGroup
|
|
109
|
+
isDisabled={props.isDisabled}
|
|
110
|
+
isReadonly={props.isReadonly}
|
|
111
|
+
isInvalid={isInvalid}
|
|
112
|
+
hasWarning={hasWarning}
|
|
113
|
+
shape={props.shape}
|
|
114
|
+
className="flex"
|
|
115
|
+
>
|
|
116
|
+
<FormFieldComponents.FieldInput
|
|
117
|
+
ref={inputRef}
|
|
118
|
+
id={inputId}
|
|
119
|
+
type="text"
|
|
120
|
+
value={numberInput.inputValue}
|
|
121
|
+
onChange={(e) => numberInput.setInputValue(e.currentTarget.value)}
|
|
122
|
+
onBlur={handleBlur}
|
|
123
|
+
placeholder={props.placeholder}
|
|
124
|
+
disabled={props.isDisabled}
|
|
125
|
+
readOnly={props.isReadonly}
|
|
126
|
+
autoComplete={props.autoComplete ?? undefined}
|
|
127
|
+
autoFocus={props.autoFocus}
|
|
128
|
+
aria-invalid={isInvalid || undefined}
|
|
129
|
+
aria-describedby={helpTextId}
|
|
130
|
+
/>
|
|
131
|
+
</FormFieldComponents.FieldGroup>
|
|
132
|
+
|
|
133
|
+
<FormFieldComponents.FormFieldHelpText
|
|
134
|
+
id={helpTextId}
|
|
135
|
+
isInvalid={isInvalid}
|
|
136
|
+
validationMessage={validationMessage}
|
|
137
|
+
hasWarning={hasWarning}
|
|
138
|
+
warningMessage={props.warningMessage}
|
|
139
|
+
helpText={props.helpText}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|