@lerianstudio/sindarian-ui 2.0.0-beta.2 → 2.0.0-beta.4

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.
@@ -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":"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"}
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");
@@ -226,10 +226,18 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
226
226
  event.target.value = '';
227
227
  handleFile(file);
228
228
  };
229
+ // `preventDefault` FIRST, and unconditionally. Returning before it does
230
+ // not merely refuse the drop: nothing cancels the browser's OWN action for
231
+ // a dropped file, so the window navigates to the file and whatever the
232
+ // page held unsaved is gone. A zone the host turned off has to swallow the
233
+ // drop, never hand it back to the browser. `dropEffect` says so to the
234
+ // cursor while the file is still in the air.
229
235
  const onDragOver = (event) => {
230
- if (disabled)
231
- return;
232
236
  event.preventDefault();
237
+ if (disabled) {
238
+ event.dataTransfer.dropEffect = 'none';
239
+ return;
240
+ }
233
241
  setDragActive(true);
234
242
  };
235
243
  const onDragLeave = (event) => {
@@ -237,10 +245,13 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
237
245
  setDragActive(false);
238
246
  };
239
247
  const onDrop = (event) => {
240
- if (disabled)
241
- return;
242
248
  event.preventDefault();
249
+ // Cleared before the gate, not after, so a host that disables the zone
250
+ // mid-drag does not leave it highlighted for a drag that can no longer
251
+ // land.
243
252
  setDragActive(false);
253
+ if (disabled)
254
+ return;
244
255
  handleFile(event.dataTransfer.files?.[0]);
245
256
  };
246
257
  // Mouse convenience only: clicking the styled zone opens the picker. A
@@ -270,3 +281,294 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
270
281
  }, 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
282
  });
272
283
  exports.FileUpload.displayName = 'FileUpload';
284
+ /** The default English announcement for each rejection kind. */
285
+ function defaultMultipleErrorMessage(error) {
286
+ if (error.kind === 'too-many') {
287
+ return `Too many files (max ${error.maxFiles}). ${error.file.name} was not added.`;
288
+ }
289
+ return defaultErrorMessage(error);
290
+ }
291
+ /** The default zone copy once the cap is reached, in grammatical English. */
292
+ function defaultFullMessage(maxFiles) {
293
+ return maxFiles === 1
294
+ ? 'Maximum of 1 file reached.'
295
+ : `Maximum of ${maxFiles} files reached.`;
296
+ }
297
+ /**
298
+ * MultipleFileUpload: the plural sibling of FileUpload. Pick SEVERAL files,
299
+ * accumulate them across repeated picks, validate each one, cap the total, and
300
+ * hand back `FileUploadResult[]`.
301
+ *
302
+ * A SIBLING COMPONENT, not a `multiple` flag. This library already answers
303
+ * "this one takes many" that way (`Select` / `MultipleSelect`), and FileUpload
304
+ * strips `'multiple'` from its props on purpose: single-file is its contract,
305
+ * not a default it happens to have. A boolean would have forced every prop
306
+ * here into a union that means one thing when the flag is set and another when
307
+ * it is not: `value` as `Result | Result[] | null`, a `maxFiles` that is
308
+ * meaningless in half the configurations, and a remove control whose
309
+ * accessible name is fixed copy in one mode and per-file in the other. The
310
+ * plural props follow the house shape for plural components: `value?: T[]`
311
+ * with `onValueChange?: (values: T[]) => void`.
312
+ *
313
+ * WHERE THE LINE SITS: this component owns SELECTION and nothing after it.
314
+ * Choosing, validating, capping, listing and removing are its job; uploading
315
+ * is not. That split is not squeamishness about scope, it is where the
316
+ * knowledge actually lives. An upload needs an endpoint, an auth scheme, a
317
+ * concurrency policy, a retry policy and, very often, a parent id that does
318
+ * not exist yet when the files are chosen: the motivating host stages evidence
319
+ * files while a form is being filled and can only upload them against the id
320
+ * that its create call returns afterwards. None of that is knowable from
321
+ * inside a library primitive, and a component that guessed would have to be
322
+ * fought rather than used. So the host keeps its own per-file record with
323
+ * status and retry, and this component keeps the part a form can hold and
324
+ * validate: the chosen files. `FileUploadResult[]` is a value; an upload state
325
+ * machine is not.
326
+ *
327
+ * ACCUMULATION is the defining behaviour. A second pick ADDS to the selection
328
+ * rather than replacing it, because a user assembling five documents does it
329
+ * in two or three trips to the file dialog, not one. Everything else follows
330
+ * from that: room is measured against what is already selected, and the batch
331
+ * that overflows the cap still contributes the files that fit.
332
+ *
333
+ * A BATCH SURVIVES ITS OWN CASUALTIES. One file rejected for type, size or a
334
+ * failed read does not discard the rest of the batch, and it does not consume
335
+ * a slot either: validation runs over the WHOLE batch before the cap is
336
+ * applied, so a file that was never eligible cannot cost an eligible one its
337
+ * place, and `'too-many'` always names a file a slot would genuinely have
338
+ * taken. The alternative punishes
339
+ * a user for a mistake in one file by throwing away four good ones, and hands
340
+ * back no way to tell which was which. Every rejection is reported through
341
+ * `onError` and announced together in one `role="alert"`.
342
+ *
343
+ * Accessibility follows the sibling BELOW THE CAP: the real `<input
344
+ * type="file">` is `sr-only` but focusable and labelable, and never
345
+ * `aria-hidden`, so FormControl-injected ARIA and react-hook-form's focus on
346
+ * error both work while the picker is enabled. At the cap that changes, and
347
+ * the paragraph below says how.
348
+ * The file list sits OUTSIDE the click zone, so activating a remove control
349
+ * cannot also reopen the picker, and each remove control is named after its
350
+ * own file: a column of identical "Remove file" buttons tells a screen-reader
351
+ * user nothing about which row they are on.
352
+ *
353
+ * AT THE CAP the picker takes the native `disabled` attribute, and that DOES
354
+ * take it out of the tab order. This is the one state in which the input is
355
+ * not a focus target, and the one state in which focus-on-error cannot land on
356
+ * it, so it is a real cost rather than a free win. The cap has no counterpart
357
+ * in the single-file sibling, so the precedent followed here is
358
+ * `DateRangePicker`'s trigger: a control whose only job is to open a dialog has
359
+ * no state worth keeping focusable, and native `disabled` is what both removes
360
+ * it from the tab order and keeps the dialog shut. The alternative, an enabled
361
+ * picker that opens the file dialog and then refuses every file with
362
+ * `too-many`, is a control that lies about being available. What keeps the cap
363
+ * from being a dead end is the escape hatch: the remove controls answer to
364
+ * `disabled` alone and NEVER to the cap, so they stay focusable and removing
365
+ * one file reopens the picker.
366
+ */
367
+ 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) {
368
+ const internalRef = React.useRef(null);
369
+ React.useImperativeHandle(ref, () => internalRef.current);
370
+ const reactId = React.useId();
371
+ const inputId = id ?? reactId;
372
+ const errorId = `${inputId}-file-upload-error`;
373
+ const [dragActive, setDragActive] = React.useState(false);
374
+ const [errors, setErrors] = React.useState([]);
375
+ // The authoritative base for the next append. Props win on every render, so
376
+ // the host stays in control; the commit below also writes through, so a
377
+ // second batch that settles before the host has re-rendered still appends to
378
+ // the first batch's result instead of overwriting it.
379
+ const valueRef = React.useRef(value);
380
+ React.useEffect(() => {
381
+ valueRef.current = value;
382
+ });
383
+ // Superseding is NOT the contract here the way it is in the single-file
384
+ // sibling — batches accumulate, so an in-flight read is never stale. The
385
+ // readers are tracked for the one case that does have to stop them: an
386
+ // unmount. A read that lands afterwards would settle its batch and commit,
387
+ // calling the host's `onValueChange` for a component that no longer exists.
388
+ const readersRef = React.useRef(new Set());
389
+ React.useEffect(() => () => {
390
+ for (const reader of readersRef.current)
391
+ reader.abort();
392
+ readersRef.current.clear();
393
+ }, []);
394
+ const invalid = ariaInvalid || errors.length > 0;
395
+ // Resolve the announcements BEFORE deciding whether the alert exists: a
396
+ // consumer that returns nothing for every rejection is opting out of this
397
+ // surface, and an association pointing at an unrendered node is worse than
398
+ // none.
399
+ const messages = errors
400
+ .map((failure) => labels?.error
401
+ ? labels.error(failure)
402
+ : defaultMultipleErrorMessage(failure))
403
+ .filter((message) => Boolean(message));
404
+ // A plain join, never `cn`: tailwind-merge treats these as class names and
405
+ // would drop an id that happens to look like a conflicting utility.
406
+ const describedBy = [ariaDescribedby, messages.length > 0 ? errorId : undefined]
407
+ .filter(Boolean)
408
+ .join(' ') || undefined;
409
+ const full = maxFiles !== undefined && value.length >= maxFiles;
410
+ // Removing must stay possible at the cap, so only the PICKER closes.
411
+ const pickerDisabled = disabled || full;
412
+ const roomFor = (selected) => maxFiles === undefined
413
+ ? Number.POSITIVE_INFINITY
414
+ : Math.max(maxFiles - selected, 0);
415
+ // The cap is enforced HERE, at the only place that appends, because only the
416
+ // commit knows the base it lands on. `ingest` measures room too, but for an
417
+ // asynchronous batch it measures it BEFORE any read settles: two overlapping
418
+ // batches both see the pre-commit selection and would each believe they fit.
419
+ const commit = (accepted, rejections) => {
420
+ const room = roomFor(valueRef.current.length);
421
+ const fitting = accepted.slice(0, room);
422
+ const overflow = accepted[room];
423
+ const failures = overflow !== undefined && maxFiles !== undefined
424
+ ? [
425
+ ...rejections,
426
+ { kind: 'too-many', file: overflow.file, maxFiles }
427
+ ]
428
+ : rejections;
429
+ setErrors(failures);
430
+ for (const rejection of failures)
431
+ onError?.(rejection);
432
+ if (fitting.length === 0)
433
+ return;
434
+ const next = [...valueRef.current, ...fitting];
435
+ valueRef.current = next;
436
+ onValueChange(next);
437
+ };
438
+ const ingest = (incoming) => {
439
+ if (incoming.length === 0)
440
+ return;
441
+ const room = roomFor(valueRef.current.length);
442
+ const rejections = [];
443
+ // Validate EVERY file BEFORE the cap is applied. Slicing to the remaining
444
+ // room first would let a file that was never eligible consume a slot a
445
+ // good file could have used — one bad pick costing a good one, which is
446
+ // the opposite of a batch surviving its own casualties — and it would
447
+ // leave every file past the slice window neither validated nor reported.
448
+ // Validation is pure metadata (size and accept), so running it over files
449
+ // that may not fit costs nothing.
450
+ const eligible = [];
451
+ for (const file of incoming) {
452
+ const rejection = validateFile(file, { accept, maxSizeBytes });
453
+ // Continue rather than abort: one bad file must not cost the good ones.
454
+ if (rejection) {
455
+ rejections.push(rejection);
456
+ continue;
457
+ }
458
+ eligible.push(file);
459
+ }
460
+ // The cap then applies to the SURVIVORS. One rejection for the batch,
461
+ // naming the FIRST eligible file that did not fit: naming one already
462
+ // refused for its size or type would blame the cap for the wrong thing,
463
+ // and repeating it per overflowing file buries the actionable part.
464
+ if (eligible.length > room && maxFiles !== undefined) {
465
+ rejections.push({ kind: 'too-many', file: eligible[room], maxFiles });
466
+ }
467
+ const candidates = eligible.slice(0, room);
468
+ // Binary path: no decode, no reader, no retained garbage string.
469
+ if (readAs === 'none') {
470
+ commit(candidates.map((file) => ({ file, text: '' })), rejections);
471
+ return;
472
+ }
473
+ if (candidates.length === 0) {
474
+ commit([], rejections);
475
+ return;
476
+ }
477
+ // Slot-per-candidate so the emitted batch keeps PICK order regardless of
478
+ // the order the reads settle in. A null slot is a read that failed; the
479
+ // batch commits once every read has settled, one way or the other.
480
+ const slots = new Array(candidates.length).fill(null);
481
+ let remaining = candidates.length;
482
+ const settle = () => {
483
+ remaining -= 1;
484
+ if (remaining > 0)
485
+ return;
486
+ const accepted = [];
487
+ const readFailures = [];
488
+ slots.forEach((slot, index) => {
489
+ if (slot)
490
+ accepted.push(slot);
491
+ else
492
+ readFailures.push({ kind: 'read-failed', file: candidates[index] });
493
+ });
494
+ commit(accepted, [...rejections, ...readFailures]);
495
+ };
496
+ candidates.forEach((file, index) => {
497
+ const reader = new FileReader();
498
+ readersRef.current.add(reader);
499
+ // Deliberately no `onabort` handler: an aborted read must NOT settle,
500
+ // or the batch would commit at exactly the moment we are stopping it.
501
+ reader.onload = () => {
502
+ readersRef.current.delete(reader);
503
+ slots[index] = { file, text: String(reader.result ?? '') };
504
+ settle();
505
+ };
506
+ reader.onerror = () => {
507
+ readersRef.current.delete(reader);
508
+ settle();
509
+ };
510
+ reader.readAsText(file);
511
+ });
512
+ };
513
+ const onInputChange = (event) => {
514
+ const files = Array.from(event.target.files ?? []);
515
+ // Release the FileList the moment it has been read, or re-picking the same
516
+ // file is silently a no-op: the browser fires `change` only when the
517
+ // selection DIFFERS from what the input already holds.
518
+ event.target.value = '';
519
+ ingest(files);
520
+ };
521
+ // `preventDefault` FIRST, and unconditionally. Returning before it does
522
+ // not merely refuse the drop: nothing cancels the browser's OWN action for
523
+ // a dropped file, so the window navigates to the file and whatever the
524
+ // page held unsaved is gone. A zone the host turned off has to swallow the
525
+ // drop, never hand it back to the browser. `dropEffect` says so to the
526
+ // cursor while the file is still in the air.
527
+ const onDragOver = (event) => {
528
+ event.preventDefault();
529
+ if (disabled) {
530
+ event.dataTransfer.dropEffect = 'none';
531
+ return;
532
+ }
533
+ setDragActive(true);
534
+ };
535
+ const onDragLeave = (event) => {
536
+ event.preventDefault();
537
+ setDragActive(false);
538
+ };
539
+ const onDrop = (event) => {
540
+ event.preventDefault();
541
+ // Cleared before the gate, not after, so a host that disables the zone
542
+ // mid-drag does not leave it highlighted for a drag that can no longer
543
+ // land.
544
+ setDragActive(false);
545
+ if (disabled)
546
+ return;
547
+ // Deliberately NOT gated on `full`: a drop onto a full zone is answered
548
+ // with the too-many rejection, which says why, instead of nothing at all.
549
+ ingest(Array.from(event.dataTransfer.files ?? []));
550
+ };
551
+ // Mouse convenience only. A click that ORIGINATED on the input already opens
552
+ // the picker natively and bubbles up here, so ignore it or it opens twice.
553
+ const openPicker = (event) => {
554
+ if (pickerDisabled || event.target === internalRef.current)
555
+ return;
556
+ internalRef.current?.click();
557
+ };
558
+ const removeAt = (index) => {
559
+ // By index, not by name: two files can share a filename and identity is
560
+ // what the row actually stands for.
561
+ const next = valueRef.current.filter((_, position) => position !== index);
562
+ valueRef.current = next;
563
+ onValueChange(next);
564
+ };
565
+ 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 &&
566
+ 'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', pickerDisabled
567
+ ? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
568
+ : '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
569
+ ? labels.full(maxFiles)
570
+ : 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
571
+ ? labels.remove(entry.file)
572
+ : `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] }));
573
+ });
574
+ 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":"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"}
@@ -188,10 +188,18 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
188
188
  event.target.value = '';
189
189
  handleFile(file);
190
190
  };
191
+ // `preventDefault` FIRST, and unconditionally. Returning before it does
192
+ // not merely refuse the drop: nothing cancels the browser's OWN action for
193
+ // a dropped file, so the window navigates to the file and whatever the
194
+ // page held unsaved is gone. A zone the host turned off has to swallow the
195
+ // drop, never hand it back to the browser. `dropEffect` says so to the
196
+ // cursor while the file is still in the air.
191
197
  const onDragOver = (event) => {
192
- if (disabled)
193
- return;
194
198
  event.preventDefault();
199
+ if (disabled) {
200
+ event.dataTransfer.dropEffect = 'none';
201
+ return;
202
+ }
195
203
  setDragActive(true);
196
204
  };
197
205
  const onDragLeave = (event) => {
@@ -199,10 +207,13 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
199
207
  setDragActive(false);
200
208
  };
201
209
  const onDrop = (event) => {
202
- if (disabled)
203
- return;
204
210
  event.preventDefault();
211
+ // Cleared before the gate, not after, so a host that disables the zone
212
+ // mid-drag does not leave it highlighted for a drag that can no longer
213
+ // land.
205
214
  setDragActive(false);
215
+ if (disabled)
216
+ return;
206
217
  handleFile(event.dataTransfer.files?.[0]);
207
218
  };
208
219
  // Mouse convenience only: clicking the styled zone opens the picker. A
@@ -232,3 +243,294 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
232
243
  }, 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
244
  });
234
245
  FileUpload.displayName = 'FileUpload';
246
+ /** The default English announcement for each rejection kind. */
247
+ function defaultMultipleErrorMessage(error) {
248
+ if (error.kind === 'too-many') {
249
+ return `Too many files (max ${error.maxFiles}). ${error.file.name} was not added.`;
250
+ }
251
+ return defaultErrorMessage(error);
252
+ }
253
+ /** The default zone copy once the cap is reached, in grammatical English. */
254
+ function defaultFullMessage(maxFiles) {
255
+ return maxFiles === 1
256
+ ? 'Maximum of 1 file reached.'
257
+ : `Maximum of ${maxFiles} files reached.`;
258
+ }
259
+ /**
260
+ * MultipleFileUpload: the plural sibling of FileUpload. Pick SEVERAL files,
261
+ * accumulate them across repeated picks, validate each one, cap the total, and
262
+ * hand back `FileUploadResult[]`.
263
+ *
264
+ * A SIBLING COMPONENT, not a `multiple` flag. This library already answers
265
+ * "this one takes many" that way (`Select` / `MultipleSelect`), and FileUpload
266
+ * strips `'multiple'` from its props on purpose: single-file is its contract,
267
+ * not a default it happens to have. A boolean would have forced every prop
268
+ * here into a union that means one thing when the flag is set and another when
269
+ * it is not: `value` as `Result | Result[] | null`, a `maxFiles` that is
270
+ * meaningless in half the configurations, and a remove control whose
271
+ * accessible name is fixed copy in one mode and per-file in the other. The
272
+ * plural props follow the house shape for plural components: `value?: T[]`
273
+ * with `onValueChange?: (values: T[]) => void`.
274
+ *
275
+ * WHERE THE LINE SITS: this component owns SELECTION and nothing after it.
276
+ * Choosing, validating, capping, listing and removing are its job; uploading
277
+ * is not. That split is not squeamishness about scope, it is where the
278
+ * knowledge actually lives. An upload needs an endpoint, an auth scheme, a
279
+ * concurrency policy, a retry policy and, very often, a parent id that does
280
+ * not exist yet when the files are chosen: the motivating host stages evidence
281
+ * files while a form is being filled and can only upload them against the id
282
+ * that its create call returns afterwards. None of that is knowable from
283
+ * inside a library primitive, and a component that guessed would have to be
284
+ * fought rather than used. So the host keeps its own per-file record with
285
+ * status and retry, and this component keeps the part a form can hold and
286
+ * validate: the chosen files. `FileUploadResult[]` is a value; an upload state
287
+ * machine is not.
288
+ *
289
+ * ACCUMULATION is the defining behaviour. A second pick ADDS to the selection
290
+ * rather than replacing it, because a user assembling five documents does it
291
+ * in two or three trips to the file dialog, not one. Everything else follows
292
+ * from that: room is measured against what is already selected, and the batch
293
+ * that overflows the cap still contributes the files that fit.
294
+ *
295
+ * A BATCH SURVIVES ITS OWN CASUALTIES. One file rejected for type, size or a
296
+ * failed read does not discard the rest of the batch, and it does not consume
297
+ * a slot either: validation runs over the WHOLE batch before the cap is
298
+ * applied, so a file that was never eligible cannot cost an eligible one its
299
+ * place, and `'too-many'` always names a file a slot would genuinely have
300
+ * taken. The alternative punishes
301
+ * a user for a mistake in one file by throwing away four good ones, and hands
302
+ * back no way to tell which was which. Every rejection is reported through
303
+ * `onError` and announced together in one `role="alert"`.
304
+ *
305
+ * Accessibility follows the sibling BELOW THE CAP: the real `<input
306
+ * type="file">` is `sr-only` but focusable and labelable, and never
307
+ * `aria-hidden`, so FormControl-injected ARIA and react-hook-form's focus on
308
+ * error both work while the picker is enabled. At the cap that changes, and
309
+ * the paragraph below says how.
310
+ * The file list sits OUTSIDE the click zone, so activating a remove control
311
+ * cannot also reopen the picker, and each remove control is named after its
312
+ * own file: a column of identical "Remove file" buttons tells a screen-reader
313
+ * user nothing about which row they are on.
314
+ *
315
+ * AT THE CAP the picker takes the native `disabled` attribute, and that DOES
316
+ * take it out of the tab order. This is the one state in which the input is
317
+ * not a focus target, and the one state in which focus-on-error cannot land on
318
+ * it, so it is a real cost rather than a free win. The cap has no counterpart
319
+ * in the single-file sibling, so the precedent followed here is
320
+ * `DateRangePicker`'s trigger: a control whose only job is to open a dialog has
321
+ * no state worth keeping focusable, and native `disabled` is what both removes
322
+ * it from the tab order and keeps the dialog shut. The alternative, an enabled
323
+ * picker that opens the file dialog and then refuses every file with
324
+ * `too-many`, is a control that lies about being available. What keeps the cap
325
+ * from being a dead end is the escape hatch: the remove controls answer to
326
+ * `disabled` alone and NEVER to the cap, so they stay focusable and removing
327
+ * one file reopens the picker.
328
+ */
329
+ 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) {
330
+ const internalRef = React.useRef(null);
331
+ React.useImperativeHandle(ref, () => internalRef.current);
332
+ const reactId = React.useId();
333
+ const inputId = id ?? reactId;
334
+ const errorId = `${inputId}-file-upload-error`;
335
+ const [dragActive, setDragActive] = React.useState(false);
336
+ const [errors, setErrors] = React.useState([]);
337
+ // The authoritative base for the next append. Props win on every render, so
338
+ // the host stays in control; the commit below also writes through, so a
339
+ // second batch that settles before the host has re-rendered still appends to
340
+ // the first batch's result instead of overwriting it.
341
+ const valueRef = React.useRef(value);
342
+ React.useEffect(() => {
343
+ valueRef.current = value;
344
+ });
345
+ // Superseding is NOT the contract here the way it is in the single-file
346
+ // sibling — batches accumulate, so an in-flight read is never stale. The
347
+ // readers are tracked for the one case that does have to stop them: an
348
+ // unmount. A read that lands afterwards would settle its batch and commit,
349
+ // calling the host's `onValueChange` for a component that no longer exists.
350
+ const readersRef = React.useRef(new Set());
351
+ React.useEffect(() => () => {
352
+ for (const reader of readersRef.current)
353
+ reader.abort();
354
+ readersRef.current.clear();
355
+ }, []);
356
+ const invalid = ariaInvalid || errors.length > 0;
357
+ // Resolve the announcements BEFORE deciding whether the alert exists: a
358
+ // consumer that returns nothing for every rejection is opting out of this
359
+ // surface, and an association pointing at an unrendered node is worse than
360
+ // none.
361
+ const messages = errors
362
+ .map((failure) => labels?.error
363
+ ? labels.error(failure)
364
+ : defaultMultipleErrorMessage(failure))
365
+ .filter((message) => Boolean(message));
366
+ // A plain join, never `cn`: tailwind-merge treats these as class names and
367
+ // would drop an id that happens to look like a conflicting utility.
368
+ const describedBy = [ariaDescribedby, messages.length > 0 ? errorId : undefined]
369
+ .filter(Boolean)
370
+ .join(' ') || undefined;
371
+ const full = maxFiles !== undefined && value.length >= maxFiles;
372
+ // Removing must stay possible at the cap, so only the PICKER closes.
373
+ const pickerDisabled = disabled || full;
374
+ const roomFor = (selected) => maxFiles === undefined
375
+ ? Number.POSITIVE_INFINITY
376
+ : Math.max(maxFiles - selected, 0);
377
+ // The cap is enforced HERE, at the only place that appends, because only the
378
+ // commit knows the base it lands on. `ingest` measures room too, but for an
379
+ // asynchronous batch it measures it BEFORE any read settles: two overlapping
380
+ // batches both see the pre-commit selection and would each believe they fit.
381
+ const commit = (accepted, rejections) => {
382
+ const room = roomFor(valueRef.current.length);
383
+ const fitting = accepted.slice(0, room);
384
+ const overflow = accepted[room];
385
+ const failures = overflow !== undefined && maxFiles !== undefined
386
+ ? [
387
+ ...rejections,
388
+ { kind: 'too-many', file: overflow.file, maxFiles }
389
+ ]
390
+ : rejections;
391
+ setErrors(failures);
392
+ for (const rejection of failures)
393
+ onError?.(rejection);
394
+ if (fitting.length === 0)
395
+ return;
396
+ const next = [...valueRef.current, ...fitting];
397
+ valueRef.current = next;
398
+ onValueChange(next);
399
+ };
400
+ const ingest = (incoming) => {
401
+ if (incoming.length === 0)
402
+ return;
403
+ const room = roomFor(valueRef.current.length);
404
+ const rejections = [];
405
+ // Validate EVERY file BEFORE the cap is applied. Slicing to the remaining
406
+ // room first would let a file that was never eligible consume a slot a
407
+ // good file could have used — one bad pick costing a good one, which is
408
+ // the opposite of a batch surviving its own casualties — and it would
409
+ // leave every file past the slice window neither validated nor reported.
410
+ // Validation is pure metadata (size and accept), so running it over files
411
+ // that may not fit costs nothing.
412
+ const eligible = [];
413
+ for (const file of incoming) {
414
+ const rejection = validateFile(file, { accept, maxSizeBytes });
415
+ // Continue rather than abort: one bad file must not cost the good ones.
416
+ if (rejection) {
417
+ rejections.push(rejection);
418
+ continue;
419
+ }
420
+ eligible.push(file);
421
+ }
422
+ // The cap then applies to the SURVIVORS. One rejection for the batch,
423
+ // naming the FIRST eligible file that did not fit: naming one already
424
+ // refused for its size or type would blame the cap for the wrong thing,
425
+ // and repeating it per overflowing file buries the actionable part.
426
+ if (eligible.length > room && maxFiles !== undefined) {
427
+ rejections.push({ kind: 'too-many', file: eligible[room], maxFiles });
428
+ }
429
+ const candidates = eligible.slice(0, room);
430
+ // Binary path: no decode, no reader, no retained garbage string.
431
+ if (readAs === 'none') {
432
+ commit(candidates.map((file) => ({ file, text: '' })), rejections);
433
+ return;
434
+ }
435
+ if (candidates.length === 0) {
436
+ commit([], rejections);
437
+ return;
438
+ }
439
+ // Slot-per-candidate so the emitted batch keeps PICK order regardless of
440
+ // the order the reads settle in. A null slot is a read that failed; the
441
+ // batch commits once every read has settled, one way or the other.
442
+ const slots = new Array(candidates.length).fill(null);
443
+ let remaining = candidates.length;
444
+ const settle = () => {
445
+ remaining -= 1;
446
+ if (remaining > 0)
447
+ return;
448
+ const accepted = [];
449
+ const readFailures = [];
450
+ slots.forEach((slot, index) => {
451
+ if (slot)
452
+ accepted.push(slot);
453
+ else
454
+ readFailures.push({ kind: 'read-failed', file: candidates[index] });
455
+ });
456
+ commit(accepted, [...rejections, ...readFailures]);
457
+ };
458
+ candidates.forEach((file, index) => {
459
+ const reader = new FileReader();
460
+ readersRef.current.add(reader);
461
+ // Deliberately no `onabort` handler: an aborted read must NOT settle,
462
+ // or the batch would commit at exactly the moment we are stopping it.
463
+ reader.onload = () => {
464
+ readersRef.current.delete(reader);
465
+ slots[index] = { file, text: String(reader.result ?? '') };
466
+ settle();
467
+ };
468
+ reader.onerror = () => {
469
+ readersRef.current.delete(reader);
470
+ settle();
471
+ };
472
+ reader.readAsText(file);
473
+ });
474
+ };
475
+ const onInputChange = (event) => {
476
+ const files = Array.from(event.target.files ?? []);
477
+ // Release the FileList the moment it has been read, or re-picking the same
478
+ // file is silently a no-op: the browser fires `change` only when the
479
+ // selection DIFFERS from what the input already holds.
480
+ event.target.value = '';
481
+ ingest(files);
482
+ };
483
+ // `preventDefault` FIRST, and unconditionally. Returning before it does
484
+ // not merely refuse the drop: nothing cancels the browser's OWN action for
485
+ // a dropped file, so the window navigates to the file and whatever the
486
+ // page held unsaved is gone. A zone the host turned off has to swallow the
487
+ // drop, never hand it back to the browser. `dropEffect` says so to the
488
+ // cursor while the file is still in the air.
489
+ const onDragOver = (event) => {
490
+ event.preventDefault();
491
+ if (disabled) {
492
+ event.dataTransfer.dropEffect = 'none';
493
+ return;
494
+ }
495
+ setDragActive(true);
496
+ };
497
+ const onDragLeave = (event) => {
498
+ event.preventDefault();
499
+ setDragActive(false);
500
+ };
501
+ const onDrop = (event) => {
502
+ event.preventDefault();
503
+ // Cleared before the gate, not after, so a host that disables the zone
504
+ // mid-drag does not leave it highlighted for a drag that can no longer
505
+ // land.
506
+ setDragActive(false);
507
+ if (disabled)
508
+ return;
509
+ // Deliberately NOT gated on `full`: a drop onto a full zone is answered
510
+ // with the too-many rejection, which says why, instead of nothing at all.
511
+ ingest(Array.from(event.dataTransfer.files ?? []));
512
+ };
513
+ // Mouse convenience only. A click that ORIGINATED on the input already opens
514
+ // the picker natively and bubbles up here, so ignore it or it opens twice.
515
+ const openPicker = (event) => {
516
+ if (pickerDisabled || event.target === internalRef.current)
517
+ return;
518
+ internalRef.current?.click();
519
+ };
520
+ const removeAt = (index) => {
521
+ // By index, not by name: two files can share a filename and identity is
522
+ // what the row actually stands for.
523
+ const next = valueRef.current.filter((_, position) => position !== index);
524
+ valueRef.current = next;
525
+ onValueChange(next);
526
+ };
527
+ 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 &&
528
+ 'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', pickerDisabled
529
+ ? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
530
+ : '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
531
+ ? labels.full(maxFiles)
532
+ : 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
533
+ ? labels.remove(entry.file)
534
+ : `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] }));
535
+ });
536
+ MultipleFileUpload.displayName = 'MultipleFileUpload';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lerianstudio/sindarian-ui",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.4",
4
4
  "description": "Sindarian UI - A UI library for Midaz Console",
5
5
  "license": "ISC",
6
6
  "author": {