"use client" import * as React from "react" import { FileIcon, UploadCloudIcon, XIcon } from "lucide-react" import { Button } from "@/components/ui/button" import { cn, stopInteractivePropagation } from "@/lib/utils" export type FileUploadRejectReason = "max-files" | "max-size" | "type" export type FileUploadItemStatus = "idle" | "uploading" | "success" | "error" export type FileUploadRejectedFile = { file: File reason: FileUploadRejectReason message: string } export type FileUploadRejectionMessageContext = { file: File reason: FileUploadRejectReason maxFiles?: number maxSize?: number accept?: string } export type FileUploadRejectionMessages = Partial< Record string)> > export type FileUploadRenderFileState = { file: File index: number progress?: number status: FileUploadItemStatus remove: () => void retry?: () => void removeLabel?: string } export type FileUploadRenderRejectedFileState = { rejectedFile: FileUploadRejectedFile index: number } type NativeFileInputProps = Omit< React.InputHTMLAttributes, | "type" | "value" | "onChange" | "className" | "children" | "onDragEnter" | "onDragLeave" | "onDragOver" | "onDrop" > export type FileUploadProps = NativeFileInputProps & { files?: File[] onFilesChange?: (files: File[]) => void rejectedFiles?: FileUploadRejectedFile[] onRejectedFilesChange?: (files: FileUploadRejectedFile[]) => void onDragEnter?: React.DragEventHandler onDragLeave?: React.DragEventHandler onDragOver?: React.DragEventHandler onDrop?: React.DragEventHandler buttonLabel?: React.ReactNode dropzoneLabel?: React.ReactNode dropzoneDescription?: React.ReactNode dragActiveLabel?: React.ReactNode helperText?: React.ReactNode clearLabel?: React.ReactNode removeLabel?: string dropzoneAriaLabel?: string rejectionMessages?: FileUploadRejectionMessages maxFiles?: number maxSize?: number appendFiles?: boolean showFileList?: boolean showClearButton?: boolean loading?: boolean progress?: number | Record status?: FileUploadItemStatus | Record validateFile?: (file: File) => string | null | Promise onRetryFile?: (file: File, index: number) => void fileTriggerLabel?: string renderFile?: (state: FileUploadRenderFileState) => React.ReactNode renderRejectedFile?: (state: FileUploadRenderRejectedFileState) => React.ReactNode renderActions?: (state: { openFileDialog: () => void; clearFiles: () => void; files: File[] }) => React.ReactNode className?: string inputClassName?: string dropzoneClassName?: string fileListClassName?: string fileItemClassName?: string rejectedListClassName?: string } function formatBytes(bytes: number) { if (bytes === 0) return "0 B" const units = ["B", "KB", "MB", "GB"] const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) const value = bytes / 1024 ** index return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}` } function normalizeAccept(accept?: string) { return accept ?.split(",") .map((item) => item.trim().toLowerCase()) .filter(Boolean) ?? [] } function fileMatchesAccept(file: File, accept?: string) { const rules = normalizeAccept(accept) if (rules.length === 0) return true const fileName = file.name.toLowerCase() const fileType = file.type.toLowerCase() return rules.some((rule) => { if (rule.startsWith(".")) return fileName.endsWith(rule) if (rule.endsWith("/*")) return fileType.startsWith(rule.slice(0, -1)) return fileType === rule }) } function getFileKey(file: File) { return `${file.name}-${file.size}-${file.lastModified}` } function getProgressForFile(progress: FileUploadProps["progress"], file: File) { if (typeof progress === "number") return progress return progress?.[getFileKey(file)] ?? progress?.[file.name] } function getStatusForFile(status: FileUploadProps["status"], file: File): FileUploadItemStatus { if (typeof status === "string") return status return status?.[getFileKey(file)] ?? status?.[file.name] ?? "idle" } function resolveRejectionMessage( reason: FileUploadRejectReason, context: Omit, messages?: FileUploadRejectionMessages ) { const customMessage = messages?.[reason] const fullContext = { ...context, reason } if (typeof customMessage === "function") return customMessage(fullContext) if (typeof customMessage === "string") return customMessage switch (reason) { case "max-files": return `Maximum ${context.maxFiles} file${context.maxFiles === 1 ? "" : "s"} allowed.` case "max-size": return `File is larger than ${formatBytes(context.maxSize ?? 0)}.` case "type": return context.accept ? `File type is not allowed. Expected: ${context.accept}.` : "File type is not allowed." } } function validateIncomingFiles({ currentFiles, incomingFiles, accept, maxFiles, maxSize, appendFiles, rejectionMessages, validateFile, }: { currentFiles: File[] incomingFiles: File[] accept?: string maxFiles?: number maxSize?: number appendFiles: boolean rejectionMessages?: FileUploadRejectionMessages validateFile?: FileUploadProps["validateFile"] }) { const accepted: File[] = [] const rejected: FileUploadRejectedFile[] = [] const nextBase = appendFiles ? currentFiles : [] const effectiveMaxFiles = maxFiles ?? (appendFiles ? undefined : incomingFiles.length) for (const file of incomingFiles) { if (effectiveMaxFiles !== undefined && nextBase.length + accepted.length >= effectiveMaxFiles) { rejected.push({ file, reason: "max-files", message: resolveRejectionMessage("max-files", { file, maxFiles: effectiveMaxFiles, maxSize, accept }, rejectionMessages), }) continue } if (maxSize !== undefined && file.size > maxSize) { rejected.push({ file, reason: "max-size", message: resolveRejectionMessage("max-size", { file, maxFiles: effectiveMaxFiles, maxSize, accept }, rejectionMessages), }) continue } if (!fileMatchesAccept(file, accept)) { rejected.push({ file, reason: "type", message: resolveRejectionMessage("type", { file, maxFiles: effectiveMaxFiles, maxSize, accept }, rejectionMessages), }) continue } const customMessage = validateFile?.(file) if (typeof customMessage === "string" && customMessage) { rejected.push({ file, reason: "type", message: customMessage, }) continue } accepted.push(file) } return { accepted, rejected, nextFiles: appendFiles ? [...currentFiles, ...accepted] : accepted, } } function defaultRenderFile({ file, progress, status, remove, retry, removeLabel = "Remove file" }: FileUploadRenderFileState) { return (
{file.name}
{status}
{formatBytes(file.size)}
{typeof progress === "number" && (
)}
{status === "error" && retry ? ( ) : null}
) } function FileUpload({ files = [], onFilesChange, rejectedFiles, onRejectedFilesChange, buttonLabel = "Choose files", dropzoneLabel = "Drop files here or click to upload", dropzoneDescription, dragActiveLabel = "Drop files to upload", helperText, clearLabel = "Clear all", removeLabel = "Remove file", dropzoneAriaLabel, rejectionMessages, maxFiles, maxSize, appendFiles = true, showFileList = true, showClearButton = true, loading = false, progress, status, validateFile, onRetryFile, fileTriggerLabel = "Open file picker", renderFile, renderRejectedFile, renderActions, className, inputClassName, dropzoneClassName, fileListClassName, fileItemClassName, rejectedListClassName, accept, multiple, disabled, onDragEnter, onDragLeave, onDragOver, onDrop, ...props }: FileUploadProps) { const inputRef = React.useRef(null) const dragDepthRef = React.useRef(0) const [isDragging, setIsDragging] = React.useState(false) const [internalRejectedFiles, setInternalRejectedFiles] = React.useState([]) const resolvedRejectedFiles = rejectedFiles ?? internalRejectedFiles const isDisabled = disabled || loading const inputMultiple = multiple ?? maxFiles !== 1 React.useEffect(() => { if (!isDisabled) return dragDepthRef.current = 0 setIsDragging(false) }, [isDisabled]) const setRejectedFiles = React.useCallback( (nextRejectedFiles: FileUploadRejectedFile[]) => { if (rejectedFiles === undefined) setInternalRejectedFiles(nextRejectedFiles) onRejectedFilesChange?.(nextRejectedFiles) }, [onRejectedFilesChange, rejectedFiles] ) const openFileDialog = React.useCallback(() => { if (!isDisabled) inputRef.current?.click() }, [isDisabled]) const clearFiles = React.useCallback(() => { onFilesChange?.([]) setRejectedFiles([]) }, [onFilesChange, setRejectedFiles]) const processFiles = React.useCallback( (incomingFiles: File[]) => { const result = validateIncomingFiles({ currentFiles: files, incomingFiles, accept, maxFiles, maxSize, appendFiles, rejectionMessages, validateFile, }) onFilesChange?.(result.nextFiles) setRejectedFiles(result.rejected) }, [accept, appendFiles, files, maxFiles, maxSize, onFilesChange, rejectionMessages, setRejectedFiles, validateFile] ) const removeFile = React.useCallback( (fileIndex: number) => { onFilesChange?.(files.filter((_, index) => index !== fileIndex)) }, [files, onFilesChange] ) const handleChange: React.ChangeEventHandler = (event) => { if (isDisabled) return processFiles(Array.from(event.target.files ?? [])) event.target.value = "" } const preventDisabledDragDefault = (event: React.DragEvent) => { if (!isDisabled) return false event.preventDefault() stopInteractivePropagation(event) return true } const handleDragEnter: React.DragEventHandler = (event) => { onDragEnter?.(event) if (event.defaultPrevented || preventDisabledDragDefault(event)) return event.preventDefault() dragDepthRef.current += 1 setIsDragging(true) } const handleDragLeave: React.DragEventHandler = (event) => { onDragLeave?.(event) if (event.defaultPrevented || preventDisabledDragDefault(event)) return dragDepthRef.current = Math.max(dragDepthRef.current - 1, 0) if (dragDepthRef.current === 0) setIsDragging(false) } const handleDragOver: React.DragEventHandler = (event) => { onDragOver?.(event) if (event.defaultPrevented || preventDisabledDragDefault(event)) return event.preventDefault() } const handleDrop: React.DragEventHandler = (event) => { onDrop?.(event) if (event.defaultPrevented || preventDisabledDragDefault(event)) return event.preventDefault() dragDepthRef.current = 0 setIsDragging(false) processFiles(Array.from(event.dataTransfer.files ?? [])) } return (
{ if (isDisabled) return if (event.key === "Enter" || event.key === " ") { event.preventDefault() openFileDialog() } }} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop} >
{isDragging ? dragActiveLabel : dropzoneLabel}
{dropzoneDescription &&
{dropzoneDescription}
} {(accept || maxSize || maxFiles || helperText) && (
{helperText ?? [accept ? `Types: ${accept}` : null, maxSize ? `Max size: ${formatBytes(maxSize)}` : null, maxFiles ? `Max files: ${maxFiles}` : null].filter(Boolean).join(" • ")}
)}
{renderActions?.({ openFileDialog, clearFiles, files }) ?? (
{showClearButton && files.length > 0 && ( )}
)}
{showFileList && files.length > 0 && (
{files.map((file, index) => { const state = { file, index, progress: getProgressForFile(progress, file), status: getStatusForFile(status, file), remove: () => removeFile(index), retry: onRetryFile ? () => onRetryFile(file, index) : undefined, removeLabel, } return (
{renderFile?.(state) ?? defaultRenderFile(state)}
) })}
)} {resolvedRejectedFiles.length > 0 && (
{resolvedRejectedFiles.map((rejectedFile, index) => (
{renderRejectedFile?.({ rejectedFile, index }) ?? {rejectedFile.file.name}: {rejectedFile.message}}
))}
)}
) } export { FileUpload, fileMatchesAccept, formatBytes, getFileKey }