@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,152 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Menu as AriaMenu,
|
|
4
|
+
MenuItem as AriaMenuItem,
|
|
5
|
+
MenuSection as AriaMenuSection,
|
|
6
|
+
MenuTrigger as AriaMenuTrigger,
|
|
7
|
+
Pressable,
|
|
8
|
+
Separator as AriaSeparator
|
|
9
|
+
} from 'react-aria-components'
|
|
10
|
+
import { AnimatedPopover } from './helpers/animated-popover'
|
|
11
|
+
import { tw } from '../utils/tw'
|
|
12
|
+
|
|
13
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export type MenuProps = {
|
|
16
|
+
/** Trigger element. Can be a ReactNode or render function receiving `{ isOpen }`. */
|
|
17
|
+
Button: React.ReactNode | ((props: { isOpen: boolean }) => React.ReactElement)
|
|
18
|
+
children?: React.ReactNode
|
|
19
|
+
className?: string
|
|
20
|
+
containerClassName?: string
|
|
21
|
+
placement?: 'bottom' | 'bottom start' | 'bottom end' | 'top' | 'top start' | 'top end'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MenuItemProps = {
|
|
25
|
+
children?: React.ReactNode
|
|
26
|
+
className?: string
|
|
27
|
+
isDisabled?: boolean
|
|
28
|
+
onAction?: () => void
|
|
29
|
+
id?: string
|
|
30
|
+
textValue?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type MenuLinkItemProps = {
|
|
34
|
+
href: string
|
|
35
|
+
children?: React.ReactNode
|
|
36
|
+
className?: string
|
|
37
|
+
isDisabled?: boolean
|
|
38
|
+
target?: React.HTMLAttributeAnchorTarget
|
|
39
|
+
rel?: string
|
|
40
|
+
id?: string
|
|
41
|
+
textValue?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type MenuGroupProps = {
|
|
45
|
+
children?: React.ReactNode
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── MenuMain ──────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function MenuMain({ Button: ButtonProp, children, className, containerClassName, placement = 'bottom end' }: MenuProps) {
|
|
51
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
52
|
+
|
|
53
|
+
const triggerContent = typeof ButtonProp === 'function' ? ButtonProp({ isOpen }) : ButtonProp
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={tw('relative inline-block text-left', containerClassName)}>
|
|
57
|
+
<AriaMenuTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
|
58
|
+
<Pressable>
|
|
59
|
+
{React.isValidElement(triggerContent) ? (
|
|
60
|
+
triggerContent
|
|
61
|
+
) : (
|
|
62
|
+
<button type="button" className="inline-flex items-center focus:outline-none">
|
|
63
|
+
{triggerContent}
|
|
64
|
+
</button>
|
|
65
|
+
)}
|
|
66
|
+
</Pressable>
|
|
67
|
+
<AnimatedPopover placement={placement} offset={8}>
|
|
68
|
+
<AriaMenu
|
|
69
|
+
className={tw(
|
|
70
|
+
'overflow-hidden min-w-56 w-max rounded bg-white py-1 shadow-lg ring-1 ring-black/5 focus:outline-none',
|
|
71
|
+
className
|
|
72
|
+
)}
|
|
73
|
+
autoFocus="first"
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</AriaMenu>
|
|
77
|
+
</AnimatedPopover>
|
|
78
|
+
</AriaMenuTrigger>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
MenuMain.displayName = 'Menu'
|
|
84
|
+
|
|
85
|
+
// ── MenuItem ──────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function Item({ children, className, isDisabled, onAction, id, textValue }: MenuItemProps) {
|
|
88
|
+
return (
|
|
89
|
+
<AriaMenuItem
|
|
90
|
+
id={id}
|
|
91
|
+
textValue={textValue ?? (typeof children === 'string' ? children : undefined)}
|
|
92
|
+
onAction={onAction}
|
|
93
|
+
isDisabled={isDisabled}
|
|
94
|
+
className={({ isFocused, isDisabled }) =>
|
|
95
|
+
tw(
|
|
96
|
+
isFocused ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-700',
|
|
97
|
+
'group flex w-full items-center px-4 py-2 text-sm outline-none',
|
|
98
|
+
isDisabled ? 'cursor-not-allowed opacity-50 pointer-events-none' : 'cursor-pointer',
|
|
99
|
+
className
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
>
|
|
103
|
+
{children}
|
|
104
|
+
</AriaMenuItem>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── LinkItem ──────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function LinkItem({ href, children, className, isDisabled, target, rel, id, textValue }: MenuLinkItemProps) {
|
|
111
|
+
return (
|
|
112
|
+
<AriaMenuItem
|
|
113
|
+
id={id}
|
|
114
|
+
textValue={textValue ?? (typeof children === 'string' ? children : undefined)}
|
|
115
|
+
href={href}
|
|
116
|
+
target={target}
|
|
117
|
+
rel={rel}
|
|
118
|
+
isDisabled={isDisabled}
|
|
119
|
+
className={({ isFocused, isDisabled }) =>
|
|
120
|
+
tw(
|
|
121
|
+
isFocused ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-700',
|
|
122
|
+
'group flex items-center px-4 py-2 text-sm gap-2 outline-none',
|
|
123
|
+
isDisabled ? 'cursor-not-allowed opacity-50 pointer-events-none' : 'cursor-pointer',
|
|
124
|
+
className
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
>
|
|
128
|
+
{children}
|
|
129
|
+
</AriaMenuItem>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Group ─────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function Group({ children }: MenuGroupProps) {
|
|
136
|
+
return <AriaMenuSection className="py-1">{children}</AriaMenuSection>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Separator ─────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
function MenuSeparator() {
|
|
142
|
+
return <AriaSeparator className="border-t border-neutral-100" />
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Export ─────────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
export const Menu = Object.assign(MenuMain, {
|
|
148
|
+
Item,
|
|
149
|
+
LinkItem,
|
|
150
|
+
Group,
|
|
151
|
+
Separator: MenuSeparator
|
|
152
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { DateField, DateInput as AriaDateInput, DateSegment, I18nProvider } from 'react-aria-components'
|
|
3
|
+
import { CalendarDate } from '@internationalized/date'
|
|
4
|
+
import { tw } from '../utils/tw'
|
|
5
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
6
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
7
|
+
import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
|
|
8
|
+
import { monthDayFns, type Iso } from 'iso-fns'
|
|
9
|
+
|
|
10
|
+
export type MonthDayInputProps = {
|
|
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
|
+
placeholder?: string
|
|
20
|
+
warningMessage?: React.ReactNode
|
|
21
|
+
|
|
22
|
+
// Validation and state
|
|
23
|
+
isRequired?: boolean
|
|
24
|
+
isDisabled?: boolean
|
|
25
|
+
isReadonly?: boolean
|
|
26
|
+
isInvalid?: boolean
|
|
27
|
+
validate?: ValidationFunction<Iso.MonthDay | null>
|
|
28
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
29
|
+
|
|
30
|
+
// Value management (ISO month-day string --MM-DD)
|
|
31
|
+
value?: Iso.MonthDay | null
|
|
32
|
+
defaultValue?: Iso.MonthDay | null
|
|
33
|
+
onChange?: (value: Iso.MonthDay | null) => void
|
|
34
|
+
|
|
35
|
+
// Locale
|
|
36
|
+
locale?: string
|
|
37
|
+
shape?: 'rectangle' | 'oval'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function MonthDayInput(props: MonthDayInputProps) {
|
|
41
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
42
|
+
const helpTextId = React.useId()
|
|
43
|
+
const labelId = React.useId()
|
|
44
|
+
|
|
45
|
+
// Internal state for CalendarDate (using placeholder year 1972)
|
|
46
|
+
const [value, _setValue] = React.useState(() => {
|
|
47
|
+
try {
|
|
48
|
+
const startingDate = props.value === undefined ? (props.defaultValue ?? null) : props.value
|
|
49
|
+
return startingDate ? parseMonthDay(startingDate) : null
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
function setValue(d: CalendarDate | null) {
|
|
56
|
+
_setValue(d)
|
|
57
|
+
const monthDay = formatMonthDay(d)
|
|
58
|
+
props.onChange?.(monthDay)
|
|
59
|
+
commitValidation()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Sync controlled value
|
|
63
|
+
if (props.value !== undefined) {
|
|
64
|
+
const currentMonthDay = formatMonthDay(value)
|
|
65
|
+
if (props.value !== currentMonthDay) {
|
|
66
|
+
try {
|
|
67
|
+
_setValue(props.value ? parseMonthDay(props.value) : null)
|
|
68
|
+
} catch {
|
|
69
|
+
_setValue(null)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Form validation
|
|
75
|
+
const {
|
|
76
|
+
validationMessage,
|
|
77
|
+
isInvalid: validationInvalid,
|
|
78
|
+
commitValidation
|
|
79
|
+
} = HeadlessForm.useValidation(
|
|
80
|
+
{
|
|
81
|
+
validate: props.validate,
|
|
82
|
+
value: formatMonthDay(value),
|
|
83
|
+
name: props.name,
|
|
84
|
+
isRequired: props.isRequired,
|
|
85
|
+
form: props.form,
|
|
86
|
+
focus() {
|
|
87
|
+
// Focus handled by DateField
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
hiddenInputRef as React.RefObject<HTMLInputElement>
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
94
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<I18nProvider locale={props.locale ?? 'en'}>
|
|
98
|
+
<div className="relative">
|
|
99
|
+
{/* Hidden input for form submission */}
|
|
100
|
+
<HeadlessForm.HiddenInput
|
|
101
|
+
ref={hiddenInputRef}
|
|
102
|
+
name={props.name}
|
|
103
|
+
value={formatMonthDay(value) ?? ''}
|
|
104
|
+
form={props.form}
|
|
105
|
+
/>
|
|
106
|
+
|
|
107
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
|
|
108
|
+
|
|
109
|
+
{/* Month/Day input using DateField */}
|
|
110
|
+
<DateField
|
|
111
|
+
aria-labelledby={labelId}
|
|
112
|
+
aria-describedby={helpTextId}
|
|
113
|
+
value={value}
|
|
114
|
+
onChange={setValue}
|
|
115
|
+
placeholderValue={new CalendarDate(1972, 1, 1)}
|
|
116
|
+
isDisabled={props.isDisabled}
|
|
117
|
+
isReadOnly={props.isReadonly}
|
|
118
|
+
isInvalid={isInvalid}
|
|
119
|
+
isRequired={props.isRequired}
|
|
120
|
+
granularity="day"
|
|
121
|
+
hideTimeZone
|
|
122
|
+
>
|
|
123
|
+
<FormFieldComponents.FieldGroup
|
|
124
|
+
isDisabled={props.isDisabled}
|
|
125
|
+
isReadonly={props.isReadonly}
|
|
126
|
+
isInvalid={isInvalid}
|
|
127
|
+
hasWarning={hasWarning}
|
|
128
|
+
shape={props.shape}
|
|
129
|
+
>
|
|
130
|
+
<AriaDateInput className="flex px-3 py-2 text-sm [&>[data-literal]:not(:has(~[data-literal]))]:hidden">
|
|
131
|
+
{(segment) => {
|
|
132
|
+
const isHidden = segment.type === 'year'
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<DateSegment
|
|
136
|
+
segment={segment}
|
|
137
|
+
data-literal={segment.type === 'literal' ? true : undefined}
|
|
138
|
+
className={tw(
|
|
139
|
+
'px-0.5 tabular-nums outline-none rounded-sm',
|
|
140
|
+
'focus:bg-primary-500 focus:text-white',
|
|
141
|
+
segment.isPlaceholder && 'text-neutral-400',
|
|
142
|
+
'last-of-type:hidden',
|
|
143
|
+
isHidden && 'hidden'
|
|
144
|
+
)}
|
|
145
|
+
/>
|
|
146
|
+
)
|
|
147
|
+
}}
|
|
148
|
+
</AriaDateInput>
|
|
149
|
+
</FormFieldComponents.FieldGroup>
|
|
150
|
+
</DateField>
|
|
151
|
+
|
|
152
|
+
<FormFieldComponents.FormFieldHelpText
|
|
153
|
+
id={helpTextId}
|
|
154
|
+
isInvalid={isInvalid}
|
|
155
|
+
validationMessage={validationMessage}
|
|
156
|
+
hasWarning={hasWarning}
|
|
157
|
+
warningMessage={props.warningMessage}
|
|
158
|
+
helpText={props.helpText}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
</I18nProvider>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseMonthDay(monthDay: Iso.MonthDay): CalendarDate | null {
|
|
166
|
+
if (!monthDayFns.isValid(monthDay)) return null
|
|
167
|
+
// Use a leap-year placeholder so Feb 29 is valid
|
|
168
|
+
return new CalendarDate(1972, monthDayFns.getMonth(monthDay), monthDayFns.getDay(monthDay))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatMonthDay(date: CalendarDate | null): Iso.MonthDay | null {
|
|
172
|
+
if (!date) return null
|
|
173
|
+
const month = date.month.toString().padStart(2, '0')
|
|
174
|
+
const day = date.day.toString().padStart(2, '0')
|
|
175
|
+
return `--${month}-${day}` as Iso.MonthDay
|
|
176
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
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 interface UrlFileReference {
|
|
11
|
+
name: string
|
|
12
|
+
url: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type FileLike = File | string | UrlFileReference
|
|
16
|
+
|
|
17
|
+
export type MultiFileInputProps = {
|
|
18
|
+
// Form identification
|
|
19
|
+
name?: string
|
|
20
|
+
form?: string
|
|
21
|
+
|
|
22
|
+
// Display elements
|
|
23
|
+
label?: React.ReactNode
|
|
24
|
+
cornerHint?: React.ReactNode
|
|
25
|
+
helpText?: React.ReactNode
|
|
26
|
+
warningMessage?: React.ReactNode
|
|
27
|
+
|
|
28
|
+
// Validation and state
|
|
29
|
+
isRequired?: boolean
|
|
30
|
+
isDisabled?: boolean
|
|
31
|
+
isReadonly?: boolean
|
|
32
|
+
isInvalid?: boolean
|
|
33
|
+
validate?: ValidationFunction<FileLike[]>
|
|
34
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
35
|
+
|
|
36
|
+
// Value management (File objects array or strings, held in memory until form submit)
|
|
37
|
+
value?: FileLike[]
|
|
38
|
+
defaultValue?: FileLike[]
|
|
39
|
+
onChange?: (value: FileLike[], hasError: boolean) => void
|
|
40
|
+
|
|
41
|
+
// File specific
|
|
42
|
+
accept?: string | null
|
|
43
|
+
maxSize?: number // in bytes per file
|
|
44
|
+
maxFiles?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function MultiFileInput(props: MultiFileInputProps) {
|
|
48
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
49
|
+
const helpTextId = React.useId()
|
|
50
|
+
const inputId = React.useId()
|
|
51
|
+
|
|
52
|
+
const exceededMaxSizeFn = React.useCallback(
|
|
53
|
+
(value: FileLike[]) => {
|
|
54
|
+
for (let i = 0; i < value.length; i++) {
|
|
55
|
+
const v = value[i]
|
|
56
|
+
if (v instanceof File && props.maxSize) {
|
|
57
|
+
const error = checkFileMaxSize(v, props.maxSize)
|
|
58
|
+
if (error) return `File ${i + 1} exceeded max size of ${formatFileSize(props.maxSize)}`
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
},
|
|
63
|
+
[props.maxSize]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const formatNotSupportedFn = React.useCallback(
|
|
67
|
+
(value: FileLike[]) => {
|
|
68
|
+
if (!props.accept) return null
|
|
69
|
+
for (const v of value) {
|
|
70
|
+
if (v instanceof File) {
|
|
71
|
+
const error = checkFileType(v, props.accept)
|
|
72
|
+
if (error) return error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
},
|
|
77
|
+
[props.accept]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// Controlled state management
|
|
81
|
+
const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? [], (value) => {
|
|
82
|
+
if (props.onChange) {
|
|
83
|
+
props.onChange(value, Boolean(exceededMaxSizeFn(value) || formatNotSupportedFn(value)))
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Form validation
|
|
88
|
+
const {
|
|
89
|
+
validationMessage,
|
|
90
|
+
isInvalid: validationInvalid,
|
|
91
|
+
commitValidation
|
|
92
|
+
} = HeadlessForm.useValidation(
|
|
93
|
+
{
|
|
94
|
+
validate(files) {
|
|
95
|
+
const exceededMaxSize = exceededMaxSizeFn(files)
|
|
96
|
+
const formatNotSupported = formatNotSupportedFn(files)
|
|
97
|
+
const exceededMaxFiles = props.maxFiles !== undefined && files.length > props.maxFiles ? 'Too many files' : null
|
|
98
|
+
const custom = [props.validate ? props.validate(files) : []].flat()
|
|
99
|
+
|
|
100
|
+
const errors = [exceededMaxSize, formatNotSupported, exceededMaxFiles, ...custom].filter(Boolean)
|
|
101
|
+
return errors as string[]
|
|
102
|
+
},
|
|
103
|
+
value,
|
|
104
|
+
name: props.name,
|
|
105
|
+
isRequired: props.isRequired,
|
|
106
|
+
form: props.form,
|
|
107
|
+
focus() {
|
|
108
|
+
inputRef.current?.focus()
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
inputRef
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
115
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
116
|
+
const errorMessage = validationMessage
|
|
117
|
+
|
|
118
|
+
const handleRemove = (index: number) => {
|
|
119
|
+
const newFiles = value.filter((_, i) => i !== index)
|
|
120
|
+
setValue(newFiles)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<HeadlessFileInput
|
|
125
|
+
value={value.map((v) => (v instanceof File ? v : typeof v === 'string' ? v : v.url))}
|
|
126
|
+
onChange={(v) => {
|
|
127
|
+
setValue(
|
|
128
|
+
v.map((a) =>
|
|
129
|
+
a instanceof File
|
|
130
|
+
? a
|
|
131
|
+
: (value.find((b) => (b instanceof File || typeof b === 'string' ? false : b.url === a)) ?? {
|
|
132
|
+
name: a,
|
|
133
|
+
url: a
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
commitValidation()
|
|
138
|
+
}}
|
|
139
|
+
multiple
|
|
140
|
+
>
|
|
141
|
+
<HeadlessForm.HiddenInput value="" form={props.form} name={undefined} ref={inputRef} />
|
|
142
|
+
<div className="relative">
|
|
143
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
|
|
144
|
+
|
|
145
|
+
<HeadlessFileInput.Preview>
|
|
146
|
+
{(files) => (
|
|
147
|
+
<>
|
|
148
|
+
{files.length > 0 && (
|
|
149
|
+
<div className="space-y-2 mb-3">
|
|
150
|
+
{files.map((fileItem, index) => {
|
|
151
|
+
const fileName = fileItem.file
|
|
152
|
+
? fileItem.file.name
|
|
153
|
+
: (value.filter((v) => !(v instanceof File) && typeof v !== 'string') as UrlFileReference[]).find(
|
|
154
|
+
(v) => v.url === fileItem.fileUrl
|
|
155
|
+
)?.name
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
key={index}
|
|
159
|
+
className="flex items-center text-left justify-between px-6 py-2 rounded border border-neutral-200 bg-white"
|
|
160
|
+
>
|
|
161
|
+
<div className="flex items-center gap-2">
|
|
162
|
+
<Icon name="document" className="h-5 w-5 text-neutral-400" />
|
|
163
|
+
<div className="min-w-0">
|
|
164
|
+
<p className="text-sm font-medium text-neutral-900 truncate">{fileName || 'File'}</p>
|
|
165
|
+
{fileItem.file && <p className="text-xs text-neutral-500">{formatFileSize(fileItem.file.size)}</p>}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
{!props.isDisabled && !props.isReadonly && (
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={() => handleRemove(index)}
|
|
172
|
+
className="p-1 hover:bg-neutral-100 rounded transition-colors"
|
|
173
|
+
>
|
|
174
|
+
<Icon name="x-mark" className="h-4 w-4 text-neutral-500" />
|
|
175
|
+
</button>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
})}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
</HeadlessFileInput.Preview>
|
|
185
|
+
|
|
186
|
+
<HeadlessFileInput.DropZone
|
|
187
|
+
accept={props.accept ?? undefined}
|
|
188
|
+
name={props.name}
|
|
189
|
+
inputId={inputId}
|
|
190
|
+
disabled={props.isDisabled || props.isReadonly}
|
|
191
|
+
>
|
|
192
|
+
{({ dragOver }) => (
|
|
193
|
+
<div
|
|
194
|
+
className={tw(
|
|
195
|
+
'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',
|
|
196
|
+
'border-2 border-dashed border-neutral-200 hover:border-neutral-300',
|
|
197
|
+
'px-6 py-2 min-h-[60px] flex items-center justify-center',
|
|
198
|
+
(props.isDisabled || props.isReadonly) && 'border-neutral-100 cursor-not-allowed',
|
|
199
|
+
hasWarning &&
|
|
200
|
+
'border-notice-300 hover:border-notice-400 text-notice-900 placeholder-notice-300 focus-within:border-notice-500 focus-within:ring-notice-500',
|
|
201
|
+
isInvalid &&
|
|
202
|
+
'border-negative-300 hover:border-negative-400 text-negative-900 placeholder-negative-300 focus-within:border-negative-500 focus-within:ring-negative-500'
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
{dragOver ? (
|
|
206
|
+
<DragOverContent />
|
|
207
|
+
) : (
|
|
208
|
+
<div className="flex items-center w-full gap-3">
|
|
209
|
+
<Icon name="cloud-arrow-up" className="h-5 w-5 text-neutral-400 flex-shrink-0" />
|
|
210
|
+
<div className="text-left">
|
|
211
|
+
<p className="text-sm font-medium text-neutral-600">Click to upload or drag and drop</p>
|
|
212
|
+
<p className="text-xs text-neutral-500">
|
|
213
|
+
{value.length > 0 ? 'Add more files' : 'Select files'}
|
|
214
|
+
{props.accept && ` (${props.accept})`}
|
|
215
|
+
{props.maxSize && ` • Max: ${formatFileSize(props.maxSize)}`}
|
|
216
|
+
</p>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
</HeadlessFileInput.DropZone>
|
|
223
|
+
|
|
224
|
+
<FormFieldComponents.FormFieldHelpText
|
|
225
|
+
id={helpTextId}
|
|
226
|
+
isInvalid={isInvalid}
|
|
227
|
+
validationMessage={errorMessage}
|
|
228
|
+
hasWarning={hasWarning}
|
|
229
|
+
warningMessage={props.warningMessage}
|
|
230
|
+
helpText={props.helpText}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
</HeadlessFileInput>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function DragOverContent() {
|
|
238
|
+
return (
|
|
239
|
+
<div className="flex items-center gap-3">
|
|
240
|
+
<Icon name="cloud-arrow-up" className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
|
241
|
+
<span className="text-sm font-semibold text-gray-900">Drop files here</span>
|
|
242
|
+
</div>
|
|
243
|
+
)
|
|
244
|
+
}
|