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,
};
}