react-dropzone 19.0.1 → 19.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -14,6 +14,12 @@ interface FileError {
14
14
  message: string;
15
15
  code: ErrorCode | string;
16
16
  }
17
+ /**
18
+ * What a custom `validator` returns: a single error, a list of errors, or `null` when the file
19
+ * passes. A validator may return the result directly (synchronous) or wrapped in a `Promise`
20
+ * (asynchronous, e.g. reading image dimensions or calling an external service).
21
+ */
22
+ type ValidatorResult = FileError | readonly FileError[] | null;
17
23
  declare enum ErrorCode {
18
24
  FileInvalidType = "file-invalid-type",
19
25
  FileTooLarge = "file-too-large",
@@ -48,7 +54,18 @@ type DropzoneOptions = Pick<React.HTMLProps<HTMLElement>, SharedProps> & {
48
54
  onFileDialogCancel?: () => void;
49
55
  onFileDialogOpen?: () => void;
50
56
  onError?: (err: Error) => void;
51
- validator?: <T extends File>(file: T) => FileError | readonly FileError[] | null;
57
+ /**
58
+ * Custom validation, run once per file on drop/selection. Return `null` to accept the file, or a
59
+ * {@link FileError} (or array of them) to reject it. May be `async` (return a `Promise`) to support
60
+ * checks that can't run synchronously - e.g. reading image dimensions, inspecting file contents,
61
+ * or calling an external service. While an async validator is pending, {@link DropzoneState.isProcessing}
62
+ * is `true`, and `onDrop`/`onDropAccepted`/`onDropRejected` fire only once it settles. If the
63
+ * validator throws or rejects, `onError` is called and the drop is discarded.
64
+ *
65
+ * Note: the validator never runs during a drag (a `DataTransferItem` has no name/size), so a
66
+ * validator-configured dropzone is `isDragUnknown` until drop.
67
+ */
68
+ validator?: <T extends File>(file: T) => ValidatorResult | Promise<ValidatorResult>;
52
69
  /**
53
70
  * Override the message of any rejection error (built-in or custom). Called once per error;
54
71
  * receives the error and the file it belongs to and returns the message to use. Return
@@ -70,6 +87,14 @@ type DropzoneState = DropzoneRef & {
70
87
  isDragUnknown: boolean;
71
88
  isDragGlobal: boolean;
72
89
  isFileDialogActive: boolean;
90
+ /**
91
+ * `true` while a drop/selection is being processed asynchronously - i.e. while `getFilesFromEvent`
92
+ * reads the files and/or an async {@link DropzoneOptions.validator} runs. Spans the whole pipeline,
93
+ * from when files start being read until validation settles. When both are synchronous (the default
94
+ * `getFilesFromEvent` with no/async-free validator) the work resolves within a microtask, so it's
95
+ * only observable for genuinely async work. Use it to show a spinner or disable UI while processing.
96
+ */
97
+ isProcessing: boolean;
73
98
  acceptedFiles: readonly FileWithPath[];
74
99
  fileRejections: readonly FileRejection[];
75
100
  rootRef: React.RefObject<HTMLElement>;
@@ -120,5 +145,5 @@ declare const Dropzone: React.ForwardRefExoticComponent<DropzoneProps & React.Re
120
145
  */
121
146
  declare function useDropzone(props?: DropzoneOptions): DropzoneState;
122
147
  //#endregion
123
- export { type Accept, DropEvent, DropzoneInputProps, DropzoneOptions, DropzoneProps, DropzoneRef, DropzoneRootProps, DropzoneState, ErrorCode, type FileError, FileRejection, type FileWithPath, Dropzone as default, useDropzone };
148
+ export { type Accept, DropEvent, DropzoneInputProps, DropzoneOptions, DropzoneProps, DropzoneRef, DropzoneRootProps, DropzoneState, ErrorCode, type FileError, FileRejection, type FileWithPath, type ValidatorResult, Dropzone as default, useDropzone };
124
149
  //# sourceMappingURL=index.d.cts.map
package/dist/index.d.ts CHANGED
@@ -14,6 +14,12 @@ interface FileError {
14
14
  message: string;
15
15
  code: ErrorCode | string;
16
16
  }
17
+ /**
18
+ * What a custom `validator` returns: a single error, a list of errors, or `null` when the file
19
+ * passes. A validator may return the result directly (synchronous) or wrapped in a `Promise`
20
+ * (asynchronous, e.g. reading image dimensions or calling an external service).
21
+ */
22
+ type ValidatorResult = FileError | readonly FileError[] | null;
17
23
  declare enum ErrorCode {
18
24
  FileInvalidType = "file-invalid-type",
19
25
  FileTooLarge = "file-too-large",
@@ -48,7 +54,18 @@ type DropzoneOptions = Pick<React.HTMLProps<HTMLElement>, SharedProps> & {
48
54
  onFileDialogCancel?: () => void;
49
55
  onFileDialogOpen?: () => void;
50
56
  onError?: (err: Error) => void;
51
- validator?: <T extends File>(file: T) => FileError | readonly FileError[] | null;
57
+ /**
58
+ * Custom validation, run once per file on drop/selection. Return `null` to accept the file, or a
59
+ * {@link FileError} (or array of them) to reject it. May be `async` (return a `Promise`) to support
60
+ * checks that can't run synchronously - e.g. reading image dimensions, inspecting file contents,
61
+ * or calling an external service. While an async validator is pending, {@link DropzoneState.isProcessing}
62
+ * is `true`, and `onDrop`/`onDropAccepted`/`onDropRejected` fire only once it settles. If the
63
+ * validator throws or rejects, `onError` is called and the drop is discarded.
64
+ *
65
+ * Note: the validator never runs during a drag (a `DataTransferItem` has no name/size), so a
66
+ * validator-configured dropzone is `isDragUnknown` until drop.
67
+ */
68
+ validator?: <T extends File>(file: T) => ValidatorResult | Promise<ValidatorResult>;
52
69
  /**
53
70
  * Override the message of any rejection error (built-in or custom). Called once per error;
54
71
  * receives the error and the file it belongs to and returns the message to use. Return
@@ -70,6 +87,14 @@ type DropzoneState = DropzoneRef & {
70
87
  isDragUnknown: boolean;
71
88
  isDragGlobal: boolean;
72
89
  isFileDialogActive: boolean;
90
+ /**
91
+ * `true` while a drop/selection is being processed asynchronously - i.e. while `getFilesFromEvent`
92
+ * reads the files and/or an async {@link DropzoneOptions.validator} runs. Spans the whole pipeline,
93
+ * from when files start being read until validation settles. When both are synchronous (the default
94
+ * `getFilesFromEvent` with no/async-free validator) the work resolves within a microtask, so it's
95
+ * only observable for genuinely async work. Use it to show a spinner or disable UI while processing.
96
+ */
97
+ isProcessing: boolean;
73
98
  acceptedFiles: readonly FileWithPath[];
74
99
  fileRejections: readonly FileRejection[];
75
100
  rootRef: React.RefObject<HTMLElement>;
@@ -120,5 +145,5 @@ declare const Dropzone: React.ForwardRefExoticComponent<DropzoneProps & React.Re
120
145
  */
121
146
  declare function useDropzone(props?: DropzoneOptions): DropzoneState;
122
147
  //#endregion
123
- export { type Accept, DropEvent, DropzoneInputProps, DropzoneOptions, DropzoneProps, DropzoneRef, DropzoneRootProps, DropzoneState, ErrorCode, type FileError, FileRejection, type FileWithPath, Dropzone as default, useDropzone };
148
+ export { type Accept, DropEvent, DropzoneInputProps, DropzoneOptions, DropzoneProps, DropzoneRef, DropzoneRootProps, DropzoneState, ErrorCode, type FileError, FileRejection, type FileWithPath, type ValidatorResult, Dropzone as default, useDropzone };
124
149
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -97,6 +97,13 @@ function isDefined(value) {
97
97
  return value !== void 0 && value !== null;
98
98
  }
99
99
  /**
100
+ * Check if a value is thenable (Promise-like), used to tell a synchronous validator result from an
101
+ * asynchronous one.
102
+ */
103
+ function isThenable(value) {
104
+ return value != null && typeof value.then === "function";
105
+ }
106
+ /**
100
107
  * Classify a set of dragged files for the `isDragAccept`/`isDragReject`/`isDragUnknown` states.
101
108
  *
102
109
  * During `dragenter`/`dragover` the browser only exposes `DataTransferItem`s, which carry a MIME
@@ -129,7 +136,10 @@ function isPropagationStopped(event) {
129
136
  }
130
137
  function isEvtWithFiles(event) {
131
138
  if (!event.dataTransfer) return !!event.target && !!event.target.files;
132
- return Array.prototype.some.call(event.dataTransfer.types, (type) => type === "Files" || type === "application/x-moz-file");
139
+ return Array.prototype.some.call(event.dataTransfer.types, (type) => type === "Files" || type === "application/x-moz-file") || Array.prototype.some.call(event.dataTransfer.items ?? [], isKindFile);
140
+ }
141
+ function isKindFile(item) {
142
+ return typeof item === "object" && item !== null && item.kind === "file";
133
143
  }
134
144
  function onDocumentDragOver(event) {
135
145
  event.preventDefault();
@@ -278,6 +288,7 @@ const initialState = {
278
288
  isDragReject: false,
279
289
  isDragUnknown: false,
280
290
  isDragGlobal: false,
291
+ isProcessing: false,
281
292
  acceptedFiles: [],
282
293
  fileRejections: []
283
294
  };
@@ -311,6 +322,23 @@ function useDropzone(props = {}) {
311
322
  const inputRef = useRef(null);
312
323
  const [state, dispatch] = useReducer(reducer, initialState);
313
324
  const { isFocused, isFileDialogActive } = state;
325
+ const processingAbortRef = useRef(null);
326
+ const beginProcessing = useCallback(() => {
327
+ processingAbortRef.current?.abort();
328
+ const controller = new AbortController();
329
+ processingAbortRef.current = controller;
330
+ dispatch({
331
+ type: "setProcessing",
332
+ isProcessing: true
333
+ });
334
+ return controller.signal;
335
+ }, []);
336
+ const endProcessing = useCallback((signal) => {
337
+ if (!signal.aborted) dispatch({
338
+ type: "setProcessing",
339
+ isProcessing: false
340
+ });
341
+ }, []);
314
342
  const fsAccessApiWorksRef = useRef(typeof window !== "undefined" && window.isSecureContext && useFsAccessApi && canUseFileSystemAccessAPI());
315
343
  const onWindowFocus = () => {
316
344
  if (!fsAccessApiWorksRef.current && isFileDialogActive) setTimeout(() => {
@@ -476,42 +504,72 @@ function useDropzone(props = {}) {
476
504
  onDragLeave,
477
505
  noDragEventsBubbling
478
506
  ]);
479
- const setFiles = useCallback((files, event) => {
480
- const acceptedFiles = [];
481
- const fileRejections = [];
507
+ const setFiles = useCallback(async (files, event, signal) => {
482
508
  const localizeError = (error, file) => getErrorMessage ? {
483
509
  ...error,
484
510
  message: getErrorMessage(error, file)
485
511
  } : error;
486
- files.forEach((file) => {
487
- const [accepted, acceptError] = fileAccepted(file, inputAcceptAttr);
488
- const [sizeMatch, sizeError] = fileMatchSize(file, minSize, maxSize);
489
- const customErrors = validator ? validator(file) : null;
490
- if (accepted && sizeMatch && !customErrors) acceptedFiles.push(file);
491
- else {
492
- let errors = [acceptError, sizeError];
493
- if (customErrors) errors = errors.concat(customErrors);
512
+ const commit = (results) => {
513
+ const acceptedFiles = [];
514
+ const fileRejections = [];
515
+ results.forEach(({ file, accepted, acceptError, sizeMatch, sizeError, customErrors }) => {
516
+ if (accepted && sizeMatch && !customErrors) acceptedFiles.push(file);
517
+ else {
518
+ let errors = [acceptError, sizeError];
519
+ if (customErrors) errors = errors.concat(customErrors);
520
+ fileRejections.push({
521
+ file,
522
+ errors: errors.filter((e) => e != null).map((error) => localizeError(error, file))
523
+ });
524
+ }
525
+ });
526
+ const acceptedFilesLimit = multiple ? maxFiles >= 1 ? maxFiles : Number.POSITIVE_INFINITY : 1;
527
+ if (acceptedFiles.length > acceptedFilesLimit) acceptedFiles.splice(acceptedFilesLimit).forEach((file) => {
494
528
  fileRejections.push({
495
529
  file,
496
- errors: errors.filter((e) => e != null).map((error) => localizeError(error, file))
530
+ errors: [localizeError(TOO_MANY_FILES_REJECTION, file)]
497
531
  });
498
- }
499
- });
500
- const acceptedFilesLimit = multiple ? maxFiles >= 1 ? maxFiles : Number.POSITIVE_INFINITY : 1;
501
- if (acceptedFiles.length > acceptedFilesLimit) acceptedFiles.splice(acceptedFilesLimit).forEach((file) => {
502
- fileRejections.push({
503
- file,
504
- errors: [localizeError(TOO_MANY_FILES_REJECTION, file)]
505
532
  });
533
+ dispatch({
534
+ acceptedFiles,
535
+ fileRejections,
536
+ type: "setFiles"
537
+ });
538
+ if (onDrop) onDrop(acceptedFiles, fileRejections, event);
539
+ if (fileRejections.length > 0 && onDropRejected) onDropRejected(fileRejections, event);
540
+ if (acceptedFiles.length > 0 && onDropAccepted) onDropAccepted(acceptedFiles, event);
541
+ };
542
+ const pending = files.map((file) => {
543
+ const [accepted, acceptError] = fileAccepted(file, inputAcceptAttr);
544
+ const [sizeMatch, sizeError] = fileMatchSize(file, minSize, maxSize);
545
+ return {
546
+ file,
547
+ accepted,
548
+ acceptError,
549
+ sizeMatch,
550
+ sizeError,
551
+ customErrors: validator ? validator(file) : null
552
+ };
506
553
  });
507
- dispatch({
508
- acceptedFiles,
509
- fileRejections,
510
- type: "setFiles"
511
- });
512
- if (onDrop) onDrop(acceptedFiles, fileRejections, event);
513
- if (fileRejections.length > 0 && onDropRejected) onDropRejected(fileRejections, event);
514
- if (acceptedFiles.length > 0 && onDropAccepted) onDropAccepted(acceptedFiles, event);
554
+ if (!pending.some(({ customErrors }) => isThenable(customErrors))) {
555
+ commit(pending);
556
+ return;
557
+ }
558
+ let results;
559
+ try {
560
+ results = await Promise.all(pending.map(async ({ customErrors, ...rest }) => ({
561
+ ...rest,
562
+ customErrors: await customErrors
563
+ })));
564
+ } catch (e) {
565
+ if (!signal.aborted) {
566
+ endProcessing(signal);
567
+ onErrCb(e);
568
+ }
569
+ return;
570
+ }
571
+ if (signal.aborted) return;
572
+ commit(results);
515
573
  }, [
516
574
  dispatch,
517
575
  multiple,
@@ -523,23 +581,39 @@ function useDropzone(props = {}) {
523
581
  onDropAccepted,
524
582
  onDropRejected,
525
583
  validator,
526
- getErrorMessage
584
+ getErrorMessage,
585
+ onErrCb,
586
+ endProcessing
527
587
  ]);
528
588
  const onDropCb = useCallback((event) => {
529
589
  event.preventDefault();
530
590
  event.persist?.();
531
591
  stopPropagation(event);
532
592
  dragTargetsRef.current = [];
533
- if (isEvtWithFiles(event)) Promise.resolve(getFilesFromEvent(event)).then((files) => {
534
- if (isPropagationStopped(event) && !noDragEventsBubbling) return;
535
- setFiles(files, event);
536
- }).catch((e) => onErrCb(e));
537
593
  dispatch({ type: "reset" });
594
+ if (isEvtWithFiles(event)) {
595
+ const signal = beginProcessing();
596
+ Promise.resolve(getFilesFromEvent(event)).then((files) => {
597
+ if (signal.aborted) return;
598
+ if (isPropagationStopped(event) && !noDragEventsBubbling) {
599
+ endProcessing(signal);
600
+ return;
601
+ }
602
+ return setFiles(files, event, signal);
603
+ }).catch((e) => {
604
+ if (!signal.aborted) {
605
+ endProcessing(signal);
606
+ onErrCb(e);
607
+ }
608
+ });
609
+ }
538
610
  }, [
539
611
  getFilesFromEvent,
540
612
  setFiles,
541
613
  onErrCb,
542
- noDragEventsBubbling
614
+ noDragEventsBubbling,
615
+ beginProcessing,
616
+ endProcessing
543
617
  ]);
544
618
  const openFileDialog = useCallback(() => {
545
619
  if (fsAccessApiWorksRef.current) {
@@ -549,10 +623,16 @@ function useDropzone(props = {}) {
549
623
  multiple,
550
624
  types: pickerTypes
551
625
  };
552
- window.showOpenFilePicker(opts).then((handles) => getFilesFromEvent(handles)).then((files) => {
553
- setFiles(files, null);
626
+ let signal;
627
+ window.showOpenFilePicker(opts).then((handles) => {
628
+ signal = beginProcessing();
629
+ return getFilesFromEvent(handles);
630
+ }).then((files) => {
554
631
  dispatch({ type: "closeDialog" });
632
+ if (signal.aborted) return;
633
+ return setFiles(files, null, signal);
555
634
  }).catch((e) => {
635
+ if (signal) endProcessing(signal);
556
636
  if (isAbort(e)) {
557
637
  onFileDialogCancelCb(e);
558
638
  dispatch({ type: "closeDialog" });
@@ -580,7 +660,9 @@ function useDropzone(props = {}) {
580
660
  setFiles,
581
661
  onErrCb,
582
662
  pickerTypes,
583
- multiple
663
+ multiple,
664
+ beginProcessing,
665
+ endProcessing
584
666
  ]);
585
667
  const onKeyDownCb = useCallback((event) => {
586
668
  if (!rootRef.current?.isEqualNode(event.target)) return;
@@ -707,10 +789,15 @@ function reducer(state, action) {
707
789
  isDragReject: action.isDragReject,
708
790
  isDragUnknown: action.isDragUnknown
709
791
  };
792
+ case "setProcessing": return {
793
+ ...state,
794
+ isProcessing: action.isProcessing
795
+ };
710
796
  case "setFiles": return {
711
797
  ...state,
712
798
  acceptedFiles: action.acceptedFiles,
713
799
  fileRejections: action.fileRejections,
800
+ isProcessing: false,
714
801
  isDragReject: false,
715
802
  isDragUnknown: false
716
803
  };