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