@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.
- package/commands/add.ts +41 -0
- package/commands/index.ts +7 -0
- package/commands/list.ts +9 -0
- package/dist/components.json +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +48 -0
- package/package.json +49 -0
- package/registry.json +1445 -0
- package/scripts/build.ts +44 -0
- package/src/components/alert-dialog.tsx +150 -0
- package/src/components/autocomplete.tsx +288 -0
- package/src/components/autosuggest.tsx +314 -0
- package/src/components/avatar.tsx +54 -0
- package/src/components/boolean-select.tsx +48 -0
- package/src/components/button-group.tsx +75 -0
- package/src/components/button-link.tsx +71 -0
- package/src/components/button.tsx +132 -0
- package/src/components/checkbox-group.tsx +172 -0
- package/src/components/checkbox.tsx +207 -0
- package/src/components/chip.tsx +158 -0
- package/src/components/container.tsx +82 -0
- package/src/components/currency-field.tsx +183 -0
- package/src/components/date-input.tsx +189 -0
- package/src/components/date-picker.tsx +211 -0
- package/src/components/date-range-picker.tsx +290 -0
- package/src/components/date-time-picker.tsx +196 -0
- package/src/components/dialog.tsx +97 -0
- package/src/components/disclosure.tsx +114 -0
- package/src/components/drawer.tsx +78 -0
- package/src/components/enum-chip.tsx +30 -0
- package/src/components/file-input.tsx +245 -0
- package/src/components/form.tsx +82 -0
- package/src/components/headless-file-input.tsx +362 -0
- package/src/components/helpers/animated-popover.tsx +24 -0
- package/src/components/helpers/button-context.ts +10 -0
- package/src/components/helpers/calendar-month-year-picker.tsx +280 -0
- package/src/components/helpers/form-field.tsx +229 -0
- package/src/components/helpers/get-button-classes.ts +138 -0
- package/src/components/helpers/headless-button.tsx +36 -0
- package/src/components/helpers/pdf-dist.client.ts +6 -0
- package/src/components/icon.tsx +26 -0
- package/src/components/image-input.tsx +265 -0
- package/src/components/img.tsx +46 -0
- package/src/components/inline-alert.tsx +54 -0
- package/src/components/labeled-value.tsx +480 -0
- package/src/components/link-tabs.tsx +118 -0
- package/src/components/menu.tsx +152 -0
- package/src/components/month-day-input.tsx +176 -0
- package/src/components/multi-file-input.tsx +244 -0
- package/src/components/multi-image-input.tsx +389 -0
- package/src/components/multicomplete.tsx +322 -0
- package/src/components/multiselect.tsx +325 -0
- package/src/components/multisuggest.tsx +357 -0
- package/src/components/number-field.tsx +143 -0
- package/src/components/numeric-tag-field.tsx +271 -0
- package/src/components/pdf-input.tsx +249 -0
- package/src/components/pdf.tsx +86 -0
- package/src/components/percentage-field.tsx +187 -0
- package/src/components/phone-number-field.tsx +166 -0
- package/src/components/radio-group.tsx +112 -0
- package/src/components/radio.tsx +91 -0
- package/src/components/select.tsx +215 -0
- package/src/components/spinner.tsx +16 -0
- package/src/components/stepper.tsx +181 -0
- package/src/components/switch.tsx +186 -0
- package/src/components/tabs.tsx +151 -0
- package/src/components/tag-field.tsx +250 -0
- package/src/components/text-area.tsx +148 -0
- package/src/components/text-field.tsx +144 -0
- package/src/components/time-input.tsx +198 -0
- package/src/components/toast.tsx +176 -0
- package/src/components/toggle-button-group.tsx +94 -0
- package/src/components/year-month-input.tsx +187 -0
- package/src/utils/colors.ts +44 -0
- package/src/utils/compose-refs.ts +32 -0
- package/src/utils/enum-colors.ts +1 -0
- package/src/utils/file-input.ts +49 -0
- package/src/utils/icons.d.ts +20 -0
- package/src/utils/tw.ts +13 -0
- package/src/utils/use-element-size.ts +35 -0
- package/src/utils/use-number-input.ts +143 -0
- package/src/utils/use-pagination.ts +38 -0
- package/src/utils/use-prevent-default.ts +27 -0
- package/src/utils/use-render-props.ts +39 -0
- package/src/utils/use-spin-delay.ts +24 -0
- package/src/utils/use-stable-accessor.ts +11 -0
- package/src/utils/use-tab-indicator.ts +106 -0
- package/tests/commands.test.ts +81 -0
- 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
|
+
}
|