@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,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
+ }