@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,265 @@
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 { FormFieldComponents } from './helpers/form-field'
7
+ import { HeadlessFileInput } from './headless-file-input'
8
+ import { Icon } from './icon'
9
+
10
+ export type ImageInputProps = {
11
+ // Form identification
12
+ name?: string
13
+ form?: string
14
+
15
+ // Display elements
16
+ label?: React.ReactNode
17
+ cornerHint?: React.ReactNode
18
+ helpText?: React.ReactNode
19
+ warningMessage?: React.ReactNode
20
+ emptyState?: React.ReactNode
21
+ renderPreview?: (props: ImageInputPreviewProps) => React.ReactNode
22
+
23
+ // Validation and state
24
+ isRequired?: boolean
25
+ isDisabled?: boolean
26
+ isInvalid?: boolean
27
+ validate?: ValidationFunction<string | File | null>
28
+ errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
29
+
30
+ // Value management (File object or string URL, held in memory until form submit)
31
+ value?: string | File | null
32
+ defaultValue?: string | File | null
33
+ onChange?: (value: string | File | null) => void
34
+
35
+ // Image specific
36
+ accept?: string
37
+ maxSize?: number // in bytes
38
+ }
39
+
40
+ export interface ImageInputPreviewProps {
41
+ fileUrl: string
42
+ isDisabled: boolean
43
+ onChange(value: string | null | File): unknown
44
+ inputId: string
45
+ name: string | undefined
46
+ accept: string | undefined
47
+ inputRef: React.Ref<HTMLInputElement>
48
+ }
49
+
50
+ export function ImageInput(props: ImageInputProps) {
51
+ const inputRef = React.useRef<HTMLInputElement>(null)
52
+ const helpTextId = React.useId()
53
+ const inputId = React.useId()
54
+
55
+ const exceededMaxSizeFn = React.useCallback(
56
+ (value: string | File | null) => {
57
+ if (value && props.maxSize && value instanceof File) {
58
+ return checkFileMaxSize(value, props.maxSize)
59
+ }
60
+ return null
61
+ },
62
+ [props.maxSize]
63
+ )
64
+
65
+ const formatNotSupportedFn = React.useCallback(
66
+ (value: string | File | null) => {
67
+ if (value instanceof File) {
68
+ return checkFileType(value, props.accept ?? 'image/*')
69
+ }
70
+ return null
71
+ },
72
+ [props.accept]
73
+ )
74
+
75
+ // Controlled state management
76
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? null, (value) => {
77
+ if (props.onChange) {
78
+ props.onChange(value)
79
+ }
80
+ })
81
+
82
+ const exceededMaxSize = React.useMemo(() => exceededMaxSizeFn(value), [value, exceededMaxSizeFn])
83
+ const formatNotSupported = React.useMemo(() => formatNotSupportedFn(value), [value, formatNotSupportedFn])
84
+
85
+ // Form validation
86
+ const {
87
+ validationMessage,
88
+ isInvalid: validationInvalid,
89
+ commitValidation
90
+ } = HeadlessForm.useValidation(
91
+ {
92
+ validate: props.validate,
93
+ value,
94
+ name: props.name,
95
+ isRequired: props.isRequired,
96
+ form: props.form,
97
+ focus() {
98
+ inputRef.current?.focus()
99
+ }
100
+ },
101
+ inputRef
102
+ )
103
+
104
+ const isInvalid = props.isInvalid || validationInvalid || Boolean(exceededMaxSize || formatNotSupported)
105
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
106
+ const errorMessage = exceededMaxSize || formatNotSupported || validationMessage
107
+
108
+ const ImagePreview = props.renderPreview ?? DefaultImageInputPreview
109
+
110
+ const accept = props.accept ?? 'image/*'
111
+
112
+ const emptyState = props.emptyState ?? (
113
+ <div className="px-6 py-12">
114
+ <Icon name="photo" className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
115
+ <p className="text-sm text-neutral-600 font-medium">Click to upload or drag and drop</p>
116
+ <p className="text-xs text-neutral-500 mt-1">{accept}</p>
117
+ {props.maxSize ? <p className="text-xs text-neutral-500 mt-1">Max size: {formatFileSize(props.maxSize)}</p> : null}
118
+ </div>
119
+ )
120
+
121
+ return (
122
+ <HeadlessFileInput
123
+ value={value}
124
+ onChange={(v) => {
125
+ setValue(v)
126
+ commitValidation()
127
+ }}
128
+ >
129
+ <div className="relative">
130
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
131
+
132
+ <HeadlessFileInput.Preview>
133
+ {(files) => {
134
+ const hasFile = files.length > 0
135
+ return (
136
+ <div
137
+ className={tw(
138
+ 'image-input-border relative block w-full rounded-md overflow-hidden text-center focus:outline-none focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2',
139
+ 'border-2 border-neutral-200 hover:border-neutral-300 min-h-56',
140
+ !hasFile && 'border-dashed',
141
+ props.isDisabled && 'border-neutral-100 cursor-not-allowed',
142
+ hasWarning &&
143
+ 'border-notice-300 hover:border-notice-400 text-notice-900 placeholder-notice-300 focus-within:border-notice-500 focus-within:ring-notice-500',
144
+ isInvalid &&
145
+ 'border-negative-300 hover:border-negative-400 text-negative-900 placeholder-negative-300 focus-within:border-negative-500 focus-within:ring-negative-500'
146
+ )}
147
+ >
148
+ {hasFile ? (
149
+ ImagePreview({
150
+ isDisabled: !!props.isDisabled,
151
+ fileUrl: files[0]!.fileUrl,
152
+ onChange: (v) => {
153
+ setValue(v)
154
+ commitValidation()
155
+ },
156
+ inputId,
157
+ inputRef,
158
+ name: props.name,
159
+ accept
160
+ })
161
+ ) : (
162
+ <HeadlessFileInput.DropZone
163
+ inputRef={inputRef}
164
+ accept={accept}
165
+ name={props.name}
166
+ inputId={inputId}
167
+ disabled={props.isDisabled}
168
+ className="size-full"
169
+ >
170
+ {({ dragOver }) => (
171
+ <div className="relative pointer-events-none">
172
+ {emptyState}
173
+ {dragOver ? (
174
+ <div className="absolute inset-0">
175
+ <DragOverContent />
176
+ </div>
177
+ ) : null}
178
+ </div>
179
+ )}
180
+ </HeadlessFileInput.DropZone>
181
+ )}
182
+ </div>
183
+ )
184
+ }}
185
+ </HeadlessFileInput.Preview>
186
+
187
+ <FormFieldComponents.FormFieldHelpText
188
+ id={helpTextId}
189
+ isInvalid={isInvalid}
190
+ validationMessage={errorMessage}
191
+ hasWarning={hasWarning}
192
+ warningMessage={props.warningMessage}
193
+ helpText={props.helpText}
194
+ />
195
+ </div>
196
+ </HeadlessFileInput>
197
+ )
198
+ }
199
+
200
+ function DragOverContent() {
201
+ return (
202
+ <div
203
+ className="absolute inset-0 p-12 rounded flex items-center justify-center"
204
+ style={{
205
+ background: `repeating-linear-gradient(45deg, #e5e7eb, #e5e7eb 20px, #d1d5db 20px, #d1d5db 50px)`
206
+ }}
207
+ >
208
+ <div>
209
+ <Icon name="cloud-arrow-up" className="h-12 w-12 mx-auto text-gray-400" />
210
+ <span className="mt-2 block text-sm font-semibold text-gray-900">Upload an Image</span>
211
+ </div>
212
+ </div>
213
+ )
214
+ }
215
+
216
+ export function DefaultImageInputPreview({
217
+ name,
218
+ accept,
219
+ fileUrl,
220
+ inputRef,
221
+ isDisabled,
222
+ onChange,
223
+ inputId
224
+ }: ImageInputPreviewProps) {
225
+ return (
226
+ <>
227
+ <HeadlessFileInput.DropZone
228
+ inputRef={inputRef}
229
+ accept={accept ?? 'image/*'}
230
+ name={name}
231
+ inputId={inputId}
232
+ disabled={isDisabled}
233
+ >
234
+ {({ dragOver }) => (
235
+ <div className="min-h-56 pointer-events-none">
236
+ {dragOver ? (
237
+ <div className="absolute inset-0">
238
+ <DragOverContent />
239
+ </div>
240
+ ) : null}
241
+ <div className="relative">
242
+ <div
243
+ aria-hidden="true"
244
+ className="absolute inset-x-0 top-0 rounded-t-md h-20 bg-gradient-to-b from-white opacity-75 mix-blend-lighten"
245
+ />
246
+ <img src={fileUrl} alt="Preview" className="w-full object-cover rounded-md" />
247
+ </div>
248
+ </div>
249
+ )}
250
+ </HeadlessFileInput.DropZone>
251
+ <div className="absolute top-2 right-2">
252
+ {!isDisabled ? (
253
+ <button
254
+ type="button"
255
+ aria-label="Remove"
256
+ onClick={() => onChange(null)}
257
+ className="p-1 h-5 w-5 flex items-center justify-center bg-gray-800/60 hover:bg-gray-500/60 rounded-full transition-colors"
258
+ >
259
+ <Icon name="x-mark" className="h-4 w-4 text-gray-200" />
260
+ </button>
261
+ ) : null}
262
+ </div>
263
+ </>
264
+ )
265
+ }
@@ -0,0 +1,46 @@
1
+ import * as React from 'react'
2
+ import { tw } from '../utils/tw'
3
+
4
+ export type ImageFit = 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
5
+ export type ImageShape = 'rounded' | 'rectangle' | 'circle'
6
+
7
+ export type ImgProps = {
8
+ src: string
9
+ alt?: string
10
+ className?: string
11
+ fit?: ImageFit
12
+ shape?: ImageShape
13
+ style?: React.CSSProperties
14
+ onError?: React.ReactEventHandler<HTMLImageElement>
15
+ }
16
+
17
+ const fitClasses: Record<ImageFit, string> = {
18
+ cover: 'object-cover object-center',
19
+ contain: 'object-contain',
20
+ fill: 'object-fill',
21
+ none: 'object-none',
22
+ 'scale-down': 'object-scale-down'
23
+ }
24
+
25
+ const shapeClasses: Record<ImageShape, string> = {
26
+ rounded: 'rounded',
27
+ rectangle: 'rounded-none',
28
+ circle: 'rounded-full'
29
+ }
30
+
31
+ export const Img = React.forwardRef<HTMLImageElement, ImgProps>(
32
+ ({ src, alt, className, fit = 'cover', shape = 'rounded', style, onError }, ref) => {
33
+ return (
34
+ <img
35
+ ref={ref}
36
+ src={src}
37
+ alt={alt}
38
+ className={tw('w-full h-full', fitClasses[fit], shapeClasses[shape], className)}
39
+ style={style}
40
+ onError={onError}
41
+ />
42
+ )
43
+ }
44
+ )
45
+
46
+ Img.displayName = 'Img'
@@ -0,0 +1,54 @@
1
+ import * as React from 'react'
2
+ import { tw } from '../utils/tw'
3
+ import { Icon } from './icon'
4
+
5
+ export type InlineAlertProps = {
6
+ children?: React.ReactNode
7
+ variant: 'info' | 'positive' | 'notice' | 'negative' | 'neutral'
8
+ icon?: React.ReactNode
9
+ className?: string
10
+ title?: React.ReactNode
11
+ description?: React.ReactNode
12
+ actions?: React.ReactNode
13
+ }
14
+
15
+ const variantClasses = {
16
+ info: 'bg-info-500/10 text-info-500 border-info-500/20',
17
+ positive: 'bg-positive-50 text-positive-800 border-positive-200',
18
+ notice: 'bg-notice-50 text-notice-800 border-notice-200',
19
+ negative: 'bg-negative-50 text-negative-800 border-negative-200',
20
+ neutral: 'bg-neutral-100 text-neutral-700 border-neutral-200'
21
+ }
22
+
23
+ const defaultIconNames = {
24
+ info: 'information-circle',
25
+ positive: 'check',
26
+ notice: 'exclamation-triangle',
27
+ negative: 'x-mark',
28
+ neutral: 'information-circle'
29
+ } as const
30
+
31
+ export function InlineAlert({ children, actions, variant, icon, className, title, description }: InlineAlertProps) {
32
+ const displayIcon = icon !== undefined ? icon : <Icon name={defaultIconNames[variant]} className="h-5 w-5" />
33
+
34
+ return (
35
+ <div
36
+ className={tw(
37
+ 'rounded-md border px-4 py-3 flex gap-3 items-start justify-between text-left',
38
+ variantClasses[variant],
39
+ className
40
+ )}
41
+ role="alert"
42
+ >
43
+ <div className="flex gap-3 items-start">
44
+ {displayIcon && <div className="flex-shrink-0">{displayIcon}</div>}
45
+ <div className="flex-1 gap-1">
46
+ {title && <div className={tw('font-semibold text-sm')}>{title}</div>}
47
+ {description && <div className="text-sm">{description}</div>}
48
+ {children && <div className="text-sm">{children}</div>}
49
+ </div>
50
+ </div>
51
+ {actions}
52
+ </div>
53
+ )
54
+ }