import { useState, useRef, useCallback } from 'react'; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export interface UseFileUploadProps { /** Maximum number of files allowed. @default 1 */ maxFiles?: number; /** Maximum file size in bytes. @default 5MB */ maxSize?: number; /** Called with the current accepted file list whenever it changes. */ onFilesChange?: (files: File[]) => void; /** Called when files are rejected due to size or count limits. */ onError?: (rejectedFiles: File[], reason: 'size' | 'count') => void; /** Whether the upload area is disabled. */ disabled?: boolean; } export interface UseFileUploadReturn { // ── State ───────────────────────────────────────────────────────────────── /** Currently accepted files. */ files: File[]; /** Whether a drag operation is currently active over the drop zone. */ dragActive: boolean; /** Inline error message, or `null` when there is no error. */ errorMessage: string | null; // ── Refs ────────────────────────────────────────────────────────────────── /** Attach to the hidden `` element. */ inputRef: React.RefObject; // ── Handlers ────────────────────────────────────────────────────────────── /** Process a `FileList` from any source (input change, drop, paste, etc.). */ handleFiles: (newFiles: FileList | null) => void; /** Drag enter / over / leave handler — attach to all three events. */ handleDrag: (e: React.DragEvent) => void; /** Drop handler — attach to the drop zone element. */ handleDrop: (e: React.DragEvent) => void; /** Input `onChange` handler — attach to the hidden ``. */ handleChange: (e: React.ChangeEvent) => void; /** Remove a file by its index in the `files` array. */ removeFile: (index: number) => void; /** Programmatically open the native file picker dialog. */ openFileDialog: () => void; } // ───────────────────────────────────────────────────────────────────────────── // Hook // ───────────────────────────────────────────────────────────────────────────── /** * Headless hook for drag-and-drop file upload logic. * * @description * Encapsulates all stateful behaviour for a file upload area: file validation * (size and count limits), drag-active tracking, error messaging, and the * hidden-input ref. Pair with any custom drop-zone UI. * * @example * ```tsx * const { files, dragActive, errorMessage, inputRef, handleDrag, handleDrop, handleChange, removeFile, openFileDialog } = * useFileUpload({ maxFiles: 3, maxSize: 10 * 1024 * 1024 }); * ``` */ export function useFileUpload({ maxFiles = 1, maxSize = 5 * 1024 * 1024, onFilesChange, onError, disabled = false, }: UseFileUploadProps = {}): UseFileUploadReturn { const [files, setFiles] = useState([]); const [dragActive, setDragActive] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const inputRef = useRef(null); const handleFiles = useCallback( (newFiles: FileList | null) => { if (!newFiles) return; setErrorMessage(null); const filesArray = Array.from(newFiles); const oversized = filesArray.filter(f => f.size > maxSize); const validFiles = filesArray.filter(f => f.size <= maxSize); if (oversized.length > 0) { const limitMB = (maxSize / 1024 / 1024).toFixed(0); setErrorMessage( `${oversized.length} file(s) exceed the ${limitMB}MB limit and were not added.` ); onError?.(oversized, 'size'); } const merged = maxFiles === 1 ? validFiles.slice(0, 1) : [...files, ...validFiles].slice(0, maxFiles); const countRejected = maxFiles === 1 ? validFiles.slice(1) : [...files, ...validFiles].slice(maxFiles); if (countRejected.length > 0) { setErrorMessage( `Only ${maxFiles} file(s) allowed. ${countRejected.length} file(s) were not added.` ); onError?.(countRejected, 'count'); } setFiles(merged); onFilesChange?.(merged); }, // eslint-disable-next-line react-hooks/exhaustive-deps [files, maxFiles, maxSize, onError, onFilesChange] ); const handleDrag = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.type === 'dragenter' || e.type === 'dragover') { setDragActive(true); } else if (e.type === 'dragleave') { setDragActive(false); } }, []); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragActive(false); if (disabled) return; handleFiles(e.dataTransfer.files); }, [disabled, handleFiles] ); const handleChange = useCallback( (e: React.ChangeEvent) => { e.preventDefault(); if (disabled) return; handleFiles(e.target.files); }, [disabled, handleFiles] ); const removeFile = useCallback( (index: number) => { setFiles(prev => { const updated = prev.filter((_, i) => i !== index); onFilesChange?.(updated); if (updated.length === 0) setErrorMessage(null); return updated; }); }, [onFilesChange] ); const openFileDialog = useCallback(() => { if (!disabled) { setErrorMessage(null); inputRef.current?.click(); } }, [disabled]); return { files, dragActive, errorMessage, inputRef, handleFiles, handleDrag, handleDrop, handleChange, removeFile, openFileDialog, }; }