@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,44 @@
1
+ import { readFile, writeFile } from 'fs/promises'
2
+ import { basename, dirname, resolve } from 'path'
3
+ import { fileURLToPath } from 'url'
4
+
5
+ interface RegistryFile {
6
+ path: string
7
+ type: string
8
+ }
9
+
10
+ interface RegistryEntry {
11
+ name: string
12
+ files: RegistryFile[]
13
+ }
14
+
15
+ interface OutputFile {
16
+ name: string
17
+ content: string
18
+ }
19
+
20
+ interface OutputEntry {
21
+ name: string
22
+ files: OutputFile[]
23
+ }
24
+
25
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
26
+
27
+ const registry: RegistryEntry[] = JSON.parse(await readFile(resolve(packageRoot, 'registry.json'), 'utf-8'))
28
+
29
+ const output: OutputEntry[] = await Promise.all(
30
+ registry.map(async (entry) => {
31
+ const files = await Promise.all(
32
+ entry.files.map(async (file) => ({
33
+ name: `${file.type}/${basename(file.path)}`,
34
+ content: await readFile(resolve(packageRoot, file.path), 'utf-8')
35
+ }))
36
+ )
37
+
38
+ return {
39
+ name: entry.name,
40
+ files
41
+ }
42
+ })
43
+ )
44
+ writeFile('./dist/components.json', JSON.stringify(output))
@@ -0,0 +1,150 @@
1
+ import * as React from 'react'
2
+ import { Dialog, Modal, ModalOverlay, Heading } from 'react-aria-components'
3
+ import { tw } from '../utils/tw'
4
+ import { Icon } from './icon'
5
+ import { Button } from './button'
6
+ import { ButtonGroupContext } from './button-group'
7
+ import { ButtonContext } from './helpers/button-context'
8
+
9
+ export type AlertDialogProps = {
10
+ title: string
11
+ description?: string
12
+ variant?: 'info' | 'positive' | 'neutral' | 'negative' | 'notice'
13
+ icon?: React.ReactNode
14
+ children?: React.ReactNode | ((props: { close(): void }) => React.ReactNode)
15
+ isOpen?: boolean
16
+ onOpenChange?: (isOpen: boolean) => void
17
+ isDismissable?: boolean
18
+ className?: string
19
+ }
20
+
21
+ const variantStyles = {
22
+ info: {
23
+ container: 'border-info-500/20',
24
+ icon: 'text-info-500',
25
+ iconBg: 'bg-info-500/10',
26
+ header: 'text-info-500'
27
+ },
28
+ positive: {
29
+ container: 'border-positive-200',
30
+ icon: 'text-positive-600',
31
+ iconBg: 'bg-positive-100',
32
+ header: 'text-positive-800'
33
+ },
34
+ notice: {
35
+ container: 'border-notice-200',
36
+ icon: 'text-notice-600',
37
+ iconBg: 'bg-notice-100',
38
+ header: 'text-notice-800'
39
+ },
40
+ negative: {
41
+ container: 'border-negative-200',
42
+ icon: 'text-negative-600',
43
+ iconBg: 'bg-negative-100',
44
+ header: 'text-negative-800'
45
+ },
46
+ neutral: {
47
+ container: 'border-neutral-200',
48
+ icon: 'text-neutral-600',
49
+ iconBg: 'bg-neutral-100',
50
+ header: 'text-neutral-700'
51
+ }
52
+ }
53
+
54
+ const defaultIconNames = {
55
+ info: 'information-circle',
56
+ positive: 'check',
57
+ neutral: 'information-circle',
58
+ negative: 'x-mark',
59
+ notice: 'exclamation-triangle'
60
+ } as const
61
+
62
+ interface AlertDialogContextValue {
63
+ close(): void
64
+ }
65
+
66
+ const AlertDialogContext = React.createContext<AlertDialogContextValue>({ close: () => undefined })
67
+
68
+ function AlertDialog_({
69
+ description,
70
+ title,
71
+ variant = 'info',
72
+ icon,
73
+ isOpen,
74
+ onOpenChange,
75
+ isDismissable = true,
76
+ className,
77
+ children
78
+ }: AlertDialogProps) {
79
+ const displayIcon = icon !== undefined ? icon : <Icon name={defaultIconNames[variant]} className="h-8 w-8" />
80
+ const styles = variantStyles[variant]
81
+
82
+ return (
83
+ <ModalOverlay
84
+ isOpen={isOpen}
85
+ onOpenChange={onOpenChange}
86
+ isDismissable={isDismissable}
87
+ isKeyboardDismissDisabled={!isDismissable}
88
+ className={tw(
89
+ 'fixed inset-0 z-[4000] flex items-center justify-center bg-black/70 backdrop-blur-lg',
90
+ 'transition-opacity duration-200 ease-out',
91
+ 'data-[entering]:opacity-0',
92
+ 'data-[exiting]:opacity-0'
93
+ )}
94
+ >
95
+ <Modal
96
+ className={tw(
97
+ 'w-[calc(100%-2rem)] max-w-sm',
98
+ 'transition-all duration-200 ease-out',
99
+ 'data-[entering]:scale-95 data-[entering]:opacity-0 data-[entering]:translate-y-2',
100
+ 'data-[exiting]:scale-95 data-[exiting]:opacity-0 data-[exiting]:translate-y-2'
101
+ )}
102
+ >
103
+ <Dialog className={tw('outline-none bg-white rounded-lg shadow-2xl w-full', className)} role="alertdialog">
104
+ {({ close }) => (
105
+ <AlertDialogContext.Provider value={{ close }}>
106
+ <ButtonGroupContext.Provider
107
+ value={{ orientation: 'horizontal', alignment: 'center', fullWidth: true, className: 'flex-nowrap' }}
108
+ >
109
+ <ButtonContext.Provider value={{ shape: 'rectangle', variant: 'soft', fullWidth: true }}>
110
+ <div className={tw('border rounded-lg p-6', styles.container)}>
111
+ <div className="flex flex-col items-center text-center">
112
+ {displayIcon && (
113
+ <div className={tw('w-12 h-12 rounded-xl flex items-center justify-center mb-4', styles.iconBg)}>
114
+ <div className={tw(styles.icon)}>{displayIcon}</div>
115
+ </div>
116
+ )}
117
+ <Heading
118
+ slot="title"
119
+ className={tw('text-xl font-semibold', description ? 'mb-2' : 'mb-5', styles.header)}
120
+ >
121
+ {title}
122
+ </Heading>
123
+ {description && <div className="text-sm text-neutral-600 mb-5">{description}</div>}
124
+ {typeof children === 'function' ? children({ close }) : children}
125
+ </div>
126
+ </div>
127
+ </ButtonContext.Provider>
128
+ </ButtonGroupContext.Provider>
129
+ </AlertDialogContext.Provider>
130
+ )}
131
+ </Dialog>
132
+ </Modal>
133
+ </ModalOverlay>
134
+ )
135
+ }
136
+
137
+ AlertDialog_.displayName = 'AlertDialog'
138
+
139
+ function CancelButton({ children = 'Cancel' }: { children?: React.ReactNode }) {
140
+ const context = React.useContext(AlertDialogContext)
141
+ return (
142
+ <Button color="neutral" onClick={context.close} variant="soft" shape="rectangle">
143
+ {children}
144
+ </Button>
145
+ )
146
+ }
147
+
148
+ export const AlertDialog = Object.assign(AlertDialog_, {
149
+ CancelButton
150
+ })
@@ -0,0 +1,288 @@
1
+ import * as React from 'react'
2
+ import { ComboBox as AriaComboBox, 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 { getColor, stringToNumber } from '../utils/colors'
8
+ import { FormFieldComponents } from './helpers/form-field'
9
+ import { matchSorter } from 'match-sorter'
10
+
11
+ // ── Types ──────────────────────────────────────────────────────────────────────
12
+
13
+ export type AutocompleteOption = {
14
+ value: string | number
15
+ label: string
16
+ secondary?: string
17
+ }
18
+
19
+ export type AutocompleteProps<T extends AutocompleteOption = AutocompleteOption> = {
20
+ // Form identification
21
+ name?: string
22
+ form?: string
23
+
24
+ // Display elements
25
+ label?: React.ReactNode
26
+ cornerHint?: React.ReactNode
27
+ helpText?: React.ReactNode
28
+ placeholder?: string
29
+ warningMessage?: React.ReactNode
30
+
31
+ // Validation and state
32
+ isRequired?: boolean
33
+ isDisabled?: boolean
34
+ isReadonly?: boolean
35
+ isInvalid?: boolean
36
+ validate?: ValidationFunction<T['value'] | null>
37
+ errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
38
+
39
+ // Value management
40
+ value?: T['value'] | null
41
+ defaultValue?: T['value'] | null
42
+ onChange?: (value: T['value'] | null) => void
43
+
44
+ // Options
45
+ options: T[]
46
+ renderOption?: (props: { option: T; colored: boolean; index: number }) => React.ReactNode
47
+
48
+ colored?: boolean
49
+
50
+ // Filtering
51
+ filterFunction?: ((items: readonly T[], value: string) => T[]) | (keyof T)[]
52
+
53
+ // Loading
54
+ isLoading?: boolean
55
+
56
+ // Input
57
+ inputAutocomplete?: React.ComponentProps<'input'>['autoComplete']
58
+ id?: string
59
+ shape?: 'rectangle' | 'oval'
60
+ }
61
+
62
+ // ── Component ──────────────────────────────────────────────────────────────────
63
+
64
+ export function Autocomplete<T extends AutocompleteOption = AutocompleteOption>(props: AutocompleteProps<T>) {
65
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
66
+ const inputRef = React.useRef<HTMLInputElement>(null)
67
+ const helpTextId = React.useId()
68
+ const labelId = React.useId()
69
+
70
+ // Controlled state for the selected value
71
+ const [selectedValue, setSelectedValue] = HeadlessForm.useControlledState(
72
+ props.value,
73
+ props.defaultValue ?? null,
74
+ props.onChange
75
+ )
76
+
77
+ // Form validation
78
+ const {
79
+ validationMessage,
80
+ isInvalid: validationInvalid,
81
+ commitValidation
82
+ } = HeadlessForm.useValidation(
83
+ {
84
+ validate: props.validate,
85
+ value: selectedValue,
86
+ name: props.name,
87
+ isRequired: props.isRequired,
88
+ form: props.form,
89
+ focus() {
90
+ inputRef.current?.focus()
91
+ }
92
+ },
93
+ hiddenInputRef
94
+ )
95
+
96
+ const isInvalid = props.isInvalid || validationInvalid
97
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
98
+
99
+ // Find the selected option
100
+ const selectedOption = React.useMemo(() => {
101
+ return props.options.find((option) => option.value === selectedValue) ?? null
102
+ }, [props.options, selectedValue])
103
+
104
+ // Input value state (what's displayed in the input)
105
+ const [inputValue, setInputValue] = React.useState(() => {
106
+ const initial = props.value ?? props.defaultValue ?? null
107
+ if (initial != null) {
108
+ const option = props.options.find((o) => o.value === initial)
109
+ return option?.label ?? ''
110
+ }
111
+ return ''
112
+ })
113
+
114
+ // Sync inputValue when external props.value changes
115
+ const lastPropsValue = React.useRef(props.value)
116
+ React.useEffect(() => {
117
+ if (props.value !== lastPropsValue.current) {
118
+ lastPropsValue.current = props.value
119
+ if (props.value != null) {
120
+ const option = props.options.find((o) => o.value === props.value)
121
+ setInputValue(option?.label ?? '')
122
+ } else {
123
+ setInputValue('')
124
+ }
125
+ }
126
+ }, [props.value, props.options])
127
+
128
+ // Filter options based on input value
129
+ const filteredOptions = React.useMemo(() => {
130
+ if (props.isLoading) return []
131
+
132
+ // When input matches the selected option's label, show all options (not filtered)
133
+ const isShowingSelectedLabel = selectedOption && inputValue === selectedOption.label
134
+ const searchTerm = isShowingSelectedLabel ? '' : inputValue
135
+
136
+ const filterFn = props.filterFunction
137
+ let filtered: T[]
138
+
139
+ if (typeof filterFn === 'function') {
140
+ filtered = filterFn(props.options, searchTerm).slice(0, 50)
141
+ } else if (Array.isArray(filterFn)) {
142
+ filtered = matchSorter(props.options, searchTerm, {
143
+ keys: filterFn as string[]
144
+ }).slice(0, 50)
145
+ } else {
146
+ filtered = defaultFilterFunction(props.options, searchTerm).slice(0, 50)
147
+ }
148
+
149
+ // Ensure selected option is always included
150
+ if (selectedOption && !filtered.some((o) => o.value === selectedOption.value)) {
151
+ filtered = [selectedOption, ...filtered]
152
+ }
153
+
154
+ return filtered
155
+ }, [props.options, inputValue, props.filterFunction, props.isLoading, selectedOption])
156
+
157
+ // Items for React Aria (with id for key management)
158
+ const items = React.useMemo(
159
+ () => filteredOptions.map((option, index) => ({ ...option, index, id: String(option.value) })),
160
+ [filteredOptions]
161
+ )
162
+
163
+ // Selected key for React Aria
164
+ const selectedKey = selectedValue != null ? String(selectedValue) : null
165
+
166
+ const handleSelectionChange = (key: Key | null) => {
167
+ if (key === null) {
168
+ setSelectedValue(null)
169
+ setInputValue('')
170
+ } else {
171
+ const option = props.options.find((o) => String(o.value) === String(key))
172
+ setSelectedValue(option?.value ?? null)
173
+ setInputValue(option?.label ?? '')
174
+ }
175
+ commitValidation()
176
+ }
177
+
178
+ const handleInputChange = (value: string) => {
179
+ setInputValue(value)
180
+ // Clear selection when input doesn't match any option label
181
+ const matchingOption = props.options.find((o) => o.label === value)
182
+ if (!matchingOption) {
183
+ setSelectedValue(null)
184
+ }
185
+ }
186
+
187
+ return (
188
+ <div className="relative">
189
+ {/* Hidden input for form submission */}
190
+ <HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value={selectedValue ?? ''} form={props.form} />
191
+
192
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
193
+
194
+ <AriaComboBox
195
+ aria-labelledby={props.label ? labelId : undefined}
196
+ aria-label={!props.label ? 'Autocomplete' : undefined}
197
+ selectedKey={selectedKey}
198
+ onSelectionChange={handleSelectionChange}
199
+ inputValue={inputValue}
200
+ onInputChange={handleInputChange}
201
+ items={items}
202
+ isDisabled={props.isDisabled || props.isReadonly}
203
+ isInvalid={isInvalid}
204
+ isRequired={props.isRequired}
205
+ allowsEmptyCollection
206
+ menuTrigger="focus"
207
+ onBlur={() => commitValidation()}
208
+ >
209
+ {/* Input and button wrapper */}
210
+ <FormFieldComponents.FieldGroup
211
+ isDisabled={props.isDisabled}
212
+ isReadonly={props.isReadonly}
213
+ isInvalid={isInvalid}
214
+ hasWarning={hasWarning}
215
+ shape={props.shape}
216
+ className="flex w-full items-center cursor-default"
217
+ >
218
+ {props.colored && selectedOption ? (
219
+ <div
220
+ className="absolute left-0 top-0 w-[3px] h-full"
221
+ style={{ backgroundColor: getColor(stringToNumber(String(selectedOption.value))) }}
222
+ />
223
+ ) : null}
224
+ <FormFieldComponents.FieldInput
225
+ ref={inputRef}
226
+ autoComplete={props.inputAutocomplete ?? 'off'}
227
+ className="pr-1"
228
+ placeholder={props.placeholder ?? undefined}
229
+ aria-describedby={helpTextId}
230
+ id={props.id}
231
+ />
232
+ <FormFieldComponents.ChevronButton />
233
+ </FormFieldComponents.FieldGroup>
234
+
235
+ {/* Dropdown */}
236
+ <AnimatedPopover offset={4} className="w-(--trigger-width)">
237
+ <FormFieldComponents.DropdownListBox
238
+ renderEmptyState={() => (
239
+ <div className="px-3 py-2 text-neutral-500 text-sm">
240
+ {props.isLoading ? 'Loading...' : inputValue ? 'No results found' : 'No options available'}
241
+ </div>
242
+ )}
243
+ >
244
+ {(item) => {
245
+ const option = item as T & { index: number; id: string }
246
+ if (props.renderOption) {
247
+ return (
248
+ <ListBoxItem id={option.id} textValue={option.label} className="outline-none">
249
+ {props.renderOption({ option, colored: props.colored ?? false, index: option.index })}
250
+ </ListBoxItem>
251
+ )
252
+ }
253
+
254
+ const color = props.colored ? getColor(stringToNumber(String(option.value))) : null
255
+ return (
256
+ <FormFieldComponents.DropdownItem
257
+ id={option.id}
258
+ textValue={option.label}
259
+ label={option.label}
260
+ secondary={option.secondary}
261
+ color={color}
262
+ />
263
+ )
264
+ }}
265
+ </FormFieldComponents.DropdownListBox>
266
+ </AnimatedPopover>
267
+ </AriaComboBox>
268
+
269
+ <FormFieldComponents.FormFieldHelpText
270
+ id={helpTextId}
271
+ isInvalid={isInvalid}
272
+ validationMessage={validationMessage}
273
+ hasWarning={hasWarning}
274
+ warningMessage={props.warningMessage}
275
+ helpText={props.helpText}
276
+ />
277
+ </div>
278
+ )
279
+ }
280
+
281
+ // ── Helpers ──────────────────────────────────────────────────────────────────────
282
+
283
+ function defaultFilterFunction<T extends AutocompleteOption>(options: readonly T[], inputValue: string): T[] {
284
+ if (!inputValue) return [...options]
285
+ return matchSorter(options, inputValue, {
286
+ keys: ['label', 'secondary']
287
+ })
288
+ }