@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,78 @@
1
+ import React from 'react'
2
+ import { Drawer as VaulDrawer } from 'vaul'
3
+ import { tw } from '../utils/tw'
4
+
5
+ export type DrawerProps = {
6
+ children?: React.ReactNode
7
+ title?: React.ReactNode
8
+ isOpen?: boolean
9
+ onOpenChange?: (isOpen: boolean) => void
10
+ size?: 'sm' | 'md' | 'lg'
11
+ className?: string
12
+ isDismissable?: boolean
13
+ position?: 'left' | 'right' | 'top' | 'bottom'
14
+ actions?: React.ReactNode
15
+ contentsContainerClassName?: string
16
+ }
17
+
18
+ export function Drawer({
19
+ children,
20
+ title,
21
+ isOpen = false,
22
+ onOpenChange,
23
+ size = 'md',
24
+ className,
25
+ isDismissable = true,
26
+ position = 'right',
27
+ actions,
28
+ contentsContainerClassName
29
+ }: DrawerProps) {
30
+ return (
31
+ <VaulDrawer.Root open={isOpen} onOpenChange={onOpenChange} direction={position} dismissible={isDismissable}>
32
+ <VaulDrawer.Portal>
33
+ <VaulDrawer.Overlay className="fixed inset-0 z-49 bg-black/70 backdrop-blur-lg" />
34
+ <VaulDrawer.Content
35
+ className={tw(
36
+ 'flex h-auto flex-col text-sm group/drawer-content fixed z-50',
37
+ 'data-[vaul-drawer-direction=bottom]:bottom-2 data-[vaul-drawer-direction=bottom]:left-2 data-[vaul-drawer-direction=bottom]:right-2 data-[vaul-drawer-direction=bottom]:max-h-[80vh]',
38
+ 'data-[vaul-drawer-direction=left]:left-2 data-[vaul-drawer-direction=left]:top-2 data-[vaul-drawer-direction=left]:bottom-2 data-[vaul-drawer-direction=left]:h-[calc(100%-1rem)] data-[vaul-drawer-direction=left]:w-3/4',
39
+ 'data-[vaul-drawer-direction=right]:right-2 data-[vaul-drawer-direction=right]:top-2 data-[vaul-drawer-direction=right]:bottom-2 data-[vaul-drawer-direction=right]:h-[calc(100%-1rem)] data-[vaul-drawer-direction=right]:w-3/4',
40
+ 'data-[vaul-drawer-direction=top]:top-2 data-[vaul-drawer-direction=top]:left-2 data-[vaul-drawer-direction=top]:right-2 data-[vaul-drawer-direction=top]:max-h-[80vh]',
41
+ size === 'sm'
42
+ ? 'data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm'
43
+ : size === 'md'
44
+ ? 'data-[vaul-drawer-direction=left]:sm:max-w-lg data-[vaul-drawer-direction=right]:sm:max-w-lg'
45
+ : 'data-[vaul-drawer-direction=left]:sm:max-w-3xl data-[vaul-drawer-direction=right]:sm:max-w-3xl',
46
+ className
47
+ )}
48
+ style={{ '--initial-transform': 'calc(100% + 8px)' } as React.CSSProperties}
49
+ >
50
+ {isDismissable ? (
51
+ <div
52
+ className={tw(
53
+ 'absolute bg-neutral-200 rounded-full shrink-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:block',
54
+ 'group-data-[vaul-drawer-direction=left]/drawer-content:h-25 group-data-[vaul-drawer-direction=left]/drawer-content:w-1 group-data-[vaul-drawer-direction=left]/drawer-content:right-2 group-data-[vaul-drawer-direction=left]/drawer-content:top-[calc(50%-50px)]',
55
+ 'group-data-[vaul-drawer-direction=right]/drawer-content:h-25 group-data-[vaul-drawer-direction=right]/drawer-content:w-1 group-data-[vaul-drawer-direction=right]/drawer-content:left-2 group-data-[vaul-drawer-direction=right]/drawer-content:top-[calc(50%-50px)]',
56
+ 'group-data-[vaul-drawer-direction=top]/drawer-content:w-25 group-data-[vaul-drawer-direction=top]/drawer-content:h-1 group-data-[vaul-drawer-direction=top]/drawer-content:bottom-2 group-data-[vaul-drawer-direction=top]/drawer-content:left-[calc(50%-50px)]',
57
+ 'group-data-[vaul-drawer-direction=bottom]/drawer-content:w-25 group-data-[vaul-drawer-direction=bottom]/drawer-content:h-1 group-data-[vaul-drawer-direction=bottom]/drawer-content:top-2 group-data-[vaul-drawer-direction=bottom]/drawer-content:left-[calc(50%-50px)]'
58
+ )}
59
+ />
60
+ ) : null}
61
+
62
+ <div className="bg-white size-full pt-6 px-6 pb-6 rounded-md shadow-xl">
63
+ <div className="flex items-start justify-between gap-4 pb-5">
64
+ {title ? (
65
+ <VaulDrawer.Title className="flex-1 text-xl font-semibold text-neutral-900">{title}</VaulDrawer.Title>
66
+ ) : (
67
+ <div />
68
+ )}
69
+ <div className="flex gap-2 items-start">{actions}</div>
70
+ </div>
71
+
72
+ <div className={tw(contentsContainerClassName)}>{children}</div>
73
+ </div>
74
+ </VaulDrawer.Content>
75
+ </VaulDrawer.Portal>
76
+ </VaulDrawer.Root>
77
+ )
78
+ }
@@ -0,0 +1,30 @@
1
+ import * as React from 'react'
2
+ import { Chip, type ChipProps } from './chip'
3
+ import { getColor, stringToNumber } from '../utils/enum-colors'
4
+
5
+ export type EnumChipProps = {
6
+ options: string[] | Readonly<string[]>
7
+ value: string
8
+ enumLabels?: { [key: string]: string }
9
+ } & Omit<ChipProps, 'children'>
10
+
11
+ export function EnumChip({ options, enumLabels, value, style, ...props }: EnumChipProps) {
12
+ const colorIndex = Object.values(options).indexOf(value)
13
+ const colorSeed = colorIndex >= 0 ? colorIndex : stringToNumber(value)
14
+ const color = getColor(colorSeed)
15
+
16
+ const label = value ? (enumLabels ? enumLabels[value] : value) : ''
17
+
18
+ return (
19
+ <Chip
20
+ {...props}
21
+ style={{
22
+ backgroundColor: `${color}19`,
23
+ color: color,
24
+ ...style
25
+ }}
26
+ >
27
+ {label}
28
+ </Chip>
29
+ )
30
+ }
@@ -0,0 +1,245 @@
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, validateFileMaxSize, validateFileType } 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 FileInputProps = {
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
+ renderPreview?: (props: FileInputPreviewProps) => React.ReactNode
21
+
22
+ // Validation and state
23
+ isRequired?: boolean
24
+ isDisabled?: boolean
25
+ isInvalid?: boolean
26
+ validate?: ValidationFunction<string | File | null>
27
+ errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
28
+
29
+ // Value management (File object or string URL, held in memory until form submit)
30
+ value?: string | File | null
31
+ defaultValue?: string | File | null
32
+ onChange?: (value: string | File | null, hasError: boolean) => void
33
+
34
+ // File specific
35
+ accept?: string
36
+ maxSize?: number // in bytes
37
+ }
38
+
39
+ export interface FileInputPreviewProps {
40
+ file: File | null
41
+ fileUrl: string | null
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
+ formatFileSize: (bytes: number) => string
49
+ setDragging: (dragging: boolean) => void
50
+ maxSize?: number
51
+ }
52
+
53
+ export function FileInput(props: FileInputProps) {
54
+ const inputRef = React.useRef<HTMLInputElement>(null)
55
+ const helpTextId = React.useId()
56
+ const inputId = React.useId()
57
+ const [, setDragging] = React.useState(false)
58
+
59
+ // Controlled state management
60
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? null, (value) => {
61
+ if (props.onChange) {
62
+ props.onChange(value, Boolean(validateFileMaxSize(value, props.maxSize) || validateFileType(value, props.accept)))
63
+ }
64
+ })
65
+
66
+ const exceededMaxSize = React.useMemo(() => validateFileMaxSize(value, props.maxSize), [value, props.maxSize])
67
+ const formatNotSupported = React.useMemo(() => validateFileType(value, props.accept), [value, props.accept])
68
+
69
+ // Form validation
70
+ const {
71
+ validationMessage,
72
+ isInvalid: validationInvalid,
73
+ commitValidation
74
+ } = HeadlessForm.useValidation(
75
+ {
76
+ validate: props.validate,
77
+ value,
78
+ name: props.name,
79
+ isRequired: props.isRequired,
80
+ form: props.form,
81
+ focus() {
82
+ inputRef.current?.focus()
83
+ }
84
+ },
85
+ inputRef
86
+ )
87
+
88
+ const isInvalid = props.isInvalid || validationInvalid || Boolean(exceededMaxSize || formatNotSupported)
89
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
90
+ const errorMessage = exceededMaxSize || formatNotSupported || validationMessage
91
+
92
+ const FilePreview = props.renderPreview ?? DefaultFileInputFilePreview
93
+
94
+ return (
95
+ <HeadlessFileInput
96
+ value={value}
97
+ onChange={(v) => {
98
+ setValue(v)
99
+ commitValidation()
100
+ }}
101
+ >
102
+ <div className="relative">
103
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
104
+
105
+ <HeadlessFileInput.Preview>
106
+ {(files) => {
107
+ const file = files.length !== 0 ? files[0]!.file : null
108
+ const fileUrl = files.length !== 0 ? files[0]!.fileUrl : null
109
+
110
+ return (
111
+ <div
112
+ className={tw(
113
+ 'relative',
114
+ hasWarning &&
115
+ '[&_.file-preview-border]:border-notice-300 [&_.file-preview-border]:hover:border-notice-400 [&_.file-preview-border]:text-notice-900 [&_.file-preview-border]:placeholder-notice-300 [&_.file-preview-border]:focus-within:border-notice-500 [&_.file-preview-border]:focus-within:ring-notice-500',
116
+ isInvalid &&
117
+ '[&_.file-preview-border]:border-negative-300 [&_.file-preview-border]:hover:border-negative-400 [&_.file-preview-border]:text-negative-900 [&_.file-preview-border]:placeholder-negative-300 [&_.file-preview-border]:focus-within:border-negative-500 [&_.file-preview-border]:focus-within:ring-negative-500'
118
+ )}
119
+ >
120
+ {FilePreview({
121
+ file,
122
+ fileUrl,
123
+ isDisabled: !!props.isDisabled,
124
+ onChange: (v) => {
125
+ setValue(v)
126
+ commitValidation()
127
+ },
128
+ inputId,
129
+ inputRef,
130
+ name: props.name,
131
+ accept: props.accept,
132
+ formatFileSize,
133
+ setDragging,
134
+ maxSize: props.maxSize
135
+ })}
136
+ </div>
137
+ )
138
+ }}
139
+ </HeadlessFileInput.Preview>
140
+
141
+ <FormFieldComponents.FormFieldHelpText
142
+ id={helpTextId}
143
+ isInvalid={isInvalid}
144
+ validationMessage={errorMessage}
145
+ hasWarning={hasWarning}
146
+ warningMessage={props.warningMessage}
147
+ helpText={props.helpText}
148
+ />
149
+ </div>
150
+ </HeadlessFileInput>
151
+ )
152
+ }
153
+
154
+ function DragOverContent() {
155
+ return (
156
+ <div
157
+ className="absolute inset-0 p-12 rounded flex items-center justify-center"
158
+ style={{
159
+ background: `repeating-linear-gradient(45deg, #e5e7eb, #e5e7eb 20px, #d1d5db 20px, #d1d5db 50px)`
160
+ }}
161
+ >
162
+ <div>
163
+ <Icon name="cloud-arrow-up" className="h-12 w-12 mx-auto text-gray-400" />
164
+ <span className="mt-2 block text-sm font-semibold text-gray-900">Upload a File</span>
165
+ </div>
166
+ </div>
167
+ )
168
+ }
169
+
170
+ export function DefaultFileInputFilePreview({
171
+ file,
172
+ isDisabled,
173
+ onChange,
174
+ inputId,
175
+ name,
176
+ accept,
177
+ formatFileSize,
178
+ setDragging,
179
+ maxSize
180
+ }: FileInputPreviewProps) {
181
+ return (
182
+ <>
183
+ <HeadlessFileInput.DropZone
184
+ accept={accept}
185
+ name={name}
186
+ inputId={inputId}
187
+ onDragChange={setDragging}
188
+ disabled={isDisabled}
189
+ >
190
+ {({ dragOver }) => (
191
+ <div
192
+ className={tw(
193
+ 'file-preview-border relative block w-full rounded-md text-center focus:outline-none focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2',
194
+ 'border-2 border-neutral-200 hover:border-neutral-300',
195
+ file ? '' : 'border-dashed',
196
+ isDisabled && 'border-neutral-100 cursor-not-allowed'
197
+ )}
198
+ >
199
+ {file ? (
200
+ <div className="relative text-left">
201
+ <div className="flex items-center justify-between p-3 rounded">
202
+ <div className="flex items-center gap-3">
203
+ <Icon name="document" className="h-8 w-8 text-neutral-400" />
204
+ <div>
205
+ <p className="text-sm font-medium text-neutral-900">{file.name || 'File'}</p>
206
+ <p className="text-xs text-neutral-500">{formatFileSize(file.size)}</p>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ {dragOver && <DragOverContent />}
211
+ </div>
212
+ ) : dragOver ? (
213
+ <DragOverContent />
214
+ ) : (
215
+ <div className="flex items-center justify-between p-3">
216
+ <div className="flex items-center gap-3">
217
+ <Icon name="cloud-arrow-up" className="h-8 w-8 text-neutral-400" />
218
+ <div className="text-left">
219
+ <p className="text-sm text-neutral-600 font-medium">Click to upload or drag and drop</p>
220
+ <div className="text-xs text-neutral-500 mt-0.5">
221
+ {accept && <span>{accept}</span>}
222
+ {accept && maxSize && <span> • </span>}
223
+ {maxSize && <span>Max: {formatFileSize(maxSize)}</span>}
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ )}
229
+ </div>
230
+ )}
231
+ </HeadlessFileInput.DropZone>
232
+
233
+ {file && !isDisabled && (
234
+ <button
235
+ type="button"
236
+ aria-label="Remove file"
237
+ onClick={() => onChange(null)}
238
+ className="absolute top-1/2 -translate-y-1/2 right-3 p-1 hover:bg-neutral-50 rounded transition-colors"
239
+ >
240
+ <Icon name="x-mark" className="h-5 w-5 text-neutral-500" />
241
+ </button>
242
+ )}
243
+ </>
244
+ )
245
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ type FormProps as ReactRouterFormProps,
3
+ Form as ReactRouterForm,
4
+ data,
5
+ type ClientActionFunctionArgs
6
+ } from 'react-router'
7
+ import { HeadlessForm } from '@maestro-js/form'
8
+ import { serverOnly$ } from 'vite-env-only/macros'
9
+
10
+ function _Form({ children, ...props }: ReactRouterFormProps) {
11
+ return (
12
+ <HeadlessForm as={ReactRouterForm} {...props}>
13
+ {children}
14
+ </HeadlessForm>
15
+ )
16
+ }
17
+
18
+ export const Form = Object.assign(_Form, {
19
+ HiddenInput: HeadlessForm.HiddenInput,
20
+ HiddenFileInput: HeadlessForm.HiddenFileInput,
21
+ HiddenSelect: HeadlessForm.HiddenSelect,
22
+ useControlledState: HeadlessForm.useControlledState,
23
+ useTopLevelFormValidation: HeadlessForm.useTopLevelFormValidation,
24
+ useReset: HeadlessForm.useReset,
25
+ clientSafeParse: HeadlessForm.parseFormDataClientSide,
26
+ serverSafeParse: HeadlessForm.parseFormData,
27
+ validationError: serverOnly$(validationError) as typeof validationError,
28
+ customValidationError: serverOnly$(customValidationError) as typeof customValidationError,
29
+ composeValidators: HeadlessForm.composeValidators,
30
+ FormError
31
+ })
32
+
33
+ async function validationError(
34
+ {
35
+ issues,
36
+ formData,
37
+ formId
38
+ }: {
39
+ issues: { path?: ((string | number | symbol) | { key: string | number | symbol })[] | undefined; message: string }[]
40
+ formData: FormData
41
+ formId?: string | null
42
+ },
43
+ init: RequestInit = {}
44
+ ) {
45
+ console.error(issues)
46
+ return data(
47
+ {
48
+ success: false as const,
49
+ formSubmissionError: {
50
+ issues: issues.map((i) => ({
51
+ path: i.path?.map((p) => (p && typeof p === 'object' && 'key' in p ? p.key : p))?.join('.') ?? '',
52
+ message: i.message
53
+ })),
54
+ formId: formId ?? formData.get('__formId')
55
+ }
56
+ },
57
+ { status: 422, ...init }
58
+ )
59
+ }
60
+
61
+ async function customValidationError(
62
+ { message, formData }: { message: string; formData: FormData },
63
+ init?: number | ResponseInit
64
+ ) {
65
+ console.error(message)
66
+ return data(
67
+ {
68
+ success: false as const,
69
+ formSubmissionError: {
70
+ issues: [{ path: '', message: message }],
71
+ formId: formData.get('__formId')
72
+ }
73
+ },
74
+ { status: typeof init === 'number' ? init : 422, ...(typeof init === 'object' ? init : {}) }
75
+ )
76
+ }
77
+
78
+ function FormError({ formId, children }: { formId?: string; children(message: string): React.ReactNode }) {
79
+ const serverErrors = HeadlessForm.useServerErrors(formId)
80
+ const formErrors = serverErrors.filter((s) => !s.path)
81
+ return formErrors.length ? <>{children(formErrors[0]!.message)}</> : null
82
+ }