import attrAccept from "attr-accept"; // attr-accept ships as a CommonJS module (`module.exports = { __esModule: true, default: fn }`). // Bundler interop surfaces its default export inconsistently — as the function under Node/Vitest, // but as `{ default: fn }` in some browser bundles. Normalize to the function. const accepts = typeof attrAccept === "function" ? attrAccept : (attrAccept as unknown as {default: typeof attrAccept}).default; /** * A map of accepted MIME types to file extensions, as passed to the `accept` prop. * * An extension value may be a single extension string or an array of them - both are * accepted, mirroring the shape of `window.showOpenFilePicker`'s `accept`. */ export interface Accept { [key: string]: string | readonly string[]; } /** * A labeled group of accepted types, mirroring one entry of `window.showOpenFilePicker`'s * `types` option. Passing the `accept` prop as an array of these lets the File System Access * picker present multiple named filter rows instead of a single one (see * {@link pickerOptionsFromAccept}). The optional `description` labels the row; when omitted it * is derived from the group's extensions. * * Grouping only surfaces when the FS Access picker is actually used (`useFsAccessApi` + * a secure context + browser support). The native `` fallback has no concept of groups * or descriptions, so every group is flattened into one accept attribute there. */ export interface AcceptGroup { description?: string; accept: Accept; } /** * A file rejection error. */ export interface FileError { message: string; code: ErrorCode | string; } /** * What a custom `validator` returns: a single error, a list of errors, or `null` when the file * passes. A validator may return the result directly (synchronous) or wrapped in a `Promise` * (asynchronous, e.g. reading image dimensions or calling an external service). */ export type ValidatorResult = FileError | readonly FileError[] | null; // Error codes export const FILE_INVALID_TYPE = "file-invalid-type"; export const FILE_TOO_LARGE = "file-too-large"; export const FILE_TOO_SMALL = "file-too-small"; export const TOO_MANY_FILES = "too-many-files"; export enum ErrorCode { FileInvalidType = "file-invalid-type", FileTooLarge = "file-too-large", FileTooSmall = "file-too-small", TooManyFiles = "too-many-files" } export function getInvalidTypeRejectionErr(accept: string = ""): FileError { const acceptArr = accept.split(","); const msg = acceptArr.length > 1 ? `one of ${acceptArr.join(", ")}` : acceptArr[0]; return { code: FILE_INVALID_TYPE, message: `File type must be ${msg}` }; } const FILE_SIZE_UNITS = ["KB", "MB", "GB", "TB", "PB"]; /** * Format a byte count into a human-readable string, e.g. `1111` -> `1.08 KB`. * Values below 1 KB are kept in bytes to preserve the singular/plural wording. */ function formatBytes(bytes: number): string { if (bytes < 1024) { return `${bytes} ${bytes === 1 ? "byte" : "bytes"}`; } let size = bytes / 1024; let unitIndex = 0; while (size >= 1024 && unitIndex < FILE_SIZE_UNITS.length - 1) { size /= 1024; unitIndex++; } // Round to 2 decimals, then drop trailing zeros (1.00 -> 1, 1.50 -> 1.5). return `${Number(size.toFixed(2))} ${FILE_SIZE_UNITS[unitIndex]}`; } export function getTooLargeRejectionErr(maxSize: number): FileError { return { code: FILE_TOO_LARGE, message: `File is larger than ${formatBytes(maxSize)}` }; } export function getTooSmallRejectionErr(minSize: number): FileError { return { code: FILE_TOO_SMALL, message: `File is smaller than ${formatBytes(minSize)}` }; } export const TOO_MANY_FILES_REJECTION: FileError = { code: TOO_MANY_FILES, message: "Too many files" }; /** * Check if the given file is a DataTransferItem with an empty type. * * During drag events, browsers may return DataTransferItem objects instead of File objects. * Some browsers (e.g., Chrome) return an empty MIME type for certain file types (like .md files) * on DataTransferItem during drag events, even though the type is correctly set during drop. */ export function isDataTransferItemWithEmptyType(file: File | DataTransferItem): boolean { return file.type === "" && typeof (file as DataTransferItem).getAsFile === "function"; } /** * Check if file is accepted. * * Firefox versions prior to 53 return a bogus MIME type for every file drag, * so dragovers with that MIME type will always be accepted. * * Chrome/other browsers may return an empty MIME type for files during drag events, * so we accept those as well (we'll validate properly on drop). */ export function fileAccepted(file: File, accept?: string): [boolean, FileError | null] { const isAcceptable = file.type === "application/x-moz-file" || accepts(file, accept ?? "") || isDataTransferItemWithEmptyType(file); return [isAcceptable, isAcceptable ? null : getInvalidTypeRejectionErr(accept)]; } export function fileMatchSize( file: {size?: number | null}, minSize?: number, maxSize?: number ): [boolean, FileError | null] { if (isDefined(file.size)) { if (isDefined(minSize) && isDefined(maxSize)) { if (file.size > maxSize) return [false, getTooLargeRejectionErr(maxSize)]; if (file.size < minSize) return [false, getTooSmallRejectionErr(minSize)]; } else if (isDefined(minSize) && file.size < minSize) { return [false, getTooSmallRejectionErr(minSize)]; } else if (isDefined(maxSize) && file.size > maxSize) { return [false, getTooLargeRejectionErr(maxSize)]; } } return [true, null]; } function isDefined(value: T): value is NonNullable { return value !== undefined && value !== null; } /** * Check if a value is thenable (Promise-like), used to tell a synchronous validator result from an * asynchronous one. */ export function isThenable(value: unknown): value is PromiseLike { return value != null && typeof (value as {then?: unknown}).then === "function"; } export function allFilesAccepted({ files, accept, minSize, maxSize, multiple, maxFiles = 0, validator }: { files: File[]; accept?: string; minSize?: number; maxSize?: number; multiple?: boolean; maxFiles?: number; validator?: (file: File) => FileError | readonly FileError[] | null; }): boolean { if ((!multiple && files.length > 1) || (multiple && maxFiles >= 1 && files.length > maxFiles)) { return false; } return files.every(file => { const [accepted] = fileAccepted(file, accept); const [sizeMatch] = fileMatchSize(file, minSize, maxSize); const customErrors = validator ? validator(file) : null; return accepted && sizeMatch && !customErrors; }); } /** * The outcome of the drag-time acceptance check. * * - `accept` - every file passes the checks we can evaluate during a drag. * - `reject` - at least one file confidently fails a check we can fully evaluate during a * drag (the file count, or a non-empty MIME type that doesn't match `accept`). * - `unknown` - nothing confidently fails, but the outcome can't be confirmed until drop, * because a custom `validator` is configured and can't be evaluated yet. */ export type DragVerdict = "accept" | "reject" | "unknown"; /** * A file rejected by the checks that can run during a drag. */ export interface DragFileRejection { file: T; errors: readonly FileError[]; } export interface DragEvaluation { verdict: DragVerdict; rejections: DragFileRejection[]; } /** * Classify a set of dragged files for the `isDragAccept`/`isDragReject`/`isDragUnknown` states. * * During `dragenter`/`dragover` the browser only exposes `DataTransferItem`s, which carry a MIME * `type` but no file name, extension or size (see https://html.spec.whatwg.org/multipage/dnd.html#dndevents). * So the drag-time check is deliberately optimistic about anything it can't see: * * - The custom `validator` is **never** run here. It is typed `(file: File) => ...` and users * routinely read `file.name`/`file.size`, which are `undefined` on a `DataTransferItem` - running * it would throw and abort the whole drag handler (leaving `isDragActive` stuck at `false`). * See https://github.com/react-dropzone/react-dropzone/issues/1408 * - When a `validator` is configured we therefore can't promise the files are acceptable, so the * verdict is `unknown` rather than a misleading `reject` (or a premature `accept`). * See https://github.com/react-dropzone/react-dropzone/issues/1244 * * The full check (including the `validator`) still runs on drop in {@link fileAccepted}/`setFiles`. */ export function evaluateDragFiles({ files, accept, minSize, maxSize, multiple, maxFiles = 0, validator, getErrorMessage }: { files: T[]; accept?: string; minSize?: number; maxSize?: number; multiple?: boolean; maxFiles?: number; // The validator is never invoked here (see the note above), so its async variant is accepted // purely so the same `validator` prop is assignable during a drag. validator?: (file: File) => ValidatorResult | Promise; getErrorMessage?: (error: FileError, file: File) => string; }): DragEvaluation { const acceptedFiles: T[] = []; const rejections: DragFileRejection[] = []; const localizeError = (error: FileError, file: T): FileError => getErrorMessage && typeof File !== "undefined" && file instanceof File ? {...error, message: getErrorMessage(error, file)} : error; files.forEach(file => { const [accepted, acceptError] = fileAccepted(file as File, accept); const [sizeMatch, sizeError] = fileMatchSize(file as File, minSize, maxSize); if (accepted && sizeMatch) { acceptedFiles.push(file); } else { rejections.push({ file, errors: [acceptError, sizeError] .filter((error): error is FileError => error != null) .map(error => localizeError(error, file)) }); } }); // Match the drop path: files that already failed a per-file check do not consume the limit, // and only otherwise-valid surplus files receive a too-many-files rejection. const acceptedFilesLimit = multiple ? (maxFiles >= 1 ? maxFiles : Number.POSITIVE_INFINITY) : 1; if (acceptedFiles.length > acceptedFilesLimit) { acceptedFiles.slice(acceptedFilesLimit).forEach(file => { rejections.push({file, errors: [localizeError(TOO_MANY_FILES_REJECTION, file)]}); }); } if (rejections.length > 0) { return {verdict: "reject", rejections}; } // Built-in checks pass. A custom validator can only ever add rejections on drop, never rescue // one - so with a validator present the drag outcome is unknown until we have real Files. return {verdict: validator ? "unknown" : "accept", rejections}; } export function getDragVerdict(options: Parameters[0]): DragVerdict { return evaluateDragFiles(options).verdict; } // React's synthetic events has event.isPropagationStopped, // but to remain compatibility with other libs (Preact) fall back // to check event.cancelBubble export function isPropagationStopped(event: any): boolean { if (typeof event.isPropagationStopped === "function") { return event.isPropagationStopped(); } else if (typeof event.cancelBubble !== "undefined") { return event.cancelBubble; } return false; } export function isEvtWithFiles(event: any): boolean { // A paste (ClipboardEvent) carries its DataTransfer under {clipboardData} rather than // {dataTransfer}, so a pasted screenshot is detected the same way as a drop. const dataTransfer = event.dataTransfer ?? event.clipboardData; if (!dataTransfer) { return !!event.target && !!event.target.files; } // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file // Some Chromium drags omit "Files" from types (e.g. reporting only ["text/plain"]) // while still exposing a kind: "file" entry in items - the same signal file-selector // uses to extract the files. Accept either so detection stays consistent. See #1409. return ( Array.prototype.some.call( dataTransfer.types, (type: string) => type === "Files" || type === "application/x-moz-file" ) || Array.prototype.some.call(dataTransfer.items ?? [], isKindFile) ); } export function isKindFile(item: any): boolean { return typeof item === "object" && item !== null && item.kind === "file"; } // allow the entire document to be a drag target export function onDocumentDragOver(event: Event): void { event.preventDefault(); } function isIe(userAgent: string): boolean { return userAgent.indexOf("MSIE") !== -1 || userAgent.indexOf("Trident/") !== -1; } function isEdge(userAgent: string): boolean { return userAgent.indexOf("Edge/") !== -1; } export function isIeOrEdge(userAgent: string = window.navigator.userAgent): boolean { return isIe(userAgent) || isEdge(userAgent); } /** * This is intended to be used to compose event handlers. * They are executed in order until one of them calls `event.isPropagationStopped()`. * Note that the check is done on the first invoke too, * meaning that if propagation was stopped before invoking the fns, * no handlers will be executed. */ export function composeEventHandlers( ...fns: Array<((event: any, ...args: any[]) => void) | null | undefined> ): (event: any, ...args: any[]) => boolean { return (event: any, ...args: any[]) => fns.some(fn => { if (!isPropagationStopped(event) && fn) { fn(event, ...args); } return isPropagationStopped(event); }); } /** * canUseFileSystemAccessAPI checks if the File System Access API is supported by the browser. */ export function canUseFileSystemAccessAPI(): boolean { return "showOpenFilePicker" in window; } /** * Coerce an `accept` extension value to an array. `window.showOpenFilePicker` allows a single * extension string as well as an array, so we accept both and normalize to an array. Anything * else (a malformed value) collapses to an empty list. */ function toExtensions(ext: string | readonly string[] | undefined): readonly string[] { if (Array.isArray(ext)) { return ext; } if (typeof ext === "string") { return [ext]; } return []; } /** * Normalize either `accept` form to the internal array of `{description, accept}` groups. * * - The object form (`{mime: ext}`) becomes a single group; its `description` (which the object * form can't express) is derived from the extensions like any other group. * - The array form is passed through; entries missing an `accept` map are dropped. * * In both cases a missing `description` is filled in later from the group's extensions (see * {@link describeAccept}), so the picker always gets the non-empty description it requires * (https://crbug.com/1264708). Returns `undefined` when no `accept` is provided. */ function normalizeAcceptGroups(accept?: Accept | readonly AcceptGroup[]): AcceptGroup[] | undefined { if (!isDefined(accept)) { return undefined; } if (Array.isArray(accept)) { return (accept as readonly AcceptGroup[]).filter(group => isDefined(group) && isDefined(group.accept)); } return [{accept: accept as Accept}]; } /** * Build a human-readable label for a picker group from its (validated) accept map, used when the * caller doesn't supply a `description`. Prefer the file extensions, fall back to the MIME types, * then to a generic label - `showOpenFilePicker` requires a non-empty description (crbug 1264708). */ function describeAccept(accept: Accept): string { const extensions: string[] = []; const mimeTypes = Object.keys(accept); for (const ext of Object.values(accept)) { for (const e of toExtensions(ext)) { if (!extensions.includes(e)) { extensions.push(e); } } } if (extensions.length > 0) { return extensions.join(", "); } if (mimeTypes.length > 0) { return mimeTypes.join(", "); } return "Files"; } /** * Merge every `accept` group into a single MIME-type -> extensions map. Duplicate MIME keys union * their extensions. This flattened map drives the native `` attribute and the * drag/drop validators, which have no concept of groups or descriptions. */ export function flattenAccept(accept?: Accept | readonly AcceptGroup[]): Accept | undefined { const groups = normalizeAcceptGroups(accept); if (!isDefined(groups)) { return undefined; } const merged: Record = {}; for (const group of groups) { for (const [mimeType, ext] of Object.entries(group.accept)) { const existing = merged[mimeType] ?? (merged[mimeType] = []); for (const e of toExtensions(ext)) { if (!existing.includes(e)) { existing.push(e); } } } } return merged; } /** * Convert the `{accept}` dropzone prop to the `{types}` option for showOpenFilePicker. * * Each group becomes one filter row in the picker. Invalid MIME types / extensions are dropped * with a warning; a group left with no valid entries is omitted entirely (showOpenFilePicker * rejects an empty `accept`). Returns `undefined` when nothing valid remains. */ export function pickerOptionsFromAccept( accept?: Accept | readonly AcceptGroup[] ): Array<{description: string; accept: Accept}> | undefined { const groups = normalizeAcceptGroups(accept); if (!isDefined(groups)) { return undefined; } const options = groups .map(group => { const acceptForPicker = Object.entries(group.accept) .filter(([mimeType, ext]) => { let ok = true; if (!isMIMEType(mimeType)) { console.warn( `Skipped "${mimeType}" because it is not a valid MIME type. Check https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types for a list of valid MIME types.` ); ok = false; } const isExtInput = Array.isArray(ext) || typeof ext === "string"; if (!isExtInput || !toExtensions(ext).every(isExt)) { console.warn(`Skipped "${mimeType}" because an invalid file extension was provided.`); ok = false; } return ok; }) .reduce((agg, [mimeType, ext]) => { agg[mimeType] = toExtensions(ext); return agg; }, {}); return { description: isDefined(group.description) && group.description !== "" ? group.description : describeAccept(acceptForPicker), accept: acceptForPicker }; }) // Drop groups with no valid entries - showOpenFilePicker rejects an empty accept map. .filter(option => Object.keys(option.accept).length > 0); return options.length > 0 ? options : undefined; } /** * Convert the `{accept}` dropzone prop to a comma-separated accept attribute string. * * When `omitWildcardMimeTypesWithExtensions` is set, a wildcard MIME type (e.g. `image/*`) * that is paired with explicit extensions is dropped in favour of those extensions. The * accept attribute is an OR list, so leaving `image/*` in would make both the native file * picker and the drop-time validator accept ANY file of that type, ignoring the extension * restriction. The drag-time `isDragAccept` check keeps the wildcard because file names * (and therefore extensions) aren't readable during a drag. * * See https://github.com/react-dropzone/react-dropzone/issues/1220 */ export function acceptPropAsAcceptAttr( accept?: Accept, {omitWildcardMimeTypesWithExtensions = false}: {omitWildcardMimeTypesWithExtensions?: boolean} = {} ): string | undefined { if (isDefined(accept)) { return ( Object.entries(accept) .reduce((a, [mimeType, extVal]) => { const ext = toExtensions(extVal); if (omitWildcardMimeTypesWithExtensions && isMIMETypeWildcard(mimeType) && ext.some(isExt)) { a.push(...ext); } else { a.push(mimeType, ...ext); } return a; }, []) // Silently discard invalid entries as pickerOptionsFromAccept warns about these .filter(v => isMIMEType(v) || isExt(v)) .join(",") ); } return undefined; } /** * Check if v is an exception caused by aborting a request (e.g window.showOpenFilePicker()). */ export function isAbort(v: any): boolean { return v instanceof DOMException && (v.name === "AbortError" || v.code === v.ABORT_ERR); } /** * Check if v is a security error. */ export function isSecurityError(v: any): boolean { return v instanceof DOMException && (v.name === "SecurityError" || v.code === v.SECURITY_ERR); } /** * Check if v is a "not allowed" error. * * Some browsers/configurations block `window.showOpenFilePicker()` outright and reject with a * `NotAllowedError` instead of showing the picker (e.g. Microsoft Edge for Business, or other * restrictive enterprise/security policies). We treat this like a security error and fall back * to the native ``. See https://github.com/react-dropzone/react-dropzone/issues/1429 */ export function isNotAllowedError(v: any): boolean { return v instanceof DOMException && v.name === "NotAllowedError"; } /** * Check if v is a MIME type string. */ export function isMIMEType(v: string): boolean { return ( v === "audio/*" || v === "video/*" || v === "image/*" || v === "text/*" || v === "application/*" || /\w+\/[-+.\w]+/g.test(v) ); } /** * Check if v is a wildcard MIME type (e.g. `image/*`). */ export function isMIMETypeWildcard(v: string): boolean { return v.endsWith("/*"); } /** * Check if v is a file extension. */ export function isExt(v: string): boolean { return /^.*\.[\w]+$/.test(v); }