"use client"; import { useEffect, useId, useLayoutEffect, useRef, useState, type DragEvent, type KeyboardEvent, } from "react"; import { ArrowUp, CircleAlert, File as FileIcon, FileArchive, FileImage, FilePlay, FileText, RotateCw, X, } from "lucide-react"; import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type HTMLMotionProps, type TargetAndTransition, type Transition, } from "motion/react"; import { motionTokens } from "@/lib/motion-tokens"; import { cn } from "@/lib/utils"; export type FileDropzoneStatus = "uploading" | "uploaded" | "failed"; export type FileDropzoneItem = { id: string; name: string; size: number; status?: FileDropzoneStatus; progress?: number; error?: string; retryable?: boolean; file?: File; preview?: string; }; export type FileDropzoneUpload = ( item: FileDropzoneItem, options: { onProgress: (percent: number) => void; signal: AbortSignal }, ) => Promise; export type FileDropzoneProps = { accept?: string; multiple?: boolean; maxFiles?: number; onFilesChange?: (files: File[]) => void; label?: string; description?: string; defaultItems?: FileDropzoneItem[]; onUpload?: FileDropzoneUpload; maxSize?: number; listPlacement?: "below" | "inside"; note?: string; dropLabel?: string; compactAt?: number; className?: string; }; const MB = 1024 * 1024; const enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], }; const exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard], }; const instant: Transition = { duration: 0 }; const fade: Transition = { duration: motionTokens.duration.instant }; const collapse: Transition = { type: "spring", stiffness: 380, damping: 30, mass: 0.8, }; const textIn: TargetAndTransition = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)`, }; const textOut: TargetAndTransition = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast, }; const shown: TargetAndTransition = { opacity: 1, y: "0em", filter: "blur(0px)", }; const clamp = (value: number) => Math.min(Math.max(value, 0), 100); const SHAKE = { x: [0, -7, 6, -4, 3, -1.5, 0] }; const shakeTransition: Transition = { duration: 0.42, ease: "easeOut" }; const land: Transition = { type: "spring", stiffness: 300, damping: 22, mass: 0.8, }; export function formatFileSize(bytes: number) { if (bytes < 1024) return `${bytes} B`; if (bytes < MB) return `${Math.round(bytes / 1024)} KB`; const mb = bytes / MB; return `${mb >= 10 ? Math.round(mb) : Number(mb.toFixed(1))} MB`; } function TypeIcon({ name }: { name: string }) { const props = { size: 20, strokeWidth: 1.75, "aria-hidden": true } as const; const extension = name.toLowerCase().split(".").pop() ?? ""; if ( ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "heic"].includes( extension, ) ) { return ; } if (["mov", "mp4", "webm", "m4v", "avi"].includes(extension)) { return ; } if (["zip", "gz", "tar", "rar", "7z"].includes(extension)) { return ; } if (["pdf", "md", "txt", "doc", "docx", "rtf"].includes(extension)) { return ; } return ; } function Swap(props: HTMLMotionProps<"span">) { const present = useIsPresent(); return ( ); } function TextSwap({ text, className, reduce, }: { text: string; className?: string; reduce: boolean; }) { return ( {text} ); } function MotionText({ text }: { text: string }) { const reduced = useReducedMotion(); const words = text.split(" "); return ( <> {text} ); } function ErrorRow({ text }: { text: string }) { const reduced = useReducedMotion(); const copyRef = useRef(null); const [height, setHeight] = useState("auto"); useEffect(() => { const node = copyRef.current; if (!node || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight), ); observer.observe(node); return () => observer.disconnect(); }, []); return ( ); } function DrawnCheck({ reduce }: { reduce: boolean }) { return ( ); } type RowProps = { item: FileDropzoneItem; reduce: boolean; delay: number; fresh: boolean; canRetry: boolean; onRemove: () => void; onRetry: () => void; removeRef: (node: HTMLButtonElement | null) => void; }; function Thumb({ item }: { item: FileDropzoneItem }) { const [broken, setBroken] = useState(false); if (!item.preview || broken) { return ( ); } return ( setBroken(true)} /> ); } function FileRow({ item, reduce, delay, fresh, canRetry, onRemove, onRetry, removeRef, }: RowProps) { const { status } = item; const rowRef = useRef(null); const shakenFor = useRef( fresh ? "new" : status, ); useEffect(() => { const previous = shakenFor.current; if (status === previous) return; shakenFor.current = status; const node = rowRef.current; if (status !== "failed" || reduce || !node) return; animate(node, SHAKE, { ...shakeTransition, delay: previous === "new" ? delay + 0.3 : 0, }); }, [status, reduce, delay]); const progress = useMotionValue( status === "uploaded" ? 100 : (item.progress ?? 0), ); const x = useTransform(progress, (value) => `${clamp(value) - 100}%`); const percent = useTransform( progress, (value) => `${Math.round(clamp(value))}%`, ); const [filled, setFilled] = useState(status !== "uploading"); const [seen, setSeen] = useState(status); if (seen !== status) { setSeen(status); if (status === "uploading") setFilled(false); } const phase = status === "uploaded" && !filled && !reduce ? "uploading" : status; const target = status === "uploaded" ? 100 : (item.progress ?? 0); useEffect(() => { if (status !== "uploading" && status !== "uploaded") return; if (reduce || (status === "uploading" && target === 0)) { progress.jump(target); return; } const controls = animate(progress, target, { type: "spring", stiffness: 280, damping: 28, onComplete: status === "uploaded" ? () => setFilled(true) : undefined, }); return () => controls.stop(); }, [status, target, progress, reduce]); useMotionValueEvent(progress, "change", (value) => { if (status === "uploaded" && value >= 99.5) setFilled(true); }); const failed = status === "failed"; const retry = failed && canRetry && item.retryable !== false; return ( {item.name} {formatFileSize(item.size)} {phase && ( <> {phase === "uploading" ? ( Uploading{" "} {percent} ) : phase === "uploaded" ? ( <> Uploaded ) : ( <> )} {phase === "uploading" && ( )} {retry && ( )} ); } export function FileDropzone({ accept, multiple = true, maxFiles = 5, onFilesChange, label = "Add files", description = "Drop files here or choose from your device", defaultItems, onUpload, maxSize, listPlacement = "below", note, dropLabel, compactAt, className, }: FileDropzoneProps) { const [items, setItems] = useState( () => defaultItems ?? [], ); const [dragging, setDragging] = useState(false); const [error, setError] = useState(""); const [announcement, setAnnouncement] = useState(""); const [batchStart, setBatchStart] = useState(0); const inputRef = useRef(null); const dropRef = useRef(null); const zoneRef = useRef(null); const dragDepth = useRef(0); const nextId = useRef(0); const controllers = useRef(new Map()); const removeRefs = useRef(new Map()); const descriptionId = useId(); const noteId = useId(); const reduce = !!useReducedMotion(); const inside = listPlacement === "inside"; const compact = compactAt !== undefined && items.length >= compactAt; const [freshIds, setFreshIds] = useState>( () => new Set(), ); const ownedPreviews = useRef(new Map()); const [hovered, setHovered] = useState(false); const [focusWithin, setFocusWithin] = useState(false); useEffect(() => { const active = controllers.current; const previews = ownedPreviews.current; return () => { active.forEach((controller) => controller.abort()); previews.forEach((url) => URL.revokeObjectURL(url)); }; }, []); const shakeZone = () => { if (!reduce && zoneRef.current) animate(zoneRef.current, SHAKE, shakeTransition); }; const filesOf = (list: FileDropzoneItem[]) => list.flatMap((item) => (item.file ? [item.file] : [])); const patch = ( id: string, next: (item: FileDropzoneItem) => FileDropzoneItem, ) => setItems((current) => current.map((item) => (item.id === id ? next(item) : item)), ); function startUpload(item: FileDropzoneItem) { if (!onUpload) return; controllers.current.get(item.id)?.abort(); const controller = new AbortController(); controllers.current.set(item.id, controller); patch(item.id, (current) => ({ ...current, status: "uploading", progress: 0, error: undefined, })); onUpload( { ...item, status: "uploading", progress: 0, error: undefined }, { signal: controller.signal, onProgress: (percent) => { if (!controller.signal.aborted) { patch(item.id, (current) => current.status === "uploading" ? { ...current, progress: Math.max(current.progress ?? 0, clamp(percent)), } : current, ); } }, }, ) .then( () => { if (controller.signal.aborted) return; patch(item.id, (current) => ({ ...current, status: "uploaded", progress: 100, })); setAnnouncement(`${item.name} uploaded`); }, (reason: unknown) => { if (controller.signal.aborted) return; const message = reason instanceof Error && reason.message ? reason.message : "Upload failed"; patch(item.id, (current) => ({ ...current, status: "failed", error: message, retryable: true, })); setAnnouncement(`${item.name} failed. ${message}`); }, ) .finally(() => { if (controllers.current.get(item.id) === controller) { controllers.current.delete(item.id); } }); } function addFiles(incoming: FileList | File[]) { const list = Array.from(incoming); const accepted = accept ?.split(",") .map((value) => value.trim().toLowerCase()) .filter(Boolean) ?? []; const matching = list.filter( (file) => accepted.length === 0 || accepted.some((type) => type.startsWith(".") ? file.name.toLowerCase().endsWith(type) : type.endsWith("/*") ? file.type.startsWith(type.slice(0, -1)) : file.type === type, ), ); const kept = multiple ? items : []; const same = (a: File, b: File) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified; const fresh = matching.filter( (file, index) => matching.findIndex((other) => same(other, file)) === index && !kept.some((item) => item.file && same(item.file, file)), ); const room = multiple ? Math.max(0, maxFiles - kept.length) : 1; const added = fresh.slice(0, room).map((file): FileDropzoneItem => { const tooLarge = maxSize !== undefined && file.size > maxSize; const id = `${file.name}-${file.size}-${file.lastModified}-${nextId.current++}`; const preview = file.type.startsWith("image/") && typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : undefined; if (preview) ownedPreviews.current.set(id, preview); return { id, name: file.name, size: file.size, file, preview, status: tooLarge ? "failed" : onUpload ? "uploading" : undefined, progress: 0, error: tooLarge ? `File is larger than ${formatFileSize(maxSize)}` : undefined, retryable: !tooLarge, }; }); const rejected = matching.length !== list.length; const overflow = fresh.length > added.length; setError( rejected ? list.length - matching.length === 1 && list.length === 1 ? `${list[0].name} is not an accepted file type.` : "Some files were not added because their type is not accepted." : overflow ? `You can add up to ${maxFiles} ${maxFiles === 1 ? "file" : "files"}.` : "", ); if (rejected || overflow) shakeZone(); if (!added.length) return; if (!multiple) { controllers.current.forEach((controller) => controller.abort()); controllers.current.clear(); } const next = [...kept, ...added]; setFreshIds( (current) => new Set([...current, ...added.map((item) => item.id)]), ); setBatchStart(kept.length); setItems((current) => (multiple ? [...current, ...added] : added)); onFilesChange?.(filesOf(next)); const failed = added.filter((item) => item.status === "failed"); setAnnouncement( `${added.length} ${added.length === 1 ? "file" : "files"} added.${ failed.length ? ` ${failed.map((item) => `${item.name}: ${item.error}`).join(". ")}.` : "" }`, ); added.filter((item) => item.status === "uploading").forEach(startUpload); } function removeItem(target: FileDropzoneItem) { controllers.current.get(target.id)?.abort(); controllers.current.delete(target.id); const index = items.findIndex((item) => item.id === target.id); const neighbor = items[index + 1] ?? items[index - 1]; const next = items.filter((item) => item.id !== target.id); const preview = ownedPreviews.current.get(target.id); if (preview) { ownedPreviews.current.delete(target.id); window.setTimeout(() => URL.revokeObjectURL(preview), 600); } setItems((current) => current.filter((item) => item.id !== target.id)); setError(""); setAnnouncement(`${target.name} removed`); if (target.file) onFilesChange?.(filesOf(next)); requestAnimationFrame(() => (neighbor ? removeRefs.current.get(neighbor.id) : dropRef.current )?.focus(), ); } function retryItem(item: FileDropzoneItem) { setAnnouncement(`Retrying ${item.name}`); startUpload(item); requestAnimationFrame(() => removeRefs.current.get(item.id)?.focus()); } function moveFocus(event: KeyboardEvent) { if (event.key === "Delete" || event.key === "Backspace") { const row = (event.target as HTMLElement).closest(`li`); const remove = row?.querySelector( `button[title="Remove"]`, ); if (remove) { event.preventDefault(); remove.click(); } return; } if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; const button = (event.target as HTMLElement).closest("button"); const rows = Array.from( event.currentTarget.querySelectorAll(`li`), ); const row = button?.closest(`li`); if (!button || !row) return; const nextRow = rows[rows.indexOf(row) + (event.key === "ArrowDown" ? 1 : -1)]; if (!nextRow) return; event.preventDefault(); ( nextRow.querySelector(`button[title="Retry"]`) ?? nextRow.querySelector(`button[title="Remove"]`) )?.focus(); } const pasteArmed = hovered || focusWithin; const addRef = useRef(addFiles); useLayoutEffect(() => { addRef.current = addFiles; }); useEffect(() => { if (!pasteArmed) return; const onPaste = (event: ClipboardEvent) => { const files = event.clipboardData?.files; if (!files?.length) return; const target = event.target as HTMLElement | null; if ( target?.closest( "input:not([type=file]), textarea, [contenteditable='true']", ) ) return; event.preventDefault(); addRef.current(files); }; document.addEventListener("paste", onPaste); return () => document.removeEventListener("paste", onPaste); }, [pasteArmed]); const carriesFiles = (event: DragEvent) => Array.from(event.dataTransfer?.types ?? []).includes("Files"); const dropCopy = dropLabel ?? (onUpload ? "Drop to upload" : multiple ? "Drop to add files" : "Drop to add the file"); const noteCopy = note ?? (accept ? `Accepted: ${accept}` : `Up to ${maxFiles} ${maxFiles === 1 ? "file" : "files"}`); const slot = { initial: reduce ? { opacity: 0 } : { height: 0, opacity: 0 }, animate: { height: "auto", opacity: 1 }, exit: reduce ? { opacity: 0, transition: fade } : { height: 0, opacity: 0, transition: { height: collapse, opacity: exitFast }, }, transition: reduce ? fade : { height: { type: "spring" as const, stiffness: 340, damping: 28, mass: 0.8, }, opacity: enter, }, }; const rowsInside = inside && items.length > 0; const list = ( {items.map((item, index) => ( removeItem(item)} onRetry={() => retryItem(item)} removeRef={(node) => { if (node) removeRefs.current.set(item.id, node); else removeRefs.current.delete(item.id); }} /> ))} ); return (
setHovered(true)} onPointerLeave={() => setHovered(false)} onFocus={() => setFocusWithin(true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setFocusWithin(false); } }} >
{ if (!carriesFiles(event)) return; event.preventDefault(); dragDepth.current += 1; setDragging(true); }} onDragOver={(event) => { if (!carriesFiles(event)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }} onDragLeave={(event) => { if (!carriesFiles(event)) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (!dragDepth.current) setDragging(false); }} onDrop={(event) => { event.preventDefault(); dragDepth.current = 0; setDragging(false); if (event.dataTransfer.files.length) addFiles(event.dataTransfer.files); }} > inputRef.current?.click()} aria-describedby={ compact ? descriptionId : `${descriptionId} ${noteId}` } initial={false} animate={{ paddingTop: compact ? 16 : 30, paddingBottom: compact ? rowsInside ? 8 : 16 : rowsInside ? 16 : 30, }} transition={ reduce ? instant : { type: "spring", stiffness: 340, damping: 28, mass: 0.8 } } > {(!compact || dragging) && ( )} {description} {!compact && ( {noteCopy} )} {inside && list}
{ if (event.target.files) addFiles(event.target.files); event.target.value = ""; }} /> {error ? : null} {!inside && list} {announcement}
); } export default FileDropzone;