(\n ({ className, children, ...props }, forwardedRef) => {\n const context = useDropzoneContext();\n\n if (!context) {\n throw new Error(\"DropzoneArea must be used within a Dropzone\");\n }\n\n const { onFocus, onBlur, onDragEnter, onDragLeave, onDrop, ref } =\n context.getRootProps();\n\n return (\n // A11y behavior is handled through Trigger. All of these are only relevant to drag and drop which means this should be fine?\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions\n {\n // TODO: test if this actually works?\n ref.current = instance;\n if (typeof forwardedRef === \"function\") {\n forwardedRef(instance);\n } else if (forwardedRef) {\n forwardedRef.current = instance;\n }\n }}\n onFocus={onFocus}\n onBlur={onBlur}\n onDragEnter={onDragEnter}\n onDragLeave={onDragLeave}\n onDrop={onDrop}\n {...props}\n aria-label=\"dropzone\"\n className={cn(\n \"flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n context.isDragActive && \"animate-pulse bg-black/5\",\n context.isInvalid && \"border-destructive\",\n className,\n )}\n >\n {children}\n
\n );\n },\n);\nDropZoneArea.displayName = \"DropZoneArea\";\n\nexport interface DropzoneDescriptionProps\n extends React.HTMLAttributes {}\n\nconst DropzoneDescription = forwardRef<\n HTMLParagraphElement,\n DropzoneDescriptionProps\n>((props, ref) => {\n const { className, ...rest } = props;\n const context = useDropzoneContext();\n if (!context) {\n throw new Error(\"DropzoneDescription must be used within a Dropzone\");\n }\n\n return (\n \n );\n});\nDropzoneDescription.displayName = \"DropzoneDescription\";\n\ninterface DropzoneFileListContext {\n onRemoveFile: () => Promise;\n onRetry: () => Promise;\n fileStatus: FileStatus;\n canRetry: boolean;\n dropzoneId: string;\n messageId: string;\n}\n\nconst DropzoneFileListContext = createContext<\n DropzoneFileListContext\n>({\n onRemoveFile: async () => {},\n onRetry: async () => {},\n fileStatus: {} as FileStatus,\n canRetry: false,\n dropzoneId: \"\",\n messageId: \"\",\n});\n\nconst useDropzoneFileListContext = () => {\n return useContext(DropzoneFileListContext);\n};\n\ninterface DropZoneFileListProps\n extends React.OlHTMLAttributes {}\n\nconst DropzoneFileList = forwardRef(\n (props, ref) => {\n const context = useDropzoneContext();\n if (!context) {\n throw new Error(\"DropzoneFileList must be used within a Dropzone\");\n }\n return (\n \n {props.children}\n
\n );\n },\n);\nDropzoneFileList.displayName = \"DropzoneFileList\";\n\ninterface DropzoneFileListItemProps\n extends React.LiHTMLAttributes {\n file: FileStatus;\n}\n\nconst DropzoneFileListItem = forwardRef<\n HTMLLIElement,\n DropzoneFileListItemProps\n>(({ className, ...props }, ref) => {\n const fileId = props.file.id;\n const {\n onRemoveFile: cOnRemoveFile,\n onRetry: cOnRetry,\n getFileMessageId: cGetFileMessageId,\n canRetry: cCanRetry,\n inputId: cInputId,\n } = useDropzoneContext();\n\n const onRemoveFile = useCallback(\n () => cOnRemoveFile(fileId),\n [fileId, cOnRemoveFile],\n );\n const onRetry = useCallback(() => cOnRetry(fileId), [fileId, cOnRetry]);\n const messageId = cGetFileMessageId(fileId);\n const isInvalid = props.file.status === \"error\";\n const canRetry = useMemo(() => cCanRetry(fileId), [fileId, cCanRetry]);\n return (\n \n \n {props.children}\n \n \n );\n});\nDropzoneFileListItem.displayName = \"DropzoneFileListItem\";\n\ninterface DropzoneFileMessageProps\n extends React.HTMLAttributes {}\n\nconst DropzoneFileMessage = forwardRef<\n HTMLParagraphElement,\n DropzoneFileMessageProps\n>((props, ref) => {\n const { children, ...rest } = props;\n const context = useDropzoneFileListContext();\n if (!context) {\n throw new Error(\n \"DropzoneFileMessage must be used within a DropzoneFileListItem\",\n );\n }\n\n const body =\n context.fileStatus.status === \"error\"\n ? String(context.fileStatus.error)\n : children;\n return (\n \n {body}\n
\n );\n});\nDropzoneFileMessage.displayName = \"DropzoneFileMessage\";\ninterface DropzoneMessageProps\n extends React.HTMLAttributes {}\n\nconst DropzoneMessage = forwardRef(\n (props, ref) => {\n const { children, ...rest } = props;\n const context = useDropzoneContext();\n if (!context) {\n throw new Error(\"DropzoneRootMessage must be used within a Dropzone\");\n }\n\n const body = context.rootError ? String(context.rootError) : children;\n return (\n \n {body}\n
\n );\n },\n);\nDropzoneMessage.displayName = \"DropzoneMessage\";\n\ninterface DropzoneRemoveFileProps extends ButtonProps {}\n\nconst DropzoneRemoveFile = forwardRef<\n HTMLButtonElement,\n DropzoneRemoveFileProps\n>(({ className, ...props }, ref) => {\n const context = useDropzoneFileListContext();\n if (!context) {\n throw new Error(\n \"DropzoneRemoveFile must be used within a DropzoneFileListItem\",\n );\n }\n return (\n \n );\n});\nDropzoneRemoveFile.displayName = \"DropzoneRemoveFile\";\n\ninterface DropzoneRetryFileProps extends ButtonProps {}\n\nconst DropzoneRetryFile = forwardRef(\n ({ className, ...props }, ref) => {\n const context = useDropzoneFileListContext();\n\n if (!context) {\n throw new Error(\n \"DropzoneRetryFile must be used within a DropzoneFileListItem\",\n );\n }\n\n const canRetry = context.canRetry;\n\n return (\n \n );\n },\n);\nDropzoneRetryFile.displayName = \"DropzoneRetryFile\";\n\ninterface DropzoneTriggerProps\n extends React.LabelHTMLAttributes {}\n\nconst DropzoneTrigger = forwardRef(\n ({ className, children, ...props }, ref) => {\n const context = useDropzoneContext();\n if (!context) {\n throw new Error(\"DropzoneTrigger must be used within a Dropzone\");\n }\n\n const { fileStatuses, getFileMessageId } = context;\n\n const fileMessageIds = useMemo(\n () =>\n fileStatuses\n .filter((file) => file.status === \"error\")\n .map((file) => getFileMessageId(file.id)),\n [fileStatuses, getFileMessageId],\n );\n\n return (\n \n );\n },\n);\nDropzoneTrigger.displayName = \"DropzoneTrigger\";\n\ninterface InfiniteProgressProps extends React.HTMLAttributes {\n status: \"pending\" | \"success\" | \"error\";\n}\n\nconst valueTextMap = {\n pending: \"indeterminate\",\n success: \"100%\",\n error: \"error\",\n};\n\nconst InfiniteProgress = forwardRef(\n ({ className, ...props }, ref) => {\n const done = props.status === \"success\" || props.status === \"error\";\n const error = props.status === \"error\";\n return (\n \n );\n },\n);\nInfiniteProgress.displayName = \"InfiniteProgress\";\n\nexport {\n Dropzone,\n DropZoneArea,\n DropzoneDescription,\n DropzoneFileList,\n DropzoneFileListItem,\n DropzoneFileMessage,\n DropzoneMessage,\n DropzoneRemoveFile,\n DropzoneRetryFile,\n DropzoneTrigger,\n InfiniteProgress,\n useDropzone,\n};\n",
20 | "type": "registry:ui"
21 | }
22 | ]
23 | }
--------------------------------------------------------------------------------
/components/dropzone.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-empty-object-type */
2 | import { cn } from "@/lib/utils";
3 | import {
4 | createContext,
5 | forwardRef,
6 | useCallback,
7 | useContext,
8 | useId,
9 | useMemo,
10 | useReducer,
11 | useState,
12 | } from "react";
13 | import {
14 | Accept,
15 | FileRejection,
16 | useDropzone as rootUseDropzone,
17 | } from "react-dropzone";
18 | import { Button, ButtonProps } from "./ui/button";
19 |
20 | type DropzoneResult =
21 | | {
22 | status: "pending";
23 | }
24 | | {
25 | status: "error";
26 | error: TUploadError;
27 | }
28 | | {
29 | status: "success";
30 | result: TUploadRes;
31 | };
32 |
33 | export type FileStatus = {
34 | id: string;
35 | fileName: string;
36 | file: File;
37 | tries: number;
38 | } & (
39 | | {
40 | status: "pending";
41 | result?: undefined;
42 | error?: undefined;
43 | }
44 | | {
45 | status: "error";
46 | error: TUploadError;
47 | result?: undefined;
48 | }
49 | | {
50 | status: "success";
51 | result: TUploadRes;
52 | error?: undefined;
53 | }
54 | );
55 |
56 | const fileStatusReducer = (
57 | state: FileStatus[],
58 | action:
59 | | {
60 | type: "add";
61 | id: string;
62 | fileName: string;
63 | file: File;
64 | }
65 | | {
66 | type: "remove";
67 | id: string;
68 | }
69 | | ({
70 | type: "update-status";
71 | id: string;
72 | } & DropzoneResult),
73 | ): FileStatus[] => {
74 | switch (action.type) {
75 | case "add":
76 | return [
77 | ...state,
78 | {
79 | id: action.id,
80 | fileName: action.fileName,
81 | file: action.file,
82 | status: "pending",
83 | tries: 1,
84 | },
85 | ];
86 | case "remove":
87 | return state.filter((fileStatus) => fileStatus.id !== action.id);
88 | case "update-status":
89 | return state.map((fileStatus) => {
90 | if (fileStatus.id === action.id) {
91 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
92 | const { id, type, ...rest } = action;
93 | return {
94 | ...fileStatus,
95 | ...rest,
96 | tries:
97 | action.status === "pending"
98 | ? fileStatus.tries + 1
99 | : fileStatus.tries,
100 | } as FileStatus;
101 | }
102 | return fileStatus;
103 | });
104 | }
105 | };
106 | type DropZoneErrorCode = (typeof dropZoneErrorCodes)[number];
107 | const dropZoneErrorCodes = [
108 | "file-invalid-type",
109 | "file-too-large",
110 | "file-too-small",
111 | "too-many-files",
112 | ] as const;
113 |
114 | const getDropZoneErrorCodes = (fileRejections: FileRejection[]) => {
115 | const errors = fileRejections.map((rejection) => {
116 | return rejection.errors
117 | .filter((error) =>
118 | dropZoneErrorCodes.includes(error.code as DropZoneErrorCode),
119 | )
120 | .map((error) => error.code) as DropZoneErrorCode[];
121 | });
122 | return Array.from(new Set(errors.flat()));
123 | };
124 |
125 | const getRootError = (
126 | errorCodes: DropZoneErrorCode[],
127 | limits: {
128 | accept?: Accept;
129 | maxSize?: number;
130 | minSize?: number;
131 | maxFiles?: number;
132 | },
133 | ) => {
134 | const errors = errorCodes.map((error) => {
135 | switch (error) {
136 | case "file-invalid-type":
137 | const acceptedTypes = Object.values(limits.accept ?? {})
138 | .flat()
139 | .join(", ");
140 | return `only ${acceptedTypes} are allowed`;
141 | case "file-too-large":
142 | const maxMb = limits.maxSize
143 | ? (limits.maxSize / (1024 * 1024)).toFixed(2)
144 | : "infinite?";
145 | return `max size is ${maxMb}MB`;
146 | case "file-too-small":
147 | const roundedMinSize = limits.minSize
148 | ? (limits.minSize / (1024 * 1024)).toFixed(2)
149 | : "negative?";
150 | return `min size is ${roundedMinSize}MB`;
151 | case "too-many-files":
152 | return `max ${limits.maxFiles} files`;
153 | }
154 | });
155 | const joinedErrors = errors.join(", ");
156 | return joinedErrors.charAt(0).toUpperCase() + joinedErrors.slice(1);
157 | };
158 |
159 | type UseDropzoneProps = {
160 | onDropFile: (
161 | file: File,
162 | ) => Promise<
163 | Exclude, { status: "pending" }>
164 | >;
165 | onRemoveFile?: (id: string) => void | Promise;
166 | onFileUploaded?: (result: TUploadRes) => void;
167 | onFileUploadError?: (error: TUploadError) => void;
168 | onAllUploaded?: () => void;
169 | onRootError?: (error: string | undefined) => void;
170 | maxRetryCount?: number;
171 | autoRetry?: boolean;
172 | validation?: {
173 | accept?: Accept;
174 | minSize?: number;
175 | maxSize?: number;
176 | maxFiles?: number;
177 | };
178 | shiftOnMaxFiles?: boolean;
179 | } & (TUploadError extends string
180 | ? {
181 | shapeUploadError?: (error: TUploadError) => string | void;
182 | }
183 | : {
184 | shapeUploadError: (error: TUploadError) => string | void;
185 | });
186 |
187 | interface UseDropzoneReturn {
188 | getRootProps: ReturnType["getRootProps"];
189 | getInputProps: ReturnType["getInputProps"];
190 | onRemoveFile: (id: string) => Promise;
191 | onRetry: (id: string) => Promise;
192 | canRetry: (id: string) => boolean;
193 | fileStatuses: FileStatus[];
194 | isInvalid: boolean;
195 | isDragActive: boolean;
196 | rootError: string | undefined;
197 | inputId: string;
198 | rootMessageId: string;
199 | rootDescriptionId: string;
200 | getFileMessageId: (id: string) => string;
201 | }
202 |
203 | const useDropzone = (
204 | props: UseDropzoneProps,
205 | ): UseDropzoneReturn => {
206 | const {
207 | onDropFile: pOnDropFile,
208 | onRemoveFile: pOnRemoveFile,
209 | shapeUploadError: pShapeUploadError,
210 | onFileUploaded: pOnFileUploaded,
211 | onFileUploadError: pOnFileUploadError,
212 | onAllUploaded: pOnAllUploaded,
213 | onRootError: pOnRootError,
214 | maxRetryCount,
215 | autoRetry,
216 | validation,
217 | shiftOnMaxFiles,
218 | } = props;
219 |
220 | const inputId = useId();
221 | const rootMessageId = `${inputId}-root-message`;
222 | const rootDescriptionId = `${inputId}-description`;
223 | const [rootError, _setRootError] = useState(undefined);
224 |
225 | const setRootError = useCallback(
226 | (error: string | undefined) => {
227 | _setRootError(error);
228 | if (pOnRootError !== undefined) {
229 | pOnRootError(error);
230 | }
231 | },
232 | [pOnRootError, _setRootError],
233 | );
234 |
235 | const [fileStatuses, dispatch] = useReducer(fileStatusReducer, []);
236 |
237 | const isInvalid = useMemo(() => {
238 | return (
239 | fileStatuses.filter((file) => file.status === "error").length > 0 ||
240 | rootError !== undefined
241 | );
242 | }, [fileStatuses, rootError]);
243 |
244 | const _uploadFile = useCallback(
245 | async (file: File, id: string, tries = 0) => {
246 | const result = await pOnDropFile(file);
247 |
248 | if (result.status === "error") {
249 | if (autoRetry === true && tries < (maxRetryCount ?? Infinity)) {
250 | dispatch({ type: "update-status", id, status: "pending" });
251 | return _uploadFile(file, id, tries + 1);
252 | }
253 |
254 | dispatch({
255 | type: "update-status",
256 | id,
257 | status: "error",
258 | error:
259 | pShapeUploadError !== undefined
260 | ? pShapeUploadError(result.error)
261 | : result.error,
262 | });
263 | if (pOnFileUploadError !== undefined) {
264 | pOnFileUploadError(result.error);
265 | }
266 | return;
267 | }
268 | if (pOnFileUploaded !== undefined) {
269 | pOnFileUploaded(result.result);
270 | }
271 | dispatch({
272 | type: "update-status",
273 | id,
274 | ...result,
275 | });
276 | },
277 | [
278 | autoRetry,
279 | maxRetryCount,
280 | pOnDropFile,
281 | pShapeUploadError,
282 | pOnFileUploadError,
283 | pOnFileUploaded,
284 | ],
285 | );
286 |
287 | const onRemoveFile = useCallback(
288 | async (id: string) => {
289 | await pOnRemoveFile?.(id);
290 | dispatch({ type: "remove", id });
291 | },
292 | [pOnRemoveFile],
293 | );
294 |
295 | const canRetry = useCallback(
296 | (id: string) => {
297 | const fileStatus = fileStatuses.find((file) => file.id === id);
298 | return (
299 | fileStatus?.status === "error" &&
300 | fileStatus.tries < (maxRetryCount ?? Infinity)
301 | );
302 | },
303 | [fileStatuses, maxRetryCount],
304 | );
305 |
306 | const onRetry = useCallback(
307 | async (id: string) => {
308 | if (!canRetry(id)) {
309 | return;
310 | }
311 | dispatch({ type: "update-status", id, status: "pending" });
312 | const fileStatus = fileStatuses.find((file) => file.id === id);
313 | if (!fileStatus || fileStatus.status !== "error") {
314 | return;
315 | }
316 | await _uploadFile(fileStatus.file, id);
317 | },
318 | [canRetry, fileStatuses, _uploadFile],
319 | );
320 |
321 | const getFileMessageId = (id: string) => `${inputId}-${id}-message`;
322 |
323 | const dropzone = rootUseDropzone({
324 | accept: validation?.accept,
325 | minSize: validation?.minSize,
326 | maxSize: validation?.maxSize,
327 | onDropAccepted: async (newFiles) => {
328 | setRootError(undefined);
329 |
330 | // useDropzone hook only checks max file count per group of uploaded files, allows going over if in multiple batches
331 | const fileCount = fileStatuses.length;
332 | const maxNewFiles =
333 | validation?.maxFiles === undefined
334 | ? Infinity
335 | : validation?.maxFiles - fileCount;
336 |
337 | if (maxNewFiles < newFiles.length) {
338 | if (shiftOnMaxFiles === true) {
339 | } else {
340 | setRootError(getRootError(["too-many-files"], validation ?? {}));
341 | }
342 | }
343 |
344 | const slicedNewFiles =
345 | shiftOnMaxFiles === true ? newFiles : newFiles.slice(0, maxNewFiles);
346 |
347 | const onDropFilePromises = slicedNewFiles.map(async (file, index) => {
348 | if (fileCount + 1 > maxNewFiles) {
349 | await onRemoveFile(fileStatuses[index].id);
350 | }
351 |
352 | const id = crypto.randomUUID();
353 | dispatch({ type: "add", fileName: file.name, file, id });
354 | await _uploadFile(file, id);
355 | });
356 |
357 | await Promise.all(onDropFilePromises);
358 | if (pOnAllUploaded !== undefined) {
359 | pOnAllUploaded();
360 | }
361 | },
362 | onDropRejected: (fileRejections) => {
363 | const errorMessage = getRootError(
364 | getDropZoneErrorCodes(fileRejections),
365 | validation ?? {},
366 | );
367 | setRootError(errorMessage);
368 | },
369 | });
370 |
371 | return {
372 | getRootProps: dropzone.getRootProps,
373 | getInputProps: dropzone.getInputProps,
374 | inputId,
375 | rootMessageId,
376 | rootDescriptionId,
377 | getFileMessageId,
378 | onRemoveFile,
379 | onRetry,
380 | canRetry,
381 | fileStatuses: fileStatuses as FileStatus[],
382 | isInvalid,
383 | rootError,
384 | isDragActive: dropzone.isDragActive,
385 | };
386 | };
387 |
388 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
389 | const DropZoneContext = createContext>({
390 | getRootProps: () => ({}) as never,
391 | getInputProps: () => ({}) as never,
392 | onRemoveFile: async () => {},
393 | onRetry: async () => {},
394 | canRetry: () => false,
395 | fileStatuses: [],
396 | isInvalid: false,
397 | isDragActive: false,
398 | rootError: undefined,
399 | inputId: "",
400 | rootMessageId: "",
401 | rootDescriptionId: "",
402 | getFileMessageId: () => "",
403 | });
404 |
405 | const useDropzoneContext = () => {
406 | return useContext(DropZoneContext) as UseDropzoneReturn<
407 | TUploadRes,
408 | TUploadError
409 | >;
410 | };
411 |
412 | interface DropzoneProps
413 | extends UseDropzoneReturn {
414 | children: React.ReactNode;
415 | }
416 | const Dropzone = (
417 | props: DropzoneProps,
418 | ) => {
419 | const { children, ...rest } = props;
420 | return (
421 | {children}
422 | );
423 | };
424 | Dropzone.displayName = "Dropzone";
425 |
426 | interface DropZoneAreaProps extends React.HTMLAttributes {}
427 | const DropZoneArea = forwardRef(
428 | ({ className, children, ...props }, forwardedRef) => {
429 | const context = useDropzoneContext();
430 |
431 | if (!context) {
432 | throw new Error("DropzoneArea must be used within a Dropzone");
433 | }
434 |
435 | const { onFocus, onBlur, onDragEnter, onDragLeave, onDrop, ref } =
436 | context.getRootProps();
437 |
438 | return (
439 | // A11y behavior is handled through Trigger. All of these are only relevant to drag and drop which means this should be fine?
440 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions
441 | {
443 | // TODO: test if this actually works?
444 | ref.current = instance;
445 | if (typeof forwardedRef === "function") {
446 | forwardedRef(instance);
447 | } else if (forwardedRef) {
448 | forwardedRef.current = instance;
449 | }
450 | }}
451 | onFocus={onFocus}
452 | onBlur={onBlur}
453 | onDragEnter={onDragEnter}
454 | onDragLeave={onDragLeave}
455 | onDrop={onDrop}
456 | {...props}
457 | aria-label="dropzone"
458 | className={cn(
459 | "flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
460 | context.isDragActive && "animate-pulse bg-black/5",
461 | context.isInvalid && "border-destructive",
462 | className,
463 | )}
464 | >
465 | {children}
466 |
467 | );
468 | },
469 | );
470 | DropZoneArea.displayName = "DropZoneArea";
471 |
472 | export interface DropzoneDescriptionProps
473 | extends React.HTMLAttributes {}
474 |
475 | const DropzoneDescription = forwardRef<
476 | HTMLParagraphElement,
477 | DropzoneDescriptionProps
478 | >((props, ref) => {
479 | const { className, ...rest } = props;
480 | const context = useDropzoneContext();
481 | if (!context) {
482 | throw new Error("DropzoneDescription must be used within a Dropzone");
483 | }
484 |
485 | return (
486 |
492 | );
493 | });
494 | DropzoneDescription.displayName = "DropzoneDescription";
495 |
496 | interface DropzoneFileListContext {
497 | onRemoveFile: () => Promise;
498 | onRetry: () => Promise;
499 | fileStatus: FileStatus;
500 | canRetry: boolean;
501 | dropzoneId: string;
502 | messageId: string;
503 | }
504 |
505 | const DropzoneFileListContext = createContext<
506 | DropzoneFileListContext
507 | >({
508 | onRemoveFile: async () => {},
509 | onRetry: async () => {},
510 | fileStatus: {} as FileStatus,
511 | canRetry: false,
512 | dropzoneId: "",
513 | messageId: "",
514 | });
515 |
516 | const useDropzoneFileListContext = () => {
517 | return useContext(DropzoneFileListContext);
518 | };
519 |
520 | interface DropZoneFileListProps
521 | extends React.OlHTMLAttributes {}
522 |
523 | const DropzoneFileList = forwardRef(
524 | (props, ref) => {
525 | const context = useDropzoneContext();
526 | if (!context) {
527 | throw new Error("DropzoneFileList must be used within a Dropzone");
528 | }
529 | return (
530 |
536 | {props.children}
537 |
538 | );
539 | },
540 | );
541 | DropzoneFileList.displayName = "DropzoneFileList";
542 |
543 | interface DropzoneFileListItemProps
544 | extends React.LiHTMLAttributes {
545 | file: FileStatus;
546 | }
547 |
548 | const DropzoneFileListItem = forwardRef<
549 | HTMLLIElement,
550 | DropzoneFileListItemProps
551 | >(({ className, ...props }, ref) => {
552 | const fileId = props.file.id;
553 | const {
554 | onRemoveFile: cOnRemoveFile,
555 | onRetry: cOnRetry,
556 | getFileMessageId: cGetFileMessageId,
557 | canRetry: cCanRetry,
558 | inputId: cInputId,
559 | } = useDropzoneContext();
560 |
561 | const onRemoveFile = useCallback(
562 | () => cOnRemoveFile(fileId),
563 | [fileId, cOnRemoveFile],
564 | );
565 | const onRetry = useCallback(() => cOnRetry(fileId), [fileId, cOnRetry]);
566 | const messageId = cGetFileMessageId(fileId);
567 | const isInvalid = props.file.status === "error";
568 | const canRetry = useMemo(() => cCanRetry(fileId), [fileId, cCanRetry]);
569 | return (
570 |
580 |
589 | {props.children}
590 |
591 |
592 | );
593 | });
594 | DropzoneFileListItem.displayName = "DropzoneFileListItem";
595 |
596 | interface DropzoneFileMessageProps
597 | extends React.HTMLAttributes {}
598 |
599 | const DropzoneFileMessage = forwardRef<
600 | HTMLParagraphElement,
601 | DropzoneFileMessageProps
602 | >((props, ref) => {
603 | const { children, ...rest } = props;
604 | const context = useDropzoneFileListContext();
605 | if (!context) {
606 | throw new Error(
607 | "DropzoneFileMessage must be used within a DropzoneFileListItem",
608 | );
609 | }
610 |
611 | const body =
612 | context.fileStatus.status === "error"
613 | ? String(context.fileStatus.error)
614 | : children;
615 | return (
616 |
625 | {body}
626 |
627 | );
628 | });
629 | DropzoneFileMessage.displayName = "DropzoneFileMessage";
630 | interface DropzoneMessageProps
631 | extends React.HTMLAttributes {}
632 |
633 | const DropzoneMessage = forwardRef(
634 | (props, ref) => {
635 | const { children, ...rest } = props;
636 | const context = useDropzoneContext();
637 | if (!context) {
638 | throw new Error("DropzoneRootMessage must be used within a Dropzone");
639 | }
640 |
641 | const body = context.rootError ? String(context.rootError) : children;
642 | return (
643 |
652 | {body}
653 |
654 | );
655 | },
656 | );
657 | DropzoneMessage.displayName = "DropzoneMessage";
658 |
659 | interface DropzoneRemoveFileProps extends ButtonProps {}
660 |
661 | const DropzoneRemoveFile = forwardRef<
662 | HTMLButtonElement,
663 | DropzoneRemoveFileProps
664 | >(({ className, ...props }, ref) => {
665 | const context = useDropzoneFileListContext();
666 | if (!context) {
667 | throw new Error(
668 | "DropzoneRemoveFile must be used within a DropzoneFileListItem",
669 | );
670 | }
671 | return (
672 |
686 | );
687 | });
688 | DropzoneRemoveFile.displayName = "DropzoneRemoveFile";
689 |
690 | interface DropzoneRetryFileProps extends ButtonProps {}
691 |
692 | const DropzoneRetryFile = forwardRef(
693 | ({ className, ...props }, ref) => {
694 | const context = useDropzoneFileListContext();
695 |
696 | if (!context) {
697 | throw new Error(
698 | "DropzoneRetryFile must be used within a DropzoneFileListItem",
699 | );
700 | }
701 |
702 | const canRetry = context.canRetry;
703 |
704 | return (
705 |
721 | );
722 | },
723 | );
724 | DropzoneRetryFile.displayName = "DropzoneRetryFile";
725 |
726 | interface DropzoneTriggerProps
727 | extends React.LabelHTMLAttributes {}
728 |
729 | const DropzoneTrigger = forwardRef(
730 | ({ className, children, ...props }, ref) => {
731 | const context = useDropzoneContext();
732 | if (!context) {
733 | throw new Error("DropzoneTrigger must be used within a Dropzone");
734 | }
735 |
736 | const { fileStatuses, getFileMessageId } = context;
737 |
738 | const fileMessageIds = useMemo(
739 | () =>
740 | fileStatuses
741 | .filter((file) => file.status === "error")
742 | .map((file) => getFileMessageId(file.id)),
743 | [fileStatuses, getFileMessageId],
744 | );
745 |
746 | return (
747 |
772 | );
773 | },
774 | );
775 | DropzoneTrigger.displayName = "DropzoneTrigger";
776 |
777 | interface InfiniteProgressProps extends React.HTMLAttributes {
778 | status: "pending" | "success" | "error";
779 | }
780 |
781 | const valueTextMap = {
782 | pending: "indeterminate",
783 | success: "100%",
784 | error: "error",
785 | };
786 |
787 | const InfiniteProgress = forwardRef(
788 | ({ className, ...props }, ref) => {
789 | const done = props.status === "success" || props.status === "error";
790 | const error = props.status === "error";
791 | return (
792 |
813 | );
814 | },
815 | );
816 | InfiniteProgress.displayName = "InfiniteProgress";
817 |
818 | export {
819 | Dropzone,
820 | DropZoneArea,
821 | DropzoneDescription,
822 | DropzoneFileList,
823 | DropzoneFileListItem,
824 | DropzoneFileMessage,
825 | DropzoneMessage,
826 | DropzoneRemoveFile,
827 | DropzoneRetryFile,
828 | DropzoneTrigger,
829 | InfiniteProgress,
830 | useDropzone,
831 | };
832 |
--------------------------------------------------------------------------------