@lerianstudio/sindarian-ui 2.0.0-beta.2 → 2.0.0-beta.3
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/dist/components/ui/file-upload/index.d.ts +159 -0
- package/dist/components/ui/file-upload/index.d.ts.map +1 -1
- package/dist/components/ui/file-upload/index.js +281 -1
- package/dist/esm/components/ui/file-upload/index.d.ts +159 -0
- package/dist/esm/components/ui/file-upload/index.d.ts.map +1 -1
- package/dist/esm/components/ui/file-upload/index.js +280 -0
- package/package.json +1 -1
|
@@ -158,4 +158,163 @@ export declare const FileUpload: React.ForwardRefExoticComponent<{
|
|
|
158
158
|
'aria-describedby'?: string;
|
|
159
159
|
'aria-label'?: string;
|
|
160
160
|
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "className" | "disabled" | "type" | "value" | "onChange" | "onError" | "onSelect" | "accept" | "multiple"> & React.RefAttributes<HTMLInputElement>>;
|
|
161
|
+
/**
|
|
162
|
+
* A rejection from `MultipleFileUpload`: every rejection the single-file
|
|
163
|
+
* sibling can produce, plus the one only a plural selection has — the cap.
|
|
164
|
+
*/
|
|
165
|
+
export type MultipleFileUploadError = FileUploadError | {
|
|
166
|
+
kind: 'too-many';
|
|
167
|
+
file: File;
|
|
168
|
+
maxFiles: number;
|
|
169
|
+
};
|
|
170
|
+
/** Overrides for the fixed English copy. Every field is optional. */
|
|
171
|
+
export interface MultipleFileUploadLabels {
|
|
172
|
+
/** Emphasised call to action in the empty zone. Defaults to "Choose files". */
|
|
173
|
+
action?: string;
|
|
174
|
+
/** Trailing hint after the action. Defaults to "or drag and drop". */
|
|
175
|
+
hint?: string;
|
|
176
|
+
/** Zone copy once `maxFiles` is reached. Receives the cap to interpolate. */
|
|
177
|
+
full?: (maxFiles: number) => string;
|
|
178
|
+
/**
|
|
179
|
+
* Accessible name of a row's remove control. Receives that row's file, so the
|
|
180
|
+
* name identifies it. Defaults to `Remove <filename>`.
|
|
181
|
+
*/
|
|
182
|
+
remove?: (file: File) => string;
|
|
183
|
+
/**
|
|
184
|
+
* Copy for one rejection. Called once per rejection in a batch; use the
|
|
185
|
+
* exported `humanizeSize` to format `maxSizeBytes`. Return `null`,
|
|
186
|
+
* `undefined` or `''` to stay SILENT and leave the announcement to the host's
|
|
187
|
+
* own `onError` handling. A batch whose every message is silent renders no
|
|
188
|
+
* alert at all.
|
|
189
|
+
*/
|
|
190
|
+
error?: (error: MultipleFileUploadError) => string | null | undefined;
|
|
191
|
+
}
|
|
192
|
+
export type MultipleFileUploadProps = {
|
|
193
|
+
/** Comma-separated accept filter. Applied to every file, extension or MIME. */
|
|
194
|
+
accept?: string;
|
|
195
|
+
/** Inclusive per-file byte ceiling. Applied to each file independently. */
|
|
196
|
+
maxSizeBytes?: number;
|
|
197
|
+
/** Ceiling on the TOTAL selection. Omitted means unbounded. */
|
|
198
|
+
maxFiles?: number;
|
|
199
|
+
/** Controlled selection. The host owns state; defaults to empty. */
|
|
200
|
+
value?: FileUploadResult[];
|
|
201
|
+
/**
|
|
202
|
+
* How each accepted file is handed over. `'text'` (default) decodes UTF-8
|
|
203
|
+
* and populates `text`; `'none'` skips decoding entirely and is the mode
|
|
204
|
+
* BINARY files need. See FileUpload's `readAs` for why.
|
|
205
|
+
*/
|
|
206
|
+
readAs?: FileUploadReadAs;
|
|
207
|
+
/** Override the fixed English copy. Omitted fields keep their defaults. */
|
|
208
|
+
labels?: MultipleFileUploadLabels;
|
|
209
|
+
/** Fires with the WHOLE next selection whenever files are added or removed. */
|
|
210
|
+
onValueChange: (values: FileUploadResult[]) => void;
|
|
211
|
+
/** Fires once per rejected file. A batch can produce several. */
|
|
212
|
+
onError?: (error: MultipleFileUploadError) => void;
|
|
213
|
+
disabled?: boolean;
|
|
214
|
+
id?: string;
|
|
215
|
+
className?: string;
|
|
216
|
+
'aria-invalid'?: boolean;
|
|
217
|
+
'aria-required'?: boolean;
|
|
218
|
+
'aria-describedby'?: string;
|
|
219
|
+
'aria-label'?: string;
|
|
220
|
+
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'accept' | 'value' | 'disabled' | 'onChange' | 'onSelect' | 'className' | 'multiple' | 'onError'>;
|
|
221
|
+
/**
|
|
222
|
+
* MultipleFileUpload: the plural sibling of FileUpload. Pick SEVERAL files,
|
|
223
|
+
* accumulate them across repeated picks, validate each one, cap the total, and
|
|
224
|
+
* hand back `FileUploadResult[]`.
|
|
225
|
+
*
|
|
226
|
+
* A SIBLING COMPONENT, not a `multiple` flag. This library already answers
|
|
227
|
+
* "this one takes many" that way (`Select` / `MultipleSelect`), and FileUpload
|
|
228
|
+
* strips `'multiple'` from its props on purpose: single-file is its contract,
|
|
229
|
+
* not a default it happens to have. A boolean would have forced every prop
|
|
230
|
+
* here into a union that means one thing when the flag is set and another when
|
|
231
|
+
* it is not: `value` as `Result | Result[] | null`, a `maxFiles` that is
|
|
232
|
+
* meaningless in half the configurations, and a remove control whose
|
|
233
|
+
* accessible name is fixed copy in one mode and per-file in the other. The
|
|
234
|
+
* plural props follow the house shape for plural components: `value?: T[]`
|
|
235
|
+
* with `onValueChange?: (values: T[]) => void`.
|
|
236
|
+
*
|
|
237
|
+
* WHERE THE LINE SITS: this component owns SELECTION and nothing after it.
|
|
238
|
+
* Choosing, validating, capping, listing and removing are its job; uploading
|
|
239
|
+
* is not. That split is not squeamishness about scope, it is where the
|
|
240
|
+
* knowledge actually lives. An upload needs an endpoint, an auth scheme, a
|
|
241
|
+
* concurrency policy, a retry policy and, very often, a parent id that does
|
|
242
|
+
* not exist yet when the files are chosen: the motivating host stages evidence
|
|
243
|
+
* files while a form is being filled and can only upload them against the id
|
|
244
|
+
* that its create call returns afterwards. None of that is knowable from
|
|
245
|
+
* inside a library primitive, and a component that guessed would have to be
|
|
246
|
+
* fought rather than used. So the host keeps its own per-file record with
|
|
247
|
+
* status and retry, and this component keeps the part a form can hold and
|
|
248
|
+
* validate: the chosen files. `FileUploadResult[]` is a value; an upload state
|
|
249
|
+
* machine is not.
|
|
250
|
+
*
|
|
251
|
+
* ACCUMULATION is the defining behaviour. A second pick ADDS to the selection
|
|
252
|
+
* rather than replacing it, because a user assembling five documents does it
|
|
253
|
+
* in two or three trips to the file dialog, not one. Everything else follows
|
|
254
|
+
* from that: room is measured against what is already selected, and the batch
|
|
255
|
+
* that overflows the cap still contributes the files that fit.
|
|
256
|
+
*
|
|
257
|
+
* A BATCH SURVIVES ITS OWN CASUALTIES. One file rejected for type, size or a
|
|
258
|
+
* failed read does not discard the rest of the batch, and it does not consume
|
|
259
|
+
* a slot either: validation runs over the WHOLE batch before the cap is
|
|
260
|
+
* applied, so a file that was never eligible cannot cost an eligible one its
|
|
261
|
+
* place, and `'too-many'` always names a file a slot would genuinely have
|
|
262
|
+
* taken. The alternative punishes
|
|
263
|
+
* a user for a mistake in one file by throwing away four good ones, and hands
|
|
264
|
+
* back no way to tell which was which. Every rejection is reported through
|
|
265
|
+
* `onError` and announced together in one `role="alert"`.
|
|
266
|
+
*
|
|
267
|
+
* Accessibility follows the sibling BELOW THE CAP: the real `<input
|
|
268
|
+
* type="file">` is `sr-only` but focusable and labelable, and never
|
|
269
|
+
* `aria-hidden`, so FormControl-injected ARIA and react-hook-form's focus on
|
|
270
|
+
* error both work while the picker is enabled. At the cap that changes, and
|
|
271
|
+
* the paragraph below says how.
|
|
272
|
+
* The file list sits OUTSIDE the click zone, so activating a remove control
|
|
273
|
+
* cannot also reopen the picker, and each remove control is named after its
|
|
274
|
+
* own file: a column of identical "Remove file" buttons tells a screen-reader
|
|
275
|
+
* user nothing about which row they are on.
|
|
276
|
+
*
|
|
277
|
+
* AT THE CAP the picker takes the native `disabled` attribute, and that DOES
|
|
278
|
+
* take it out of the tab order. This is the one state in which the input is
|
|
279
|
+
* not a focus target, and the one state in which focus-on-error cannot land on
|
|
280
|
+
* it, so it is a real cost rather than a free win. The cap has no counterpart
|
|
281
|
+
* in the single-file sibling, so the precedent followed here is
|
|
282
|
+
* `DateRangePicker`'s trigger: a control whose only job is to open a dialog has
|
|
283
|
+
* no state worth keeping focusable, and native `disabled` is what both removes
|
|
284
|
+
* it from the tab order and keeps the dialog shut. The alternative, an enabled
|
|
285
|
+
* picker that opens the file dialog and then refuses every file with
|
|
286
|
+
* `too-many`, is a control that lies about being available. What keeps the cap
|
|
287
|
+
* from being a dead end is the escape hatch: the remove controls answer to
|
|
288
|
+
* `disabled` alone and NEVER to the cap, so they stay focusable and removing
|
|
289
|
+
* one file reopens the picker.
|
|
290
|
+
*/
|
|
291
|
+
export declare const MultipleFileUpload: React.ForwardRefExoticComponent<{
|
|
292
|
+
/** Comma-separated accept filter. Applied to every file, extension or MIME. */
|
|
293
|
+
accept?: string;
|
|
294
|
+
/** Inclusive per-file byte ceiling. Applied to each file independently. */
|
|
295
|
+
maxSizeBytes?: number;
|
|
296
|
+
/** Ceiling on the TOTAL selection. Omitted means unbounded. */
|
|
297
|
+
maxFiles?: number;
|
|
298
|
+
/** Controlled selection. The host owns state; defaults to empty. */
|
|
299
|
+
value?: FileUploadResult[];
|
|
300
|
+
/**
|
|
301
|
+
* How each accepted file is handed over. `'text'` (default) decodes UTF-8
|
|
302
|
+
* and populates `text`; `'none'` skips decoding entirely and is the mode
|
|
303
|
+
* BINARY files need. See FileUpload's `readAs` for why.
|
|
304
|
+
*/
|
|
305
|
+
readAs?: FileUploadReadAs;
|
|
306
|
+
/** Override the fixed English copy. Omitted fields keep their defaults. */
|
|
307
|
+
labels?: MultipleFileUploadLabels;
|
|
308
|
+
/** Fires with the WHOLE next selection whenever files are added or removed. */
|
|
309
|
+
onValueChange: (values: FileUploadResult[]) => void;
|
|
310
|
+
/** Fires once per rejected file. A batch can produce several. */
|
|
311
|
+
onError?: (error: MultipleFileUploadError) => void;
|
|
312
|
+
disabled?: boolean;
|
|
313
|
+
id?: string;
|
|
314
|
+
className?: string;
|
|
315
|
+
'aria-invalid'?: boolean;
|
|
316
|
+
'aria-required'?: boolean;
|
|
317
|
+
'aria-describedby'?: string;
|
|
318
|
+
'aria-label'?: string;
|
|
319
|
+
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "className" | "disabled" | "type" | "value" | "onChange" | "onError" | "onSelect" | "accept" | "multiple"> & React.RefAttributes<HTMLInputElement>>;
|
|
161
320
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/file-upload/index.tsx"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/file-upload/index.tsx"],"names":[],"mappings":"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"}
|
|
@@ -34,7 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
};
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
-
exports.FileUpload = void 0;
|
|
37
|
+
exports.MultipleFileUpload = exports.FileUpload = void 0;
|
|
38
38
|
exports.validateFile = validateFile;
|
|
39
39
|
exports.humanizeSize = humanizeSize;
|
|
40
40
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
@@ -270,3 +270,283 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
|
|
|
270
270
|
}, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4", "aria-hidden": "true" }) })] })) : ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose a file' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), errorText ? ((0, jsx_runtime_1.jsx)("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorText })) : null] }));
|
|
271
271
|
});
|
|
272
272
|
exports.FileUpload.displayName = 'FileUpload';
|
|
273
|
+
/** The default English announcement for each rejection kind. */
|
|
274
|
+
function defaultMultipleErrorMessage(error) {
|
|
275
|
+
if (error.kind === 'too-many') {
|
|
276
|
+
return `Too many files (max ${error.maxFiles}). ${error.file.name} was not added.`;
|
|
277
|
+
}
|
|
278
|
+
return defaultErrorMessage(error);
|
|
279
|
+
}
|
|
280
|
+
/** The default zone copy once the cap is reached, in grammatical English. */
|
|
281
|
+
function defaultFullMessage(maxFiles) {
|
|
282
|
+
return maxFiles === 1
|
|
283
|
+
? 'Maximum of 1 file reached.'
|
|
284
|
+
: `Maximum of ${maxFiles} files reached.`;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* MultipleFileUpload: the plural sibling of FileUpload. Pick SEVERAL files,
|
|
288
|
+
* accumulate them across repeated picks, validate each one, cap the total, and
|
|
289
|
+
* hand back `FileUploadResult[]`.
|
|
290
|
+
*
|
|
291
|
+
* A SIBLING COMPONENT, not a `multiple` flag. This library already answers
|
|
292
|
+
* "this one takes many" that way (`Select` / `MultipleSelect`), and FileUpload
|
|
293
|
+
* strips `'multiple'` from its props on purpose: single-file is its contract,
|
|
294
|
+
* not a default it happens to have. A boolean would have forced every prop
|
|
295
|
+
* here into a union that means one thing when the flag is set and another when
|
|
296
|
+
* it is not: `value` as `Result | Result[] | null`, a `maxFiles` that is
|
|
297
|
+
* meaningless in half the configurations, and a remove control whose
|
|
298
|
+
* accessible name is fixed copy in one mode and per-file in the other. The
|
|
299
|
+
* plural props follow the house shape for plural components: `value?: T[]`
|
|
300
|
+
* with `onValueChange?: (values: T[]) => void`.
|
|
301
|
+
*
|
|
302
|
+
* WHERE THE LINE SITS: this component owns SELECTION and nothing after it.
|
|
303
|
+
* Choosing, validating, capping, listing and removing are its job; uploading
|
|
304
|
+
* is not. That split is not squeamishness about scope, it is where the
|
|
305
|
+
* knowledge actually lives. An upload needs an endpoint, an auth scheme, a
|
|
306
|
+
* concurrency policy, a retry policy and, very often, a parent id that does
|
|
307
|
+
* not exist yet when the files are chosen: the motivating host stages evidence
|
|
308
|
+
* files while a form is being filled and can only upload them against the id
|
|
309
|
+
* that its create call returns afterwards. None of that is knowable from
|
|
310
|
+
* inside a library primitive, and a component that guessed would have to be
|
|
311
|
+
* fought rather than used. So the host keeps its own per-file record with
|
|
312
|
+
* status and retry, and this component keeps the part a form can hold and
|
|
313
|
+
* validate: the chosen files. `FileUploadResult[]` is a value; an upload state
|
|
314
|
+
* machine is not.
|
|
315
|
+
*
|
|
316
|
+
* ACCUMULATION is the defining behaviour. A second pick ADDS to the selection
|
|
317
|
+
* rather than replacing it, because a user assembling five documents does it
|
|
318
|
+
* in two or three trips to the file dialog, not one. Everything else follows
|
|
319
|
+
* from that: room is measured against what is already selected, and the batch
|
|
320
|
+
* that overflows the cap still contributes the files that fit.
|
|
321
|
+
*
|
|
322
|
+
* A BATCH SURVIVES ITS OWN CASUALTIES. One file rejected for type, size or a
|
|
323
|
+
* failed read does not discard the rest of the batch, and it does not consume
|
|
324
|
+
* a slot either: validation runs over the WHOLE batch before the cap is
|
|
325
|
+
* applied, so a file that was never eligible cannot cost an eligible one its
|
|
326
|
+
* place, and `'too-many'` always names a file a slot would genuinely have
|
|
327
|
+
* taken. The alternative punishes
|
|
328
|
+
* a user for a mistake in one file by throwing away four good ones, and hands
|
|
329
|
+
* back no way to tell which was which. Every rejection is reported through
|
|
330
|
+
* `onError` and announced together in one `role="alert"`.
|
|
331
|
+
*
|
|
332
|
+
* Accessibility follows the sibling BELOW THE CAP: the real `<input
|
|
333
|
+
* type="file">` is `sr-only` but focusable and labelable, and never
|
|
334
|
+
* `aria-hidden`, so FormControl-injected ARIA and react-hook-form's focus on
|
|
335
|
+
* error both work while the picker is enabled. At the cap that changes, and
|
|
336
|
+
* the paragraph below says how.
|
|
337
|
+
* The file list sits OUTSIDE the click zone, so activating a remove control
|
|
338
|
+
* cannot also reopen the picker, and each remove control is named after its
|
|
339
|
+
* own file: a column of identical "Remove file" buttons tells a screen-reader
|
|
340
|
+
* user nothing about which row they are on.
|
|
341
|
+
*
|
|
342
|
+
* AT THE CAP the picker takes the native `disabled` attribute, and that DOES
|
|
343
|
+
* take it out of the tab order. This is the one state in which the input is
|
|
344
|
+
* not a focus target, and the one state in which focus-on-error cannot land on
|
|
345
|
+
* it, so it is a real cost rather than a free win. The cap has no counterpart
|
|
346
|
+
* in the single-file sibling, so the precedent followed here is
|
|
347
|
+
* `DateRangePicker`'s trigger: a control whose only job is to open a dialog has
|
|
348
|
+
* no state worth keeping focusable, and native `disabled` is what both removes
|
|
349
|
+
* it from the tab order and keeps the dialog shut. The alternative, an enabled
|
|
350
|
+
* picker that opens the file dialog and then refuses every file with
|
|
351
|
+
* `too-many`, is a control that lies about being available. What keeps the cap
|
|
352
|
+
* from being a dead end is the escape hatch: the remove controls answer to
|
|
353
|
+
* `disabled` alone and NEVER to the cap, so they stay focusable and removing
|
|
354
|
+
* one file reopens the picker.
|
|
355
|
+
*/
|
|
356
|
+
exports.MultipleFileUpload = React.forwardRef(function MultipleFileUpload({ accept, maxSizeBytes, maxFiles, value = [], readAs = 'text', labels, onValueChange, onError, disabled = false, id, className, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, ...rest }, ref) {
|
|
357
|
+
const internalRef = React.useRef(null);
|
|
358
|
+
React.useImperativeHandle(ref, () => internalRef.current);
|
|
359
|
+
const reactId = React.useId();
|
|
360
|
+
const inputId = id ?? reactId;
|
|
361
|
+
const errorId = `${inputId}-file-upload-error`;
|
|
362
|
+
const [dragActive, setDragActive] = React.useState(false);
|
|
363
|
+
const [errors, setErrors] = React.useState([]);
|
|
364
|
+
// The authoritative base for the next append. Props win on every render, so
|
|
365
|
+
// the host stays in control; the commit below also writes through, so a
|
|
366
|
+
// second batch that settles before the host has re-rendered still appends to
|
|
367
|
+
// the first batch's result instead of overwriting it.
|
|
368
|
+
const valueRef = React.useRef(value);
|
|
369
|
+
React.useEffect(() => {
|
|
370
|
+
valueRef.current = value;
|
|
371
|
+
});
|
|
372
|
+
// Superseding is NOT the contract here the way it is in the single-file
|
|
373
|
+
// sibling — batches accumulate, so an in-flight read is never stale. The
|
|
374
|
+
// readers are tracked for the one case that does have to stop them: an
|
|
375
|
+
// unmount. A read that lands afterwards would settle its batch and commit,
|
|
376
|
+
// calling the host's `onValueChange` for a component that no longer exists.
|
|
377
|
+
const readersRef = React.useRef(new Set());
|
|
378
|
+
React.useEffect(() => () => {
|
|
379
|
+
for (const reader of readersRef.current)
|
|
380
|
+
reader.abort();
|
|
381
|
+
readersRef.current.clear();
|
|
382
|
+
}, []);
|
|
383
|
+
const invalid = ariaInvalid || errors.length > 0;
|
|
384
|
+
// Resolve the announcements BEFORE deciding whether the alert exists: a
|
|
385
|
+
// consumer that returns nothing for every rejection is opting out of this
|
|
386
|
+
// surface, and an association pointing at an unrendered node is worse than
|
|
387
|
+
// none.
|
|
388
|
+
const messages = errors
|
|
389
|
+
.map((failure) => labels?.error
|
|
390
|
+
? labels.error(failure)
|
|
391
|
+
: defaultMultipleErrorMessage(failure))
|
|
392
|
+
.filter((message) => Boolean(message));
|
|
393
|
+
// A plain join, never `cn`: tailwind-merge treats these as class names and
|
|
394
|
+
// would drop an id that happens to look like a conflicting utility.
|
|
395
|
+
const describedBy = [ariaDescribedby, messages.length > 0 ? errorId : undefined]
|
|
396
|
+
.filter(Boolean)
|
|
397
|
+
.join(' ') || undefined;
|
|
398
|
+
const full = maxFiles !== undefined && value.length >= maxFiles;
|
|
399
|
+
// Removing must stay possible at the cap, so only the PICKER closes.
|
|
400
|
+
const pickerDisabled = disabled || full;
|
|
401
|
+
const roomFor = (selected) => maxFiles === undefined
|
|
402
|
+
? Number.POSITIVE_INFINITY
|
|
403
|
+
: Math.max(maxFiles - selected, 0);
|
|
404
|
+
// The cap is enforced HERE, at the only place that appends, because only the
|
|
405
|
+
// commit knows the base it lands on. `ingest` measures room too, but for an
|
|
406
|
+
// asynchronous batch it measures it BEFORE any read settles: two overlapping
|
|
407
|
+
// batches both see the pre-commit selection and would each believe they fit.
|
|
408
|
+
const commit = (accepted, rejections) => {
|
|
409
|
+
const room = roomFor(valueRef.current.length);
|
|
410
|
+
const fitting = accepted.slice(0, room);
|
|
411
|
+
const overflow = accepted[room];
|
|
412
|
+
const failures = overflow !== undefined && maxFiles !== undefined
|
|
413
|
+
? [
|
|
414
|
+
...rejections,
|
|
415
|
+
{ kind: 'too-many', file: overflow.file, maxFiles }
|
|
416
|
+
]
|
|
417
|
+
: rejections;
|
|
418
|
+
setErrors(failures);
|
|
419
|
+
for (const rejection of failures)
|
|
420
|
+
onError?.(rejection);
|
|
421
|
+
if (fitting.length === 0)
|
|
422
|
+
return;
|
|
423
|
+
const next = [...valueRef.current, ...fitting];
|
|
424
|
+
valueRef.current = next;
|
|
425
|
+
onValueChange(next);
|
|
426
|
+
};
|
|
427
|
+
const ingest = (incoming) => {
|
|
428
|
+
if (incoming.length === 0)
|
|
429
|
+
return;
|
|
430
|
+
const room = roomFor(valueRef.current.length);
|
|
431
|
+
const rejections = [];
|
|
432
|
+
// Validate EVERY file BEFORE the cap is applied. Slicing to the remaining
|
|
433
|
+
// room first would let a file that was never eligible consume a slot a
|
|
434
|
+
// good file could have used — one bad pick costing a good one, which is
|
|
435
|
+
// the opposite of a batch surviving its own casualties — and it would
|
|
436
|
+
// leave every file past the slice window neither validated nor reported.
|
|
437
|
+
// Validation is pure metadata (size and accept), so running it over files
|
|
438
|
+
// that may not fit costs nothing.
|
|
439
|
+
const eligible = [];
|
|
440
|
+
for (const file of incoming) {
|
|
441
|
+
const rejection = validateFile(file, { accept, maxSizeBytes });
|
|
442
|
+
// Continue rather than abort: one bad file must not cost the good ones.
|
|
443
|
+
if (rejection) {
|
|
444
|
+
rejections.push(rejection);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
eligible.push(file);
|
|
448
|
+
}
|
|
449
|
+
// The cap then applies to the SURVIVORS. One rejection for the batch,
|
|
450
|
+
// naming the FIRST eligible file that did not fit: naming one already
|
|
451
|
+
// refused for its size or type would blame the cap for the wrong thing,
|
|
452
|
+
// and repeating it per overflowing file buries the actionable part.
|
|
453
|
+
if (eligible.length > room && maxFiles !== undefined) {
|
|
454
|
+
rejections.push({ kind: 'too-many', file: eligible[room], maxFiles });
|
|
455
|
+
}
|
|
456
|
+
const candidates = eligible.slice(0, room);
|
|
457
|
+
// Binary path: no decode, no reader, no retained garbage string.
|
|
458
|
+
if (readAs === 'none') {
|
|
459
|
+
commit(candidates.map((file) => ({ file, text: '' })), rejections);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (candidates.length === 0) {
|
|
463
|
+
commit([], rejections);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// Slot-per-candidate so the emitted batch keeps PICK order regardless of
|
|
467
|
+
// the order the reads settle in. A null slot is a read that failed; the
|
|
468
|
+
// batch commits once every read has settled, one way or the other.
|
|
469
|
+
const slots = new Array(candidates.length).fill(null);
|
|
470
|
+
let remaining = candidates.length;
|
|
471
|
+
const settle = () => {
|
|
472
|
+
remaining -= 1;
|
|
473
|
+
if (remaining > 0)
|
|
474
|
+
return;
|
|
475
|
+
const accepted = [];
|
|
476
|
+
const readFailures = [];
|
|
477
|
+
slots.forEach((slot, index) => {
|
|
478
|
+
if (slot)
|
|
479
|
+
accepted.push(slot);
|
|
480
|
+
else
|
|
481
|
+
readFailures.push({ kind: 'read-failed', file: candidates[index] });
|
|
482
|
+
});
|
|
483
|
+
commit(accepted, [...rejections, ...readFailures]);
|
|
484
|
+
};
|
|
485
|
+
candidates.forEach((file, index) => {
|
|
486
|
+
const reader = new FileReader();
|
|
487
|
+
readersRef.current.add(reader);
|
|
488
|
+
// Deliberately no `onabort` handler: an aborted read must NOT settle,
|
|
489
|
+
// or the batch would commit at exactly the moment we are stopping it.
|
|
490
|
+
reader.onload = () => {
|
|
491
|
+
readersRef.current.delete(reader);
|
|
492
|
+
slots[index] = { file, text: String(reader.result ?? '') };
|
|
493
|
+
settle();
|
|
494
|
+
};
|
|
495
|
+
reader.onerror = () => {
|
|
496
|
+
readersRef.current.delete(reader);
|
|
497
|
+
settle();
|
|
498
|
+
};
|
|
499
|
+
reader.readAsText(file);
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
const onInputChange = (event) => {
|
|
503
|
+
const files = Array.from(event.target.files ?? []);
|
|
504
|
+
// Release the FileList the moment it has been read, or re-picking the same
|
|
505
|
+
// file is silently a no-op: the browser fires `change` only when the
|
|
506
|
+
// selection DIFFERS from what the input already holds.
|
|
507
|
+
event.target.value = '';
|
|
508
|
+
ingest(files);
|
|
509
|
+
};
|
|
510
|
+
const onDragOver = (event) => {
|
|
511
|
+
if (disabled)
|
|
512
|
+
return;
|
|
513
|
+
event.preventDefault();
|
|
514
|
+
setDragActive(true);
|
|
515
|
+
};
|
|
516
|
+
const onDragLeave = (event) => {
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
setDragActive(false);
|
|
519
|
+
};
|
|
520
|
+
const onDrop = (event) => {
|
|
521
|
+
if (disabled)
|
|
522
|
+
return;
|
|
523
|
+
event.preventDefault();
|
|
524
|
+
setDragActive(false);
|
|
525
|
+
// Deliberately NOT gated on `full`: a drop onto a full zone is answered
|
|
526
|
+
// with the too-many rejection, which says why, instead of nothing at all.
|
|
527
|
+
ingest(Array.from(event.dataTransfer.files ?? []));
|
|
528
|
+
};
|
|
529
|
+
// Mouse convenience only. A click that ORIGINATED on the input already opens
|
|
530
|
+
// the picker natively and bubbles up here, so ignore it or it opens twice.
|
|
531
|
+
const openPicker = (event) => {
|
|
532
|
+
if (pickerDisabled || event.target === internalRef.current)
|
|
533
|
+
return;
|
|
534
|
+
internalRef.current?.click();
|
|
535
|
+
};
|
|
536
|
+
const removeAt = (index) => {
|
|
537
|
+
// By index, not by name: two files can share a filename and identity is
|
|
538
|
+
// what the row actually stands for.
|
|
539
|
+
const next = valueRef.current.filter((_, position) => position !== index);
|
|
540
|
+
valueRef.current = next;
|
|
541
|
+
onValueChange(next);
|
|
542
|
+
};
|
|
543
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)('space-y-2', className), children: [(0, jsx_runtime_1.jsxs)("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: (0, utils_1.cn)('border-input bg-card focus-within:ring-ring focus-within:ring-offset-background aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-within:ring-destructive flex w-full items-center gap-3 rounded-md border px-3 py-4 text-sm shadow-xs transition-colors focus-within:ring-2 focus-within:ring-offset-1 focus-within:outline-none', dragActive &&
|
|
544
|
+
'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', pickerDisabled
|
|
545
|
+
? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
|
|
546
|
+
: 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [(0, jsx_runtime_1.jsx)("input", { ref: internalRef, id: inputId, type: "file", multiple: true, accept: accept, disabled: pickerDisabled, className: "sr-only", "aria-invalid": invalid || undefined, "aria-required": ariaRequired || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, onChange: onInputChange, ...rest }), (0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), full && maxFiles !== undefined ? ((0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: labels?.full
|
|
547
|
+
? labels.full(maxFiles)
|
|
548
|
+
: defaultFullMessage(maxFiles) })) : ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose files' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), value.length > 0 ? ((0, jsx_runtime_1.jsx)("ul", { className: "space-y-1", children: value.map((entry, index) => ((0, jsx_runtime_1.jsxs)("li", { className: "flex items-center gap-3 text-sm", children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 flex-1 truncate", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: entry.file.name }), ' ', (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(entry.file.size) })] }), (0, jsx_runtime_1.jsx)(icon_button_1.IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove
|
|
549
|
+
? labels.remove(entry.file)
|
|
550
|
+
: `Remove ${entry.file.name}`, onClick: () => removeAt(index), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4", "aria-hidden": "true" }) })] }, `${entry.file.name}-${index}`))) })) : null, messages.length > 0 ? ((0, jsx_runtime_1.jsx)("div", { id: errorId, role: "alert", className: "space-y-1", children: messages.map((message, index) => ((0, jsx_runtime_1.jsx)("p", { className: "text-system-error-h1a text-xs font-medium", children: message }, index))) })) : null] }));
|
|
551
|
+
});
|
|
552
|
+
exports.MultipleFileUpload.displayName = 'MultipleFileUpload';
|
|
@@ -158,4 +158,163 @@ export declare const FileUpload: React.ForwardRefExoticComponent<{
|
|
|
158
158
|
'aria-describedby'?: string;
|
|
159
159
|
'aria-label'?: string;
|
|
160
160
|
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "className" | "disabled" | "type" | "value" | "onChange" | "onError" | "onSelect" | "accept" | "multiple"> & React.RefAttributes<HTMLInputElement>>;
|
|
161
|
+
/**
|
|
162
|
+
* A rejection from `MultipleFileUpload`: every rejection the single-file
|
|
163
|
+
* sibling can produce, plus the one only a plural selection has — the cap.
|
|
164
|
+
*/
|
|
165
|
+
export type MultipleFileUploadError = FileUploadError | {
|
|
166
|
+
kind: 'too-many';
|
|
167
|
+
file: File;
|
|
168
|
+
maxFiles: number;
|
|
169
|
+
};
|
|
170
|
+
/** Overrides for the fixed English copy. Every field is optional. */
|
|
171
|
+
export interface MultipleFileUploadLabels {
|
|
172
|
+
/** Emphasised call to action in the empty zone. Defaults to "Choose files". */
|
|
173
|
+
action?: string;
|
|
174
|
+
/** Trailing hint after the action. Defaults to "or drag and drop". */
|
|
175
|
+
hint?: string;
|
|
176
|
+
/** Zone copy once `maxFiles` is reached. Receives the cap to interpolate. */
|
|
177
|
+
full?: (maxFiles: number) => string;
|
|
178
|
+
/**
|
|
179
|
+
* Accessible name of a row's remove control. Receives that row's file, so the
|
|
180
|
+
* name identifies it. Defaults to `Remove <filename>`.
|
|
181
|
+
*/
|
|
182
|
+
remove?: (file: File) => string;
|
|
183
|
+
/**
|
|
184
|
+
* Copy for one rejection. Called once per rejection in a batch; use the
|
|
185
|
+
* exported `humanizeSize` to format `maxSizeBytes`. Return `null`,
|
|
186
|
+
* `undefined` or `''` to stay SILENT and leave the announcement to the host's
|
|
187
|
+
* own `onError` handling. A batch whose every message is silent renders no
|
|
188
|
+
* alert at all.
|
|
189
|
+
*/
|
|
190
|
+
error?: (error: MultipleFileUploadError) => string | null | undefined;
|
|
191
|
+
}
|
|
192
|
+
export type MultipleFileUploadProps = {
|
|
193
|
+
/** Comma-separated accept filter. Applied to every file, extension or MIME. */
|
|
194
|
+
accept?: string;
|
|
195
|
+
/** Inclusive per-file byte ceiling. Applied to each file independently. */
|
|
196
|
+
maxSizeBytes?: number;
|
|
197
|
+
/** Ceiling on the TOTAL selection. Omitted means unbounded. */
|
|
198
|
+
maxFiles?: number;
|
|
199
|
+
/** Controlled selection. The host owns state; defaults to empty. */
|
|
200
|
+
value?: FileUploadResult[];
|
|
201
|
+
/**
|
|
202
|
+
* How each accepted file is handed over. `'text'` (default) decodes UTF-8
|
|
203
|
+
* and populates `text`; `'none'` skips decoding entirely and is the mode
|
|
204
|
+
* BINARY files need. See FileUpload's `readAs` for why.
|
|
205
|
+
*/
|
|
206
|
+
readAs?: FileUploadReadAs;
|
|
207
|
+
/** Override the fixed English copy. Omitted fields keep their defaults. */
|
|
208
|
+
labels?: MultipleFileUploadLabels;
|
|
209
|
+
/** Fires with the WHOLE next selection whenever files are added or removed. */
|
|
210
|
+
onValueChange: (values: FileUploadResult[]) => void;
|
|
211
|
+
/** Fires once per rejected file. A batch can produce several. */
|
|
212
|
+
onError?: (error: MultipleFileUploadError) => void;
|
|
213
|
+
disabled?: boolean;
|
|
214
|
+
id?: string;
|
|
215
|
+
className?: string;
|
|
216
|
+
'aria-invalid'?: boolean;
|
|
217
|
+
'aria-required'?: boolean;
|
|
218
|
+
'aria-describedby'?: string;
|
|
219
|
+
'aria-label'?: string;
|
|
220
|
+
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'accept' | 'value' | 'disabled' | 'onChange' | 'onSelect' | 'className' | 'multiple' | 'onError'>;
|
|
221
|
+
/**
|
|
222
|
+
* MultipleFileUpload: the plural sibling of FileUpload. Pick SEVERAL files,
|
|
223
|
+
* accumulate them across repeated picks, validate each one, cap the total, and
|
|
224
|
+
* hand back `FileUploadResult[]`.
|
|
225
|
+
*
|
|
226
|
+
* A SIBLING COMPONENT, not a `multiple` flag. This library already answers
|
|
227
|
+
* "this one takes many" that way (`Select` / `MultipleSelect`), and FileUpload
|
|
228
|
+
* strips `'multiple'` from its props on purpose: single-file is its contract,
|
|
229
|
+
* not a default it happens to have. A boolean would have forced every prop
|
|
230
|
+
* here into a union that means one thing when the flag is set and another when
|
|
231
|
+
* it is not: `value` as `Result | Result[] | null`, a `maxFiles` that is
|
|
232
|
+
* meaningless in half the configurations, and a remove control whose
|
|
233
|
+
* accessible name is fixed copy in one mode and per-file in the other. The
|
|
234
|
+
* plural props follow the house shape for plural components: `value?: T[]`
|
|
235
|
+
* with `onValueChange?: (values: T[]) => void`.
|
|
236
|
+
*
|
|
237
|
+
* WHERE THE LINE SITS: this component owns SELECTION and nothing after it.
|
|
238
|
+
* Choosing, validating, capping, listing and removing are its job; uploading
|
|
239
|
+
* is not. That split is not squeamishness about scope, it is where the
|
|
240
|
+
* knowledge actually lives. An upload needs an endpoint, an auth scheme, a
|
|
241
|
+
* concurrency policy, a retry policy and, very often, a parent id that does
|
|
242
|
+
* not exist yet when the files are chosen: the motivating host stages evidence
|
|
243
|
+
* files while a form is being filled and can only upload them against the id
|
|
244
|
+
* that its create call returns afterwards. None of that is knowable from
|
|
245
|
+
* inside a library primitive, and a component that guessed would have to be
|
|
246
|
+
* fought rather than used. So the host keeps its own per-file record with
|
|
247
|
+
* status and retry, and this component keeps the part a form can hold and
|
|
248
|
+
* validate: the chosen files. `FileUploadResult[]` is a value; an upload state
|
|
249
|
+
* machine is not.
|
|
250
|
+
*
|
|
251
|
+
* ACCUMULATION is the defining behaviour. A second pick ADDS to the selection
|
|
252
|
+
* rather than replacing it, because a user assembling five documents does it
|
|
253
|
+
* in two or three trips to the file dialog, not one. Everything else follows
|
|
254
|
+
* from that: room is measured against what is already selected, and the batch
|
|
255
|
+
* that overflows the cap still contributes the files that fit.
|
|
256
|
+
*
|
|
257
|
+
* A BATCH SURVIVES ITS OWN CASUALTIES. One file rejected for type, size or a
|
|
258
|
+
* failed read does not discard the rest of the batch, and it does not consume
|
|
259
|
+
* a slot either: validation runs over the WHOLE batch before the cap is
|
|
260
|
+
* applied, so a file that was never eligible cannot cost an eligible one its
|
|
261
|
+
* place, and `'too-many'` always names a file a slot would genuinely have
|
|
262
|
+
* taken. The alternative punishes
|
|
263
|
+
* a user for a mistake in one file by throwing away four good ones, and hands
|
|
264
|
+
* back no way to tell which was which. Every rejection is reported through
|
|
265
|
+
* `onError` and announced together in one `role="alert"`.
|
|
266
|
+
*
|
|
267
|
+
* Accessibility follows the sibling BELOW THE CAP: the real `<input
|
|
268
|
+
* type="file">` is `sr-only` but focusable and labelable, and never
|
|
269
|
+
* `aria-hidden`, so FormControl-injected ARIA and react-hook-form's focus on
|
|
270
|
+
* error both work while the picker is enabled. At the cap that changes, and
|
|
271
|
+
* the paragraph below says how.
|
|
272
|
+
* The file list sits OUTSIDE the click zone, so activating a remove control
|
|
273
|
+
* cannot also reopen the picker, and each remove control is named after its
|
|
274
|
+
* own file: a column of identical "Remove file" buttons tells a screen-reader
|
|
275
|
+
* user nothing about which row they are on.
|
|
276
|
+
*
|
|
277
|
+
* AT THE CAP the picker takes the native `disabled` attribute, and that DOES
|
|
278
|
+
* take it out of the tab order. This is the one state in which the input is
|
|
279
|
+
* not a focus target, and the one state in which focus-on-error cannot land on
|
|
280
|
+
* it, so it is a real cost rather than a free win. The cap has no counterpart
|
|
281
|
+
* in the single-file sibling, so the precedent followed here is
|
|
282
|
+
* `DateRangePicker`'s trigger: a control whose only job is to open a dialog has
|
|
283
|
+
* no state worth keeping focusable, and native `disabled` is what both removes
|
|
284
|
+
* it from the tab order and keeps the dialog shut. The alternative, an enabled
|
|
285
|
+
* picker that opens the file dialog and then refuses every file with
|
|
286
|
+
* `too-many`, is a control that lies about being available. What keeps the cap
|
|
287
|
+
* from being a dead end is the escape hatch: the remove controls answer to
|
|
288
|
+
* `disabled` alone and NEVER to the cap, so they stay focusable and removing
|
|
289
|
+
* one file reopens the picker.
|
|
290
|
+
*/
|
|
291
|
+
export declare const MultipleFileUpload: React.ForwardRefExoticComponent<{
|
|
292
|
+
/** Comma-separated accept filter. Applied to every file, extension or MIME. */
|
|
293
|
+
accept?: string;
|
|
294
|
+
/** Inclusive per-file byte ceiling. Applied to each file independently. */
|
|
295
|
+
maxSizeBytes?: number;
|
|
296
|
+
/** Ceiling on the TOTAL selection. Omitted means unbounded. */
|
|
297
|
+
maxFiles?: number;
|
|
298
|
+
/** Controlled selection. The host owns state; defaults to empty. */
|
|
299
|
+
value?: FileUploadResult[];
|
|
300
|
+
/**
|
|
301
|
+
* How each accepted file is handed over. `'text'` (default) decodes UTF-8
|
|
302
|
+
* and populates `text`; `'none'` skips decoding entirely and is the mode
|
|
303
|
+
* BINARY files need. See FileUpload's `readAs` for why.
|
|
304
|
+
*/
|
|
305
|
+
readAs?: FileUploadReadAs;
|
|
306
|
+
/** Override the fixed English copy. Omitted fields keep their defaults. */
|
|
307
|
+
labels?: MultipleFileUploadLabels;
|
|
308
|
+
/** Fires with the WHOLE next selection whenever files are added or removed. */
|
|
309
|
+
onValueChange: (values: FileUploadResult[]) => void;
|
|
310
|
+
/** Fires once per rejected file. A batch can produce several. */
|
|
311
|
+
onError?: (error: MultipleFileUploadError) => void;
|
|
312
|
+
disabled?: boolean;
|
|
313
|
+
id?: string;
|
|
314
|
+
className?: string;
|
|
315
|
+
'aria-invalid'?: boolean;
|
|
316
|
+
'aria-required'?: boolean;
|
|
317
|
+
'aria-describedby'?: string;
|
|
318
|
+
'aria-label'?: string;
|
|
319
|
+
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "className" | "disabled" | "type" | "value" | "onChange" | "onError" | "onSelect" | "accept" | "multiple"> & React.RefAttributes<HTMLInputElement>>;
|
|
161
320
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ui/file-upload/index.tsx"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ui/file-upload/index.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAM9B,MAAM,MAAM,gBAAgB,GAAG;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAE3D,MAAM,MAAM,eAAe,GACvB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACvD;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,IAAI,EAAE,IAAI,CAAA;CAAE,CAAA;AAEvC,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,MAAM,CAAA;AAE9C;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,sEAAsE;IACtE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sEAAsE;IACtE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CAC9D;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,wGAAwG;IACxG,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0FAA0F;IAC1F,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iEAAiE;IACjE,KAAK,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAA;IAC/B;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,uGAAuG;IACvG,QAAQ,EAAE,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI,CAAA;IACnD,gHAAgH;IAChH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IAC1C,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,GAAG,IAAI,CACN,KAAK,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAEzC,MAAM,GACN,QAAQ,GACR,OAAO,GACP,UAAU,GACV,UAAU,GACV,UAAU,GACV,WAAW,GAQX,SAAS,GAET,UAAU,CACb,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,IAAI,EACV,IAAI,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/C,eAAe,GAAG,IAAI,CASxB;AAqBD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUlD;AAcD,eAAO,MAAM,UAAU;IAvHrB,wGAAwG;aAC/F,MAAM;IACf,0FAA0F;mBAC3E,MAAM;IACrB,iEAAiE;YACzD,gBAAgB,GAAG,IAAI;IAC/B;;;;;;;OAOG;aACM,gBAAgB;IACzB,2EAA2E;aAClE,gBAAgB;IACzB,uGAAuG;cAC7F,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI;IACnD,gHAAgH;cACtG,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI;eAC/B,OAAO;SACb,MAAM;gBACC,MAAM;qBACD,OAAO;sBACN,OAAO;yBACJ,MAAM;mBACZ,MAAM;yMAkUtB,CAAA;AAID;;;GAGG;AACH,MAAM,MAAM,uBAAuB,GACjC,eAAe,GAAG;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAA;AAEtE,qEAAqE;AACrE,MAAM,WAAW,wBAAwB;IACvC,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,sEAAsE;IACtE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,6EAA6E;IAC7E,IAAI,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAA;IACnC;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,CAAA;IAC/B;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CACtE;AAED,MAAM,MAAM,uBAAuB,GAAG;IACpC,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,oEAAoE;IACpE,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,wBAAwB,CAAA;IACjC,+EAA+E;IAC/E,aAAa,EAAE,CAAC,MAAM,EAAE,gBAAgB,EAAE,KAAK,IAAI,CAAA;IACnD,iEAAiE;IACjE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,CAAA;IAClD,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,GAAG,IAAI,CACN,KAAK,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAGzC,MAAM,GACN,QAAQ,GACR,OAAO,GACP,UAAU,GACV,UAAU,GACV,UAAU,GACV,WAAW,GACX,UAAU,GAIV,SAAS,CACZ,CAAA;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AACH,eAAO,MAAM,kBAAkB;IAlI7B,+EAA+E;aACtE,MAAM;IACf,2EAA2E;mBAC5D,MAAM;IACrB,+DAA+D;eACpD,MAAM;IACjB,oEAAoE;YAC5D,gBAAgB,EAAE;IAC1B;;;;OAIG;aACM,gBAAgB;IACzB,2EAA2E;aAClE,wBAAwB;IACjC,+EAA+E;mBAChE,CAAC,MAAM,EAAE,gBAAgB,EAAE,KAAK,IAAI;IACnD,iEAAiE;cACvD,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI;eACvC,OAAO;SACb,MAAM;gBACC,MAAM;qBACD,OAAO;sBACN,OAAO;yBACJ,MAAM;mBACZ,MAAM;yMAicrB,CAAA"}
|
|
@@ -232,3 +232,283 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
|
|
|
232
232
|
}, children: _jsx(X, { className: "size-4", "aria-hidden": "true" }) })] })) : (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose a file' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), errorText ? (_jsx("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorText })) : null] }));
|
|
233
233
|
});
|
|
234
234
|
FileUpload.displayName = 'FileUpload';
|
|
235
|
+
/** The default English announcement for each rejection kind. */
|
|
236
|
+
function defaultMultipleErrorMessage(error) {
|
|
237
|
+
if (error.kind === 'too-many') {
|
|
238
|
+
return `Too many files (max ${error.maxFiles}). ${error.file.name} was not added.`;
|
|
239
|
+
}
|
|
240
|
+
return defaultErrorMessage(error);
|
|
241
|
+
}
|
|
242
|
+
/** The default zone copy once the cap is reached, in grammatical English. */
|
|
243
|
+
function defaultFullMessage(maxFiles) {
|
|
244
|
+
return maxFiles === 1
|
|
245
|
+
? 'Maximum of 1 file reached.'
|
|
246
|
+
: `Maximum of ${maxFiles} files reached.`;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* MultipleFileUpload: the plural sibling of FileUpload. Pick SEVERAL files,
|
|
250
|
+
* accumulate them across repeated picks, validate each one, cap the total, and
|
|
251
|
+
* hand back `FileUploadResult[]`.
|
|
252
|
+
*
|
|
253
|
+
* A SIBLING COMPONENT, not a `multiple` flag. This library already answers
|
|
254
|
+
* "this one takes many" that way (`Select` / `MultipleSelect`), and FileUpload
|
|
255
|
+
* strips `'multiple'` from its props on purpose: single-file is its contract,
|
|
256
|
+
* not a default it happens to have. A boolean would have forced every prop
|
|
257
|
+
* here into a union that means one thing when the flag is set and another when
|
|
258
|
+
* it is not: `value` as `Result | Result[] | null`, a `maxFiles` that is
|
|
259
|
+
* meaningless in half the configurations, and a remove control whose
|
|
260
|
+
* accessible name is fixed copy in one mode and per-file in the other. The
|
|
261
|
+
* plural props follow the house shape for plural components: `value?: T[]`
|
|
262
|
+
* with `onValueChange?: (values: T[]) => void`.
|
|
263
|
+
*
|
|
264
|
+
* WHERE THE LINE SITS: this component owns SELECTION and nothing after it.
|
|
265
|
+
* Choosing, validating, capping, listing and removing are its job; uploading
|
|
266
|
+
* is not. That split is not squeamishness about scope, it is where the
|
|
267
|
+
* knowledge actually lives. An upload needs an endpoint, an auth scheme, a
|
|
268
|
+
* concurrency policy, a retry policy and, very often, a parent id that does
|
|
269
|
+
* not exist yet when the files are chosen: the motivating host stages evidence
|
|
270
|
+
* files while a form is being filled and can only upload them against the id
|
|
271
|
+
* that its create call returns afterwards. None of that is knowable from
|
|
272
|
+
* inside a library primitive, and a component that guessed would have to be
|
|
273
|
+
* fought rather than used. So the host keeps its own per-file record with
|
|
274
|
+
* status and retry, and this component keeps the part a form can hold and
|
|
275
|
+
* validate: the chosen files. `FileUploadResult[]` is a value; an upload state
|
|
276
|
+
* machine is not.
|
|
277
|
+
*
|
|
278
|
+
* ACCUMULATION is the defining behaviour. A second pick ADDS to the selection
|
|
279
|
+
* rather than replacing it, because a user assembling five documents does it
|
|
280
|
+
* in two or three trips to the file dialog, not one. Everything else follows
|
|
281
|
+
* from that: room is measured against what is already selected, and the batch
|
|
282
|
+
* that overflows the cap still contributes the files that fit.
|
|
283
|
+
*
|
|
284
|
+
* A BATCH SURVIVES ITS OWN CASUALTIES. One file rejected for type, size or a
|
|
285
|
+
* failed read does not discard the rest of the batch, and it does not consume
|
|
286
|
+
* a slot either: validation runs over the WHOLE batch before the cap is
|
|
287
|
+
* applied, so a file that was never eligible cannot cost an eligible one its
|
|
288
|
+
* place, and `'too-many'` always names a file a slot would genuinely have
|
|
289
|
+
* taken. The alternative punishes
|
|
290
|
+
* a user for a mistake in one file by throwing away four good ones, and hands
|
|
291
|
+
* back no way to tell which was which. Every rejection is reported through
|
|
292
|
+
* `onError` and announced together in one `role="alert"`.
|
|
293
|
+
*
|
|
294
|
+
* Accessibility follows the sibling BELOW THE CAP: the real `<input
|
|
295
|
+
* type="file">` is `sr-only` but focusable and labelable, and never
|
|
296
|
+
* `aria-hidden`, so FormControl-injected ARIA and react-hook-form's focus on
|
|
297
|
+
* error both work while the picker is enabled. At the cap that changes, and
|
|
298
|
+
* the paragraph below says how.
|
|
299
|
+
* The file list sits OUTSIDE the click zone, so activating a remove control
|
|
300
|
+
* cannot also reopen the picker, and each remove control is named after its
|
|
301
|
+
* own file: a column of identical "Remove file" buttons tells a screen-reader
|
|
302
|
+
* user nothing about which row they are on.
|
|
303
|
+
*
|
|
304
|
+
* AT THE CAP the picker takes the native `disabled` attribute, and that DOES
|
|
305
|
+
* take it out of the tab order. This is the one state in which the input is
|
|
306
|
+
* not a focus target, and the one state in which focus-on-error cannot land on
|
|
307
|
+
* it, so it is a real cost rather than a free win. The cap has no counterpart
|
|
308
|
+
* in the single-file sibling, so the precedent followed here is
|
|
309
|
+
* `DateRangePicker`'s trigger: a control whose only job is to open a dialog has
|
|
310
|
+
* no state worth keeping focusable, and native `disabled` is what both removes
|
|
311
|
+
* it from the tab order and keeps the dialog shut. The alternative, an enabled
|
|
312
|
+
* picker that opens the file dialog and then refuses every file with
|
|
313
|
+
* `too-many`, is a control that lies about being available. What keeps the cap
|
|
314
|
+
* from being a dead end is the escape hatch: the remove controls answer to
|
|
315
|
+
* `disabled` alone and NEVER to the cap, so they stay focusable and removing
|
|
316
|
+
* one file reopens the picker.
|
|
317
|
+
*/
|
|
318
|
+
export const MultipleFileUpload = React.forwardRef(function MultipleFileUpload({ accept, maxSizeBytes, maxFiles, value = [], readAs = 'text', labels, onValueChange, onError, disabled = false, id, className, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, ...rest }, ref) {
|
|
319
|
+
const internalRef = React.useRef(null);
|
|
320
|
+
React.useImperativeHandle(ref, () => internalRef.current);
|
|
321
|
+
const reactId = React.useId();
|
|
322
|
+
const inputId = id ?? reactId;
|
|
323
|
+
const errorId = `${inputId}-file-upload-error`;
|
|
324
|
+
const [dragActive, setDragActive] = React.useState(false);
|
|
325
|
+
const [errors, setErrors] = React.useState([]);
|
|
326
|
+
// The authoritative base for the next append. Props win on every render, so
|
|
327
|
+
// the host stays in control; the commit below also writes through, so a
|
|
328
|
+
// second batch that settles before the host has re-rendered still appends to
|
|
329
|
+
// the first batch's result instead of overwriting it.
|
|
330
|
+
const valueRef = React.useRef(value);
|
|
331
|
+
React.useEffect(() => {
|
|
332
|
+
valueRef.current = value;
|
|
333
|
+
});
|
|
334
|
+
// Superseding is NOT the contract here the way it is in the single-file
|
|
335
|
+
// sibling — batches accumulate, so an in-flight read is never stale. The
|
|
336
|
+
// readers are tracked for the one case that does have to stop them: an
|
|
337
|
+
// unmount. A read that lands afterwards would settle its batch and commit,
|
|
338
|
+
// calling the host's `onValueChange` for a component that no longer exists.
|
|
339
|
+
const readersRef = React.useRef(new Set());
|
|
340
|
+
React.useEffect(() => () => {
|
|
341
|
+
for (const reader of readersRef.current)
|
|
342
|
+
reader.abort();
|
|
343
|
+
readersRef.current.clear();
|
|
344
|
+
}, []);
|
|
345
|
+
const invalid = ariaInvalid || errors.length > 0;
|
|
346
|
+
// Resolve the announcements BEFORE deciding whether the alert exists: a
|
|
347
|
+
// consumer that returns nothing for every rejection is opting out of this
|
|
348
|
+
// surface, and an association pointing at an unrendered node is worse than
|
|
349
|
+
// none.
|
|
350
|
+
const messages = errors
|
|
351
|
+
.map((failure) => labels?.error
|
|
352
|
+
? labels.error(failure)
|
|
353
|
+
: defaultMultipleErrorMessage(failure))
|
|
354
|
+
.filter((message) => Boolean(message));
|
|
355
|
+
// A plain join, never `cn`: tailwind-merge treats these as class names and
|
|
356
|
+
// would drop an id that happens to look like a conflicting utility.
|
|
357
|
+
const describedBy = [ariaDescribedby, messages.length > 0 ? errorId : undefined]
|
|
358
|
+
.filter(Boolean)
|
|
359
|
+
.join(' ') || undefined;
|
|
360
|
+
const full = maxFiles !== undefined && value.length >= maxFiles;
|
|
361
|
+
// Removing must stay possible at the cap, so only the PICKER closes.
|
|
362
|
+
const pickerDisabled = disabled || full;
|
|
363
|
+
const roomFor = (selected) => maxFiles === undefined
|
|
364
|
+
? Number.POSITIVE_INFINITY
|
|
365
|
+
: Math.max(maxFiles - selected, 0);
|
|
366
|
+
// The cap is enforced HERE, at the only place that appends, because only the
|
|
367
|
+
// commit knows the base it lands on. `ingest` measures room too, but for an
|
|
368
|
+
// asynchronous batch it measures it BEFORE any read settles: two overlapping
|
|
369
|
+
// batches both see the pre-commit selection and would each believe they fit.
|
|
370
|
+
const commit = (accepted, rejections) => {
|
|
371
|
+
const room = roomFor(valueRef.current.length);
|
|
372
|
+
const fitting = accepted.slice(0, room);
|
|
373
|
+
const overflow = accepted[room];
|
|
374
|
+
const failures = overflow !== undefined && maxFiles !== undefined
|
|
375
|
+
? [
|
|
376
|
+
...rejections,
|
|
377
|
+
{ kind: 'too-many', file: overflow.file, maxFiles }
|
|
378
|
+
]
|
|
379
|
+
: rejections;
|
|
380
|
+
setErrors(failures);
|
|
381
|
+
for (const rejection of failures)
|
|
382
|
+
onError?.(rejection);
|
|
383
|
+
if (fitting.length === 0)
|
|
384
|
+
return;
|
|
385
|
+
const next = [...valueRef.current, ...fitting];
|
|
386
|
+
valueRef.current = next;
|
|
387
|
+
onValueChange(next);
|
|
388
|
+
};
|
|
389
|
+
const ingest = (incoming) => {
|
|
390
|
+
if (incoming.length === 0)
|
|
391
|
+
return;
|
|
392
|
+
const room = roomFor(valueRef.current.length);
|
|
393
|
+
const rejections = [];
|
|
394
|
+
// Validate EVERY file BEFORE the cap is applied. Slicing to the remaining
|
|
395
|
+
// room first would let a file that was never eligible consume a slot a
|
|
396
|
+
// good file could have used — one bad pick costing a good one, which is
|
|
397
|
+
// the opposite of a batch surviving its own casualties — and it would
|
|
398
|
+
// leave every file past the slice window neither validated nor reported.
|
|
399
|
+
// Validation is pure metadata (size and accept), so running it over files
|
|
400
|
+
// that may not fit costs nothing.
|
|
401
|
+
const eligible = [];
|
|
402
|
+
for (const file of incoming) {
|
|
403
|
+
const rejection = validateFile(file, { accept, maxSizeBytes });
|
|
404
|
+
// Continue rather than abort: one bad file must not cost the good ones.
|
|
405
|
+
if (rejection) {
|
|
406
|
+
rejections.push(rejection);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
eligible.push(file);
|
|
410
|
+
}
|
|
411
|
+
// The cap then applies to the SURVIVORS. One rejection for the batch,
|
|
412
|
+
// naming the FIRST eligible file that did not fit: naming one already
|
|
413
|
+
// refused for its size or type would blame the cap for the wrong thing,
|
|
414
|
+
// and repeating it per overflowing file buries the actionable part.
|
|
415
|
+
if (eligible.length > room && maxFiles !== undefined) {
|
|
416
|
+
rejections.push({ kind: 'too-many', file: eligible[room], maxFiles });
|
|
417
|
+
}
|
|
418
|
+
const candidates = eligible.slice(0, room);
|
|
419
|
+
// Binary path: no decode, no reader, no retained garbage string.
|
|
420
|
+
if (readAs === 'none') {
|
|
421
|
+
commit(candidates.map((file) => ({ file, text: '' })), rejections);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (candidates.length === 0) {
|
|
425
|
+
commit([], rejections);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
// Slot-per-candidate so the emitted batch keeps PICK order regardless of
|
|
429
|
+
// the order the reads settle in. A null slot is a read that failed; the
|
|
430
|
+
// batch commits once every read has settled, one way or the other.
|
|
431
|
+
const slots = new Array(candidates.length).fill(null);
|
|
432
|
+
let remaining = candidates.length;
|
|
433
|
+
const settle = () => {
|
|
434
|
+
remaining -= 1;
|
|
435
|
+
if (remaining > 0)
|
|
436
|
+
return;
|
|
437
|
+
const accepted = [];
|
|
438
|
+
const readFailures = [];
|
|
439
|
+
slots.forEach((slot, index) => {
|
|
440
|
+
if (slot)
|
|
441
|
+
accepted.push(slot);
|
|
442
|
+
else
|
|
443
|
+
readFailures.push({ kind: 'read-failed', file: candidates[index] });
|
|
444
|
+
});
|
|
445
|
+
commit(accepted, [...rejections, ...readFailures]);
|
|
446
|
+
};
|
|
447
|
+
candidates.forEach((file, index) => {
|
|
448
|
+
const reader = new FileReader();
|
|
449
|
+
readersRef.current.add(reader);
|
|
450
|
+
// Deliberately no `onabort` handler: an aborted read must NOT settle,
|
|
451
|
+
// or the batch would commit at exactly the moment we are stopping it.
|
|
452
|
+
reader.onload = () => {
|
|
453
|
+
readersRef.current.delete(reader);
|
|
454
|
+
slots[index] = { file, text: String(reader.result ?? '') };
|
|
455
|
+
settle();
|
|
456
|
+
};
|
|
457
|
+
reader.onerror = () => {
|
|
458
|
+
readersRef.current.delete(reader);
|
|
459
|
+
settle();
|
|
460
|
+
};
|
|
461
|
+
reader.readAsText(file);
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
const onInputChange = (event) => {
|
|
465
|
+
const files = Array.from(event.target.files ?? []);
|
|
466
|
+
// Release the FileList the moment it has been read, or re-picking the same
|
|
467
|
+
// file is silently a no-op: the browser fires `change` only when the
|
|
468
|
+
// selection DIFFERS from what the input already holds.
|
|
469
|
+
event.target.value = '';
|
|
470
|
+
ingest(files);
|
|
471
|
+
};
|
|
472
|
+
const onDragOver = (event) => {
|
|
473
|
+
if (disabled)
|
|
474
|
+
return;
|
|
475
|
+
event.preventDefault();
|
|
476
|
+
setDragActive(true);
|
|
477
|
+
};
|
|
478
|
+
const onDragLeave = (event) => {
|
|
479
|
+
event.preventDefault();
|
|
480
|
+
setDragActive(false);
|
|
481
|
+
};
|
|
482
|
+
const onDrop = (event) => {
|
|
483
|
+
if (disabled)
|
|
484
|
+
return;
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
setDragActive(false);
|
|
487
|
+
// Deliberately NOT gated on `full`: a drop onto a full zone is answered
|
|
488
|
+
// with the too-many rejection, which says why, instead of nothing at all.
|
|
489
|
+
ingest(Array.from(event.dataTransfer.files ?? []));
|
|
490
|
+
};
|
|
491
|
+
// Mouse convenience only. A click that ORIGINATED on the input already opens
|
|
492
|
+
// the picker natively and bubbles up here, so ignore it or it opens twice.
|
|
493
|
+
const openPicker = (event) => {
|
|
494
|
+
if (pickerDisabled || event.target === internalRef.current)
|
|
495
|
+
return;
|
|
496
|
+
internalRef.current?.click();
|
|
497
|
+
};
|
|
498
|
+
const removeAt = (index) => {
|
|
499
|
+
// By index, not by name: two files can share a filename and identity is
|
|
500
|
+
// what the row actually stands for.
|
|
501
|
+
const next = valueRef.current.filter((_, position) => position !== index);
|
|
502
|
+
valueRef.current = next;
|
|
503
|
+
onValueChange(next);
|
|
504
|
+
};
|
|
505
|
+
return (_jsxs("div", { className: cn('space-y-2', className), children: [_jsxs("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: cn('border-input bg-card focus-within:ring-ring focus-within:ring-offset-background aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-within:ring-destructive flex w-full items-center gap-3 rounded-md border px-3 py-4 text-sm shadow-xs transition-colors focus-within:ring-2 focus-within:ring-offset-1 focus-within:outline-none', dragActive &&
|
|
506
|
+
'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', pickerDisabled
|
|
507
|
+
? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
|
|
508
|
+
: 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [_jsx("input", { ref: internalRef, id: inputId, type: "file", multiple: true, accept: accept, disabled: pickerDisabled, className: "sr-only", "aria-invalid": invalid || undefined, "aria-required": ariaRequired || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, onChange: onInputChange, ...rest }), _jsx(Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), full && maxFiles !== undefined ? (_jsx("span", { className: "text-muted-foreground", children: labels?.full
|
|
509
|
+
? labels.full(maxFiles)
|
|
510
|
+
: defaultFullMessage(maxFiles) })) : (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose files' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), value.length > 0 ? (_jsx("ul", { className: "space-y-1", children: value.map((entry, index) => (_jsxs("li", { className: "flex items-center gap-3 text-sm", children: [_jsxs("span", { className: "min-w-0 flex-1 truncate", children: [_jsx("span", { className: "text-foreground font-medium", children: entry.file.name }), ' ', _jsx("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(entry.file.size) })] }), _jsx(IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove
|
|
511
|
+
? labels.remove(entry.file)
|
|
512
|
+
: `Remove ${entry.file.name}`, onClick: () => removeAt(index), children: _jsx(X, { className: "size-4", "aria-hidden": "true" }) })] }, `${entry.file.name}-${index}`))) })) : null, messages.length > 0 ? (_jsx("div", { id: errorId, role: "alert", className: "space-y-1", children: messages.map((message, index) => (_jsx("p", { className: "text-system-error-h1a text-xs font-medium", children: message }, index))) })) : null] }));
|
|
513
|
+
});
|
|
514
|
+
MultipleFileUpload.displayName = 'MultipleFileUpload';
|