@vendure-io/ui 2.0.0-beta.1 → 2.0.0-beta.11

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.
@@ -0,0 +1,117 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@vendure-io/ui/components/atoms/button';
4
+ import { Calendar } from '@vendure-io/ui/components/atoms/calendar';
5
+ import { Popover, PopoverContent, PopoverTrigger } from '@vendure-io/ui/components/atoms/popover';
6
+ import { useFormatSettings } from '@vendure-io/ui/components/molecules/format-provider';
7
+ import {
8
+ formatCalendarDate,
9
+ formatDateLabel,
10
+ parseCalendarDate,
11
+ } from '@vendure-io/ui/lib/date-value';
12
+ import { cn } from '@vendure-io/ui/lib/utils';
13
+ import { CalendarIcon, XIcon } from 'lucide-react';
14
+ import * as React from 'react';
15
+
16
+ type CalendarProps = Omit<
17
+ React.ComponentProps<typeof Calendar>,
18
+ 'mode' | 'selected' | 'onSelect' | 'disabled' | 'defaultMonth'
19
+ >;
20
+
21
+ interface DatePickerProps extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
22
+ /** Local calendar date (`YYYY-MM-DD`), never an instant. */
23
+ value?: string;
24
+ onValueChange?: (value: string | undefined) => void;
25
+ min?: string;
26
+ max?: string;
27
+ placeholder?: React.ReactNode;
28
+ clearable?: boolean;
29
+ disabled?: boolean;
30
+ invalid?: boolean;
31
+ name?: string;
32
+ id?: string;
33
+ calendarProps?: CalendarProps;
34
+ }
35
+
36
+ /** Date-only picker whose value cannot shift when serialized across time zones. */
37
+ function DatePicker({
38
+ value,
39
+ onValueChange,
40
+ min,
41
+ max,
42
+ placeholder = 'Pick a date',
43
+ clearable = true,
44
+ disabled,
45
+ invalid,
46
+ name,
47
+ id,
48
+ calendarProps,
49
+ className,
50
+ ...props
51
+ }: DatePickerProps) {
52
+ const [open, setOpen] = React.useState(false);
53
+ const { locale } = useFormatSettings();
54
+ const selected = parseCalendarDate(value);
55
+ const minDate = parseCalendarDate(min);
56
+ const maxDate = parseCalendarDate(max);
57
+ const disabledMatchers = [
58
+ ...(minDate ? [{ before: minDate }] : []),
59
+ ...(maxDate ? [{ after: maxDate }] : []),
60
+ ];
61
+
62
+ return (
63
+ <div
64
+ data-slot="date-picker"
65
+ className={cn('flex min-w-0 items-center gap-1', className)}
66
+ {...props}
67
+ >
68
+ {name ? <input type="hidden" name={name} value={value ?? ''} /> : null}
69
+ <Popover open={open} onOpenChange={setOpen}>
70
+ <PopoverTrigger
71
+ render={
72
+ <Button
73
+ id={id}
74
+ type="button"
75
+ variant="outline"
76
+ disabled={disabled}
77
+ aria-invalid={invalid || undefined}
78
+ className="min-w-0 flex-1 justify-start font-normal"
79
+ />
80
+ }
81
+ >
82
+ <CalendarIcon />
83
+ <span className={cn('truncate', !selected && 'text-muted-foreground')}>
84
+ {selected ? formatDateLabel(selected, locale) : placeholder}
85
+ </span>
86
+ </PopoverTrigger>
87
+ <PopoverContent className="w-auto p-0" align="start">
88
+ <Calendar
89
+ {...calendarProps}
90
+ mode="single"
91
+ selected={selected}
92
+ defaultMonth={selected ?? minDate}
93
+ disabled={disabledMatchers}
94
+ onSelect={(date) => {
95
+ onValueChange?.(date ? formatCalendarDate(date) : undefined);
96
+ if (date) setOpen(false);
97
+ }}
98
+ />
99
+ </PopoverContent>
100
+ </Popover>
101
+ {clearable && selected ? (
102
+ <Button
103
+ type="button"
104
+ variant="ghost"
105
+ size="icon-sm"
106
+ disabled={disabled}
107
+ aria-label="Clear date"
108
+ onClick={() => onValueChange?.(undefined)}
109
+ >
110
+ <XIcon />
111
+ </Button>
112
+ ) : null}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ export { DatePicker, type DatePickerProps };
@@ -0,0 +1,151 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@vendure-io/ui/components/atoms/button';
4
+ import { Calendar } from '@vendure-io/ui/components/atoms/calendar';
5
+ import { Popover, PopoverContent, PopoverTrigger } from '@vendure-io/ui/components/atoms/popover';
6
+ import { useFormatSettings } from '@vendure-io/ui/components/molecules/format-provider';
7
+ import {
8
+ formatCalendarDate,
9
+ formatDateRangeLabel,
10
+ parseCalendarDate,
11
+ } from '@vendure-io/ui/lib/date-value';
12
+ import { cn } from '@vendure-io/ui/lib/utils';
13
+ import { CalendarRangeIcon, XIcon } from 'lucide-react';
14
+ import * as React from 'react';
15
+ import type { DateRange } from 'react-day-picker';
16
+
17
+ type CalendarProps = Omit<
18
+ React.ComponentProps<typeof Calendar>,
19
+ 'mode' | 'selected' | 'onSelect' | 'defaultMonth'
20
+ >;
21
+
22
+ interface DateRangeValue {
23
+ from?: string;
24
+ to?: string;
25
+ }
26
+
27
+ interface DateRangePreset {
28
+ id: string;
29
+ label: React.ReactNode;
30
+ value: DateRangeValue | (() => DateRangeValue);
31
+ }
32
+
33
+ interface DateRangePickerProps
34
+ extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
35
+ /** Local calendar-date boundaries (`YYYY-MM-DD`), inclusive. */
36
+ value?: DateRangeValue;
37
+ onValueChange?: (value: DateRangeValue | undefined) => void;
38
+ placeholder?: React.ReactNode;
39
+ clearable?: boolean;
40
+ disabled?: boolean;
41
+ invalid?: boolean;
42
+ presets?: readonly DateRangePreset[];
43
+ calendarProps?: CalendarProps;
44
+ }
45
+
46
+ function resolvePreset(value: DateRangePreset['value']): DateRangeValue {
47
+ return typeof value === 'function' ? value() : value;
48
+ }
49
+
50
+ function DateRangePicker({
51
+ value,
52
+ onValueChange,
53
+ placeholder = 'Pick a date range',
54
+ clearable = true,
55
+ disabled,
56
+ invalid,
57
+ presets = [],
58
+ calendarProps,
59
+ className,
60
+ ...props
61
+ }: DateRangePickerProps) {
62
+ const [open, setOpen] = React.useState(false);
63
+ const { locale } = useFormatSettings();
64
+ const selected: DateRange = {
65
+ from: parseCalendarDate(value?.from),
66
+ to: parseCalendarDate(value?.to),
67
+ };
68
+ const hasValue = Boolean(selected.from);
69
+
70
+ function commitRange(range: DateRange | undefined) {
71
+ if (!range?.from) {
72
+ onValueChange?.(undefined);
73
+ return;
74
+ }
75
+ onValueChange?.({
76
+ from: formatCalendarDate(range.from),
77
+ to: range.to ? formatCalendarDate(range.to) : undefined,
78
+ });
79
+ }
80
+
81
+ return (
82
+ <div
83
+ data-slot="date-range-picker"
84
+ className={cn('flex min-w-0 items-center gap-1', className)}
85
+ {...props}
86
+ >
87
+ <Popover open={open} onOpenChange={setOpen}>
88
+ <PopoverTrigger
89
+ render={
90
+ <Button
91
+ type="button"
92
+ variant="outline"
93
+ disabled={disabled}
94
+ aria-invalid={invalid || undefined}
95
+ className="min-w-0 flex-1 justify-start font-normal"
96
+ />
97
+ }
98
+ >
99
+ <CalendarRangeIcon />
100
+ <span className={cn('truncate', !hasValue && 'text-muted-foreground')}>
101
+ {selected.from ? formatDateRangeLabel(selected.from, selected.to, locale) : placeholder}
102
+ </span>
103
+ </PopoverTrigger>
104
+ <PopoverContent className="w-auto p-0" align="start">
105
+ <div className={cn('flex', presets.length > 0 && 'divide-x')}>
106
+ {presets.length > 0 ? (
107
+ <div className="flex w-40 flex-col gap-1 p-2">
108
+ {presets.map((preset) => (
109
+ <Button
110
+ key={preset.id}
111
+ type="button"
112
+ variant="ghost"
113
+ size="sm"
114
+ className="justify-start font-normal"
115
+ onClick={() => {
116
+ onValueChange?.(resolvePreset(preset.value));
117
+ setOpen(false);
118
+ }}
119
+ >
120
+ {preset.label}
121
+ </Button>
122
+ ))}
123
+ </div>
124
+ ) : null}
125
+ <Calendar
126
+ {...calendarProps}
127
+ mode="range"
128
+ selected={selected}
129
+ defaultMonth={selected.from}
130
+ onSelect={commitRange}
131
+ />
132
+ </div>
133
+ </PopoverContent>
134
+ </Popover>
135
+ {clearable && hasValue ? (
136
+ <Button
137
+ type="button"
138
+ variant="ghost"
139
+ size="icon-sm"
140
+ disabled={disabled}
141
+ aria-label="Clear date range"
142
+ onClick={() => onValueChange?.(undefined)}
143
+ >
144
+ <XIcon />
145
+ </Button>
146
+ ) : null}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ export { DateRangePicker, type DateRangePickerProps, type DateRangePreset, type DateRangeValue };
@@ -0,0 +1,131 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@vendure-io/ui/components/atoms/button';
4
+ import { Calendar } from '@vendure-io/ui/components/atoms/calendar';
5
+ import { Input } from '@vendure-io/ui/components/atoms/input';
6
+ import { Popover, PopoverContent, PopoverTrigger } from '@vendure-io/ui/components/atoms/popover';
7
+ import { useFormatSettings } from '@vendure-io/ui/components/molecules/format-provider';
8
+ import { formatDateLabel, parseInstant } from '@vendure-io/ui/lib/date-value';
9
+ import { cn } from '@vendure-io/ui/lib/utils';
10
+ import { CalendarClockIcon, XIcon } from 'lucide-react';
11
+ import * as React from 'react';
12
+
13
+ type CalendarProps = Omit<
14
+ React.ComponentProps<typeof Calendar>,
15
+ 'mode' | 'selected' | 'onSelect' | 'defaultMonth'
16
+ >;
17
+
18
+ interface DateTimePickerProps
19
+ extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
20
+ /** ISO 8601 instant. Calendar and time controls edit it in the user's local zone. */
21
+ value?: string;
22
+ onValueChange?: (value: string | undefined) => void;
23
+ placeholder?: React.ReactNode;
24
+ clearable?: boolean;
25
+ disabled?: boolean;
26
+ invalid?: boolean;
27
+ name?: string;
28
+ id?: string;
29
+ calendarProps?: CalendarProps;
30
+ }
31
+
32
+ function DateTimePicker({
33
+ value,
34
+ onValueChange,
35
+ placeholder = 'Pick a date and time',
36
+ clearable = true,
37
+ disabled,
38
+ invalid,
39
+ name,
40
+ id,
41
+ calendarProps,
42
+ className,
43
+ ...props
44
+ }: DateTimePickerProps) {
45
+ const [open, setOpen] = React.useState(false);
46
+ const { locale } = useFormatSettings();
47
+ const selected = parseInstant(value);
48
+ const timeValue = selected
49
+ ? `${String(selected.getHours()).padStart(2, '0')}:${String(selected.getMinutes()).padStart(2, '0')}`
50
+ : '';
51
+
52
+ function commitDate(date: Date | undefined) {
53
+ if (!date) return;
54
+ const next = new Date(date);
55
+ next.setHours(selected?.getHours() ?? 0, selected?.getMinutes() ?? 0, 0, 0);
56
+ onValueChange?.(next.toISOString());
57
+ setOpen(false);
58
+ }
59
+
60
+ function commitTime(time: string) {
61
+ if (!time) return;
62
+ const [hoursText, minutesText] = time.split(':');
63
+ const hours = Number(hoursText);
64
+ const minutes = Number(minutesText);
65
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return;
66
+ const next = selected ? new Date(selected) : new Date();
67
+ next.setHours(hours, minutes, 0, 0);
68
+ onValueChange?.(next.toISOString());
69
+ }
70
+
71
+ return (
72
+ <div
73
+ data-slot="date-time-picker"
74
+ className={cn('flex min-w-0 items-center gap-2', className)}
75
+ {...props}
76
+ >
77
+ {name ? <input type="hidden" name={name} value={value ?? ''} /> : null}
78
+ <Popover open={open} onOpenChange={setOpen}>
79
+ <PopoverTrigger
80
+ render={
81
+ <Button
82
+ id={id}
83
+ type="button"
84
+ variant="outline"
85
+ disabled={disabled}
86
+ aria-invalid={invalid || undefined}
87
+ className="min-w-0 flex-1 justify-start font-normal"
88
+ />
89
+ }
90
+ >
91
+ <CalendarClockIcon />
92
+ <span className={cn('truncate', !selected && 'text-muted-foreground')}>
93
+ {selected ? formatDateLabel(selected, locale) : placeholder}
94
+ </span>
95
+ </PopoverTrigger>
96
+ <PopoverContent className="w-auto p-0" align="start">
97
+ <Calendar
98
+ {...calendarProps}
99
+ mode="single"
100
+ selected={selected}
101
+ defaultMonth={selected}
102
+ onSelect={commitDate}
103
+ />
104
+ </PopoverContent>
105
+ </Popover>
106
+ <Input
107
+ type="time"
108
+ aria-label="Time"
109
+ aria-invalid={invalid || undefined}
110
+ value={timeValue}
111
+ disabled={disabled || !selected}
112
+ className="w-28"
113
+ onChange={(event) => commitTime(event.currentTarget.value)}
114
+ />
115
+ {clearable && selected ? (
116
+ <Button
117
+ type="button"
118
+ variant="ghost"
119
+ size="icon-sm"
120
+ disabled={disabled}
121
+ aria-label="Clear date and time"
122
+ onClick={() => onValueChange?.(undefined)}
123
+ >
124
+ <XIcon />
125
+ </Button>
126
+ ) : null}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ export { DateTimePicker, type DateTimePickerProps };
@@ -0,0 +1,261 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@vendure-io/ui/components/atoms/button';
4
+ import { cn } from '@vendure-io/ui/lib/utils';
5
+ import { FileIcon, UploadCloudIcon, XIcon } from 'lucide-react';
6
+ import * as React from 'react';
7
+
8
+ type FileValidationResult = string | readonly string[] | null | undefined;
9
+ type FileValidator = (file: File) => FileValidationResult | Promise<FileValidationResult>;
10
+
11
+ interface FileRejection {
12
+ file: File;
13
+ messages: string[];
14
+ }
15
+
16
+ interface FileDropzoneProps extends Omit<React.ComponentProps<'div'>, 'onChange'> {
17
+ value?: readonly File[];
18
+ onValueChange?: (files: File[]) => void;
19
+ onRejected?: (rejections: FileRejection[]) => void;
20
+ accept?: string;
21
+ multiple?: boolean;
22
+ maxFiles?: number;
23
+ maxSize?: number;
24
+ validateFile?: FileValidator;
25
+ disabled?: boolean;
26
+ required?: boolean;
27
+ name?: string;
28
+ id?: string;
29
+ label?: React.ReactNode;
30
+ description?: React.ReactNode;
31
+ emptyLabel?: React.ReactNode;
32
+ dragLabel?: React.ReactNode;
33
+ renderFile?: (file: File, remove: () => void) => React.ReactNode;
34
+ }
35
+
36
+ function fileMatchesAccept(file: File, accept: string | undefined): boolean {
37
+ if (!accept?.trim()) return true;
38
+ const fileName = file.name.toLowerCase();
39
+ const mime = file.type.toLowerCase();
40
+ return accept.split(',').some((rawRule) => {
41
+ const rule = rawRule.trim().toLowerCase();
42
+ if (!rule) return false;
43
+ if (rule.startsWith('.')) return fileName.endsWith(rule);
44
+ if (rule.endsWith('/*')) return mime.startsWith(rule.slice(0, -1));
45
+ return mime === rule;
46
+ });
47
+ }
48
+
49
+ function formatFileSize(bytes: number): string {
50
+ if (bytes < 1024) return `${bytes} B`;
51
+ if (bytes < 1024 ** 2) return `${Math.round(bytes / 1024)} KB`;
52
+ return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
53
+ }
54
+
55
+ function normalizeValidationResult(result: FileValidationResult): string[] {
56
+ if (!result) return [];
57
+ return typeof result === 'string' ? [result] : [...result];
58
+ }
59
+
60
+ /**
61
+ * Controlled, transport-agnostic file input. It owns accessible picking,
62
+ * drag-and-drop, limits, validation, errors, and removal; consumers own upload
63
+ * persistence and can replace the selected-file rendering with `renderFile`.
64
+ */
65
+ function FileDropzone({
66
+ value = [],
67
+ onValueChange,
68
+ onRejected,
69
+ accept,
70
+ multiple = false,
71
+ maxFiles = multiple ? Number.POSITIVE_INFINITY : 1,
72
+ maxSize,
73
+ validateFile,
74
+ disabled,
75
+ required,
76
+ name,
77
+ id: providedId,
78
+ label = 'Upload files',
79
+ description,
80
+ emptyLabel = 'Drag and drop files here, or choose files',
81
+ dragLabel = 'Drop files to add them',
82
+ renderFile,
83
+ className,
84
+ ...props
85
+ }: FileDropzoneProps) {
86
+ const generatedId = React.useId();
87
+ const id = providedId ?? generatedId;
88
+ const descriptionId = `${id}-description`;
89
+ const errorId = `${id}-error`;
90
+ const [dragging, setDragging] = React.useState(false);
91
+ const [validating, setValidating] = React.useState(false);
92
+ const [rejections, setRejections] = React.useState<FileRejection[]>([]);
93
+ const dragDepth = React.useRef(0);
94
+
95
+ async function addFiles(incoming: readonly File[]) {
96
+ if (disabled || incoming.length === 0) return;
97
+ setValidating(true);
98
+ const accepted: File[] = [];
99
+ const rejected: FileRejection[] = [];
100
+ const remaining = Math.max(0, maxFiles - (multiple ? value.length : 0));
101
+
102
+ for (const file of incoming.slice(0, remaining)) {
103
+ const messages: string[] = [];
104
+ if (!fileMatchesAccept(file, accept)) messages.push('This file type is not accepted.');
105
+ if (maxSize !== undefined && file.size > maxSize) {
106
+ messages.push(`File size must be ${formatFileSize(maxSize)} or smaller.`);
107
+ }
108
+ if (validateFile) {
109
+ try {
110
+ messages.push(...normalizeValidationResult(await validateFile(file)));
111
+ } catch {
112
+ messages.push('This file could not be validated.');
113
+ }
114
+ }
115
+ if (messages.length > 0) rejected.push({ file, messages });
116
+ else accepted.push(file);
117
+ }
118
+
119
+ if (incoming.length > remaining) {
120
+ for (const file of incoming.slice(remaining)) {
121
+ rejected.push({ file, messages: [`You can select up to ${maxFiles} files.`] });
122
+ }
123
+ }
124
+
125
+ setRejections(rejected);
126
+ setValidating(false);
127
+ if (rejected.length > 0) onRejected?.(rejected);
128
+ if (accepted.length > 0) {
129
+ onValueChange?.(multiple ? [...value, ...accepted] : accepted.slice(0, 1));
130
+ }
131
+ }
132
+
133
+ function removeFile(index: number) {
134
+ onValueChange?.(value.filter((_, fileIndex) => fileIndex !== index));
135
+ setRejections([]);
136
+ }
137
+
138
+ return (
139
+ <div data-slot="file-dropzone" className={cn('flex flex-col gap-3', className)} {...props}>
140
+ <div>
141
+ <label htmlFor={id} className="text-sm font-medium">
142
+ {label}
143
+ {required ? <span className="text-destructive"> *</span> : null}
144
+ </label>
145
+ {description ? (
146
+ <p id={descriptionId} className="text-muted-foreground mt-1 text-sm">
147
+ {description}
148
+ </p>
149
+ ) : null}
150
+ </div>
151
+
152
+ <input
153
+ id={id}
154
+ name={name}
155
+ type="file"
156
+ accept={accept}
157
+ multiple={multiple}
158
+ required={required && value.length === 0}
159
+ disabled={disabled || validating}
160
+ aria-describedby={
161
+ cn(description && descriptionId, rejections.length > 0 && errorId) || undefined
162
+ }
163
+ className="peer sr-only"
164
+ onChange={(event) => {
165
+ const files = Array.from(event.currentTarget.files ?? []);
166
+ event.currentTarget.value = '';
167
+ void addFiles(files);
168
+ }}
169
+ />
170
+
171
+ <label
172
+ htmlFor={id}
173
+ data-slot="file-dropzone-target"
174
+ data-dragging={dragging || undefined}
175
+ data-disabled={disabled || validating || undefined}
176
+ onDragEnter={(event) => {
177
+ event.preventDefault();
178
+ if (disabled) return;
179
+ dragDepth.current += 1;
180
+ setDragging(true);
181
+ }}
182
+ onDragOver={(event) => {
183
+ event.preventDefault();
184
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
185
+ }}
186
+ onDragLeave={(event) => {
187
+ event.preventDefault();
188
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
189
+ if (dragDepth.current === 0) setDragging(false);
190
+ }}
191
+ onDrop={(event) => {
192
+ event.preventDefault();
193
+ dragDepth.current = 0;
194
+ setDragging(false);
195
+ void addFiles(Array.from(event.dataTransfer.files));
196
+ }}
197
+ className={cn(
198
+ 'border-border text-muted-foreground hover:border-foreground/40 flex min-h-32 cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-8 text-center transition-[border-color,background-color,color] duration-(--transition-duration-fast) ease-(--ease-out)',
199
+ 'peer-focus-visible:border-ring peer-focus-visible:ring-3 peer-focus-visible:ring-ring/50',
200
+ 'data-dragging:border-foreground data-dragging:bg-accent data-dragging:text-accent-foreground',
201
+ 'data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50',
202
+ )}
203
+ >
204
+ <UploadCloudIcon className="size-7" />
205
+ <span className="text-foreground text-sm font-medium">
206
+ {validating ? 'Checking files…' : dragging ? dragLabel : emptyLabel}
207
+ </span>
208
+ {accept ? <span className="text-xs">Accepted: {accept}</span> : null}
209
+ </label>
210
+
211
+ {value.length > 0 ? (
212
+ <ul data-slot="file-dropzone-files" className="flex flex-col gap-2">
213
+ {value.map((file, index) => (
214
+ <li key={`${file.name}-${file.size}-${file.lastModified}`}>
215
+ {renderFile ? (
216
+ renderFile(file, () => removeFile(index))
217
+ ) : (
218
+ <div className="bg-muted/60 flex min-w-0 items-center gap-3 rounded-lg border px-3 py-2">
219
+ <FileIcon className="text-muted-foreground size-4 shrink-0" />
220
+ <div className="min-w-0 flex-1">
221
+ <p className="truncate text-sm font-medium">{file.name}</p>
222
+ <p className="text-muted-foreground text-xs">{formatFileSize(file.size)}</p>
223
+ </div>
224
+ <Button
225
+ type="button"
226
+ variant="ghost"
227
+ size="icon-xs"
228
+ disabled={disabled}
229
+ aria-label={`Remove ${file.name}`}
230
+ onClick={() => removeFile(index)}
231
+ >
232
+ <XIcon />
233
+ </Button>
234
+ </div>
235
+ )}
236
+ </li>
237
+ ))}
238
+ </ul>
239
+ ) : null}
240
+
241
+ {rejections.length > 0 ? (
242
+ <div id={errorId} role="alert" className="text-destructive text-sm">
243
+ {rejections.map(({ file, messages }) => (
244
+ <p key={`${file.name}-${file.size}`}>
245
+ <span className="font-medium">{file.name}:</span> {messages.join(' ')}
246
+ </p>
247
+ ))}
248
+ </div>
249
+ ) : null}
250
+ </div>
251
+ );
252
+ }
253
+
254
+ export {
255
+ FileDropzone,
256
+ fileMatchesAccept,
257
+ formatFileSize,
258
+ type FileDropzoneProps,
259
+ type FileRejection,
260
+ type FileValidator,
261
+ };
@@ -40,7 +40,7 @@ interface IdChipProps {
40
40
  /** Render the copy affordance. @default true */
41
41
  copyable?: boolean;
42
42
  className?: string;
43
- /** Called after a successful copy. Wire your toast here — the DS never toasts. */
43
+ /** Called after a successful copy. Wire your toast here — the DS never toasts. Falls back to `CopyFeedbackProvider`. */
44
44
  onCopied?: () => void;
45
45
  }
46
46