@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,389 @@
1
+ import * as React from 'react'
2
+ import { HeadlessForm } from '@maestro-js/form'
3
+ import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
4
+ import { tw } from '../utils/tw'
5
+ import { formatFileSize, checkFileType, checkFileMaxSize } from '../utils/file-input'
6
+ import { useStableAccessor } from '../utils/use-stable-accessor'
7
+ import { FormFieldComponents } from './helpers/form-field'
8
+ import { HeadlessFileInput } from './headless-file-input'
9
+ import { Icon } from './icon'
10
+
11
+ export type MultiImageInputProps = {
12
+ // Form identification
13
+ name?: string
14
+ form?: string
15
+
16
+ // Display elements
17
+ label?: React.ReactNode
18
+ cornerHint?: React.ReactNode
19
+ helpText?: React.ReactNode
20
+ warningMessage?: React.ReactNode
21
+ placeholder?: string
22
+
23
+ // Validation and state
24
+ isRequired?: boolean
25
+ isDisabled?: boolean
26
+ isInvalid?: boolean
27
+ validate?: ValidationFunction<(string | File)[]>
28
+ errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
29
+
30
+ // Value management (File objects array or strings, held in memory until form submit)
31
+ value?: (string | File)[]
32
+ defaultValue?: (string | File)[]
33
+ onChange?: (value: (string | File)[]) => void
34
+
35
+ // Image specific
36
+ accept?: string
37
+ maxSize?: number // in bytes per image
38
+ maxImages?: number
39
+ aspectRatio?: number // width/height ratio
40
+ columns?: number // Grid columns for display
41
+ allowReordering?: boolean
42
+
43
+ renderPreview?: (props: MultiImageInputPreviewProps) => React.ReactNode
44
+ }
45
+
46
+ export interface MultiImageInputPreviewProps {
47
+ fileUrl: string
48
+ isDisabled: boolean
49
+ index: number
50
+ onChange(value: string | null | File): unknown
51
+ }
52
+
53
+ export function MultiImageInput(props: MultiImageInputProps) {
54
+ const inputRef = React.useRef<HTMLInputElement>(null)
55
+ const helpTextId = React.useId()
56
+ const inputId = React.useId()
57
+ const labelRef = React.useRef<HTMLLabelElement>(null)
58
+
59
+ const exceededMaxSizeFn = React.useCallback(
60
+ (value: (string | File)[]) => {
61
+ for (let i = 0; i < value.length; i++) {
62
+ const v = value[i]
63
+ if (v instanceof File && props.maxSize) {
64
+ const error = checkFileMaxSize(v, props.maxSize)
65
+ if (error) return `Image ${i + 1} exceeded max size of ${formatFileSize(props.maxSize)}`
66
+ }
67
+ }
68
+ return null
69
+ },
70
+ [props.maxSize]
71
+ )
72
+
73
+ const formatNotSupportedFn = React.useCallback(
74
+ (value: (string | File)[]) => {
75
+ const accept = props.accept ?? 'image/*'
76
+ for (const v of value) {
77
+ if (v instanceof File) {
78
+ const error = checkFileType(v, accept)
79
+ if (error) return error
80
+ }
81
+ }
82
+ return null
83
+ },
84
+ [props.accept]
85
+ )
86
+
87
+ // Controlled state management
88
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? [], (value) => {
89
+ if (props.onChange) {
90
+ props.onChange(value)
91
+ }
92
+ })
93
+
94
+ // Form validation
95
+ const {
96
+ validationMessage,
97
+ isInvalid: validationInvalid,
98
+ commitValidation
99
+ } = HeadlessForm.useValidation(
100
+ {
101
+ validate(files) {
102
+ const exceededMaxSize = exceededMaxSizeFn(files)
103
+ const formatNotSupported = formatNotSupportedFn(files)
104
+ const exceededMaxImages = props.maxImages !== undefined && files.length > props.maxImages ? 'Too many images' : null
105
+ const custom = [props.validate ? props.validate(files) : []].flat()
106
+
107
+ const errors = [exceededMaxSize, formatNotSupported, exceededMaxImages, ...custom].filter(Boolean)
108
+ return errors as string[]
109
+ },
110
+ value,
111
+ name: props.name,
112
+ isRequired: props.isRequired,
113
+ form: props.form,
114
+ focus() {
115
+ labelRef.current?.focus()
116
+ }
117
+ },
118
+ inputRef
119
+ )
120
+
121
+ const isInvalid = props.isInvalid || validationInvalid
122
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
123
+ const errorMessage = validationMessage
124
+
125
+ // Stable handlers via useStableAccessor to avoid re-creating on every render
126
+ const getValue = useStableAccessor(value)
127
+ const getSetValue = useStableAccessor(setValue)
128
+
129
+ const handleReorder = React.useCallback(
130
+ (fromIndex: number, toIndex: number) => {
131
+ const newFiles = [...getValue()]
132
+ const [movedFile] = newFiles.splice(fromIndex, 1)
133
+ newFiles.splice(toIndex, 0, movedFile!)
134
+ getSetValue()(newFiles)
135
+ },
136
+ [getValue, getSetValue]
137
+ )
138
+
139
+ const handleRemove = React.useCallback(
140
+ (index: number) => {
141
+ getSetValue()(getValue().filter((_: string | File, i: number) => i !== index))
142
+ },
143
+ [getValue, getSetValue]
144
+ )
145
+
146
+ const handleReplace = React.useCallback(
147
+ (index: number, file: string | File) => {
148
+ getSetValue()(getValue().map((f: string | File, i: number) => (i === index ? file : f)))
149
+ },
150
+ [getValue, getSetValue]
151
+ )
152
+
153
+ const getCommitValidation = useStableAccessor(commitValidation)
154
+ const handleFileInputChange = React.useCallback(
155
+ (v: (string | File)[]) => {
156
+ getSetValue()(v)
157
+ getCommitValidation()()
158
+ },
159
+ [getSetValue, getCommitValidation]
160
+ )
161
+
162
+ // Stable component reference
163
+ const imagePreview = React.useMemo(() => props.renderPreview ?? DefaultImagePreview, [props.renderPreview])
164
+
165
+ // Stable keys for grid items
166
+ const fileKeyMapRef = React.useRef(new WeakMap<File, string>())
167
+ const fileKeyCounterRef = React.useRef(0)
168
+ const getFileKey = React.useCallback((item: string | File): string => {
169
+ if (typeof item === 'string') return item
170
+ let key = fileKeyMapRef.current.get(item)
171
+ if (!key) {
172
+ key = `file-${fileKeyCounterRef.current++}`
173
+ fileKeyMapRef.current.set(item, key)
174
+ }
175
+ return key
176
+ }, [])
177
+
178
+ // Memoize grid style
179
+ const gridStyle = React.useMemo(
180
+ () => ({ gridTemplateColumns: `repeat(${props.columns ?? 4}, minmax(0, 1fr))` }),
181
+ [props.columns]
182
+ )
183
+
184
+ return (
185
+ <HeadlessFileInput value={value} onChange={handleFileInputChange} multiple>
186
+ <div className="relative">
187
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
188
+
189
+ <HeadlessFileInput.Preview>
190
+ {(files) => {
191
+ const canAddMore = !props.maxImages || files.length < props.maxImages
192
+
193
+ return (
194
+ <>
195
+ {/* Drop zone */}
196
+ {canAddMore && (
197
+ <HeadlessFileInput.DropZone
198
+ accept={props.accept ?? 'image/*'}
199
+ name={props.name}
200
+ inputId={inputId}
201
+ inputRef={inputRef}
202
+ ref={labelRef}
203
+ disabled={props.isDisabled}
204
+ >
205
+ {({ dragOver }) => (
206
+ <div
207
+ className={tw(
208
+ 'relative flex flex-col items-center justify-center rounded-md border-2 border-dashed cursor-pointer transition-all duration-200 mb-2 p-8',
209
+ 'focus:outline-none focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2 pointer-events-none',
210
+ 'border-neutral-200 hover:border-neutral-300',
211
+ props.isDisabled && 'border-neutral-100 cursor-not-allowed',
212
+ hasWarning &&
213
+ 'border-notice-300 hover:border-notice-400 text-notice-900 placeholder-notice-300 focus-within:border-notice-500 focus-within:ring-notice-500',
214
+ isInvalid &&
215
+ 'border-negative-300 hover:border-negative-400 text-negative-900 placeholder-negative-300 focus-within:border-negative-500 focus-within:ring-negative-500'
216
+ )}
217
+ >
218
+ {dragOver ? (
219
+ <div className="min-h-20">
220
+ <DragOverContent />
221
+ </div>
222
+ ) : (
223
+ <div className="text-center">
224
+ <Icon name="cloud-arrow-up" className="h-10 w-10 mx-auto mb-3 text-neutral-400" />
225
+ <p className="text-sm text-neutral-600 font-medium">
226
+ {props.placeholder || 'Drop images here or click to browse'}
227
+ </p>
228
+ {props.maxSize && (
229
+ <p className="text-xs text-neutral-500 mt-1">
230
+ Max size: {formatFileSize(props.maxSize)} per image
231
+ </p>
232
+ )}
233
+ {props.maxImages && (
234
+ <p className="text-xs text-neutral-500 mt-1">
235
+ {files.length} / {props.maxImages} images uploaded
236
+ </p>
237
+ )}
238
+ </div>
239
+ )}
240
+ </div>
241
+ )}
242
+ </HeadlessFileInput.DropZone>
243
+ )}
244
+
245
+ {/* Image previews grid */}
246
+ {files.length > 0 && (
247
+ <div className={tw('grid gap-3')} style={gridStyle}>
248
+ {files.map((fileItem, index) => (
249
+ <GridItem
250
+ key={getFileKey(value[index]!)}
251
+ fileUrl={fileItem.fileUrl}
252
+ index={index}
253
+ aspectRatio={props.aspectRatio ?? 1}
254
+ isDisabled={props.isDisabled ?? false}
255
+ allowReordering={props.allowReordering ?? false}
256
+ onReorder={handleReorder}
257
+ onRemove={handleRemove}
258
+ onReplace={handleReplace}
259
+ ImagePreview={imagePreview}
260
+ />
261
+ ))}
262
+ </div>
263
+ )}
264
+
265
+ {/* Message when at max capacity */}
266
+ {!canAddMore && (
267
+ <div className="mt-3 text-sm text-neutral-500 text-center">
268
+ Maximum of {props.maxImages} images reached
269
+ </div>
270
+ )}
271
+ </>
272
+ )
273
+ }}
274
+ </HeadlessFileInput.Preview>
275
+
276
+ <FormFieldComponents.FormFieldHelpText
277
+ id={helpTextId}
278
+ isInvalid={isInvalid}
279
+ validationMessage={errorMessage}
280
+ hasWarning={hasWarning}
281
+ warningMessage={props.warningMessage}
282
+ helpText={props.helpText}
283
+ />
284
+ </div>
285
+ </HeadlessFileInput>
286
+ )
287
+ }
288
+
289
+ // ── Memoized sub-components ─────────────────────────────────────────────────
290
+
291
+ const GridItem = React.memo(function GridItem({
292
+ fileUrl,
293
+ index,
294
+ aspectRatio,
295
+ isDisabled,
296
+ allowReordering,
297
+ onReorder,
298
+ onRemove,
299
+ onReplace,
300
+ ImagePreview
301
+ }: {
302
+ fileUrl: string
303
+ index: number
304
+ aspectRatio: number
305
+ isDisabled: boolean
306
+ allowReordering: boolean
307
+ onReorder: (fromIndex: number, toIndex: number) => void
308
+ onRemove: (index: number) => void
309
+ onReplace: (index: number, file: string | File) => void
310
+ ImagePreview: (props: MultiImageInputPreviewProps) => React.ReactNode
311
+ }) {
312
+ const handleDragStart = React.useCallback(
313
+ (e: React.DragEvent) => e.dataTransfer.setData('index', index.toString()),
314
+ [index]
315
+ )
316
+
317
+ const handleDrop = React.useCallback(
318
+ (e: React.DragEvent) => {
319
+ e.preventDefault()
320
+ const fromIndex = parseInt(e.dataTransfer.getData('index'))
321
+ if (fromIndex !== index) onReorder(fromIndex, index)
322
+ },
323
+ [index, onReorder]
324
+ )
325
+
326
+ const handleChange = React.useCallback(
327
+ (v: string | null | File) => {
328
+ if (v === null) onRemove(index)
329
+ else onReplace(index, v)
330
+ },
331
+ [index, onRemove, onReplace]
332
+ )
333
+
334
+ return (
335
+ <div
336
+ className="relative group overflow-hidden rounded-md"
337
+ style={{ aspectRatio }}
338
+ draggable={!isDisabled && allowReordering}
339
+ onDragStart={handleDragStart}
340
+ onDragOver={preventDefaultHandler}
341
+ onDrop={handleDrop}
342
+ >
343
+ <ImagePreview isDisabled={isDisabled} fileUrl={fileUrl} index={index} onChange={handleChange} />
344
+ </div>
345
+ )
346
+ })
347
+
348
+ const DefaultImagePreview = React.memo(function DefaultImagePreview({
349
+ fileUrl,
350
+ isDisabled,
351
+ onChange
352
+ }: MultiImageInputPreviewProps) {
353
+ return (
354
+ <>
355
+ <img src={fileUrl} alt="Preview" className="w-full h-full object-cover rounded-md" />
356
+ {!isDisabled && (
357
+ <button
358
+ type="button"
359
+ aria-label="Remove image"
360
+ onClick={() => onChange(null)}
361
+ className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-600 rounded-full transition-all duration-200 hover:scale-110"
362
+ >
363
+ <Icon name="x-mark" className="h-4 w-4 text-white" />
364
+ </button>
365
+ )}
366
+ </>
367
+ )
368
+ })
369
+
370
+ // ── Static helpers ──────────────────────────────────────────────────────────
371
+
372
+ const DRAG_OVER_STYLE = {
373
+ background: 'repeating-linear-gradient(45deg, #e5e7eb, #e5e7eb 20px, #d1d5db 20px, #d1d5db 50px)'
374
+ } as const
375
+
376
+ function DragOverContent() {
377
+ return (
378
+ <div className="absolute inset-0 p-6 rounded-md flex items-center justify-center text-center" style={DRAG_OVER_STYLE}>
379
+ <div>
380
+ <Icon name="cloud-arrow-up" className="h-12 w-12 mx-auto text-gray-400" />
381
+ <span className="mt-2 block text-sm font-semibold text-gray-900">Drop Image</span>
382
+ </div>
383
+ </div>
384
+ )
385
+ }
386
+
387
+ function preventDefaultHandler(e: React.DragEvent) {
388
+ e.preventDefault()
389
+ }