From ff8df41d4bd12b2b090eb2b8c4702376da264989 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 9 Apr 2026 22:52:18 -0300 Subject: [PATCH] feat: add file upload component with drag-and-drop support --- components.json | 18 +- src/ui/components/shadcn/file-upload.tsx | 1442 ++++++++++++++++++ src/ui/hooks/use-as-ref.ts | 14 + src/ui/hooks/use-isomorphic-layout-effect.ts | 6 + src/ui/hooks/use-lazy-ref.ts | 13 + 5 files changed, 1485 insertions(+), 8 deletions(-) create mode 100644 src/ui/components/shadcn/file-upload.tsx create mode 100644 src/ui/hooks/use-as-ref.ts create mode 100644 src/ui/hooks/use-isomorphic-layout-effect.ts create mode 100644 src/ui/hooks/use-lazy-ref.ts diff --git a/components.json b/components.json index 65a98bf..5d69feb 100644 --- a/components.json +++ b/components.json @@ -12,14 +12,16 @@ }, "iconLibrary": "lucide", "rtl": false, - "aliases": { - "components": "@/ui/components", - "ui": "@/ui/components/shadcn", - "lib": "@/ui/components/shadcn/lib", - "utils": "@/ui/components/shadcn/lib/utils", - "hooks": "@/ui/hooks" - }, "menuColor": "default", "menuAccent": "subtle", - "registries": {} + "aliases": { + "components": "@/ui/components", + "utils": "@/ui/components/shadcn/lib/utils", + "ui": "@/ui/components/shadcn", + "lib": "@/ui/components/shadcn/lib", + "hooks": "@/ui/hooks" + }, + "registries": { + "@diceui": "https://diceui.com/r/{name}.json" + } } diff --git a/src/ui/components/shadcn/file-upload.tsx b/src/ui/components/shadcn/file-upload.tsx new file mode 100644 index 0000000..8db4600 --- /dev/null +++ b/src/ui/components/shadcn/file-upload.tsx @@ -0,0 +1,1442 @@ +'use client'; + +import { cn } from '@/ui/components/shadcn/lib/utils'; +import { useAsRef } from '@/ui/hooks/use-as-ref'; +import { useLazyRef } from '@/ui/hooks/use-lazy-ref'; +import { + FileArchiveIcon, + FileAudioIcon, + FileCodeIcon, + FileCogIcon, + FileIcon, + FileTextIcon, + FileVideoIcon, +} from 'lucide-react'; +import { + Direction as DirectionPrimitive, + Slot as SlotPrimitive, +} from 'radix-ui'; +import * as React from 'react'; + +const ROOT_NAME = 'FileUpload'; +const DROPZONE_NAME = 'FileUploadDropzone'; +const TRIGGER_NAME = 'FileUploadTrigger'; +const LIST_NAME = 'FileUploadList'; +const ITEM_NAME = 'FileUploadItem'; +const ITEM_PREVIEW_NAME = 'FileUploadItemPreview'; +const ITEM_METADATA_NAME = 'FileUploadItemMetadata'; +const ITEM_PROGRESS_NAME = 'FileUploadItemProgress'; +const ITEM_DELETE_NAME = 'FileUploadItemDelete'; +const CLEAR_NAME = 'FileUploadClear'; + +function formatBytes(bytes: number) { + if (bytes === 0) return '0 B'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / 1024 ** i).toFixed(i ? 1 : 0)} ${sizes[i]}`; +} + +function getFileIcon(file: File) { + const type = file.type; + const extension = file.name.split('.').pop()?.toLowerCase() ?? ''; + + if (type.startsWith('video/')) { + return ; + } + + if (type.startsWith('audio/')) { + return ; + } + + if ( + type.startsWith('text/') || + ['txt', 'md', 'rtf', 'pdf'].includes(extension) + ) { + return ; + } + + if ( + [ + 'html', + 'css', + 'js', + 'jsx', + 'ts', + 'tsx', + 'json', + 'xml', + 'php', + 'py', + 'rb', + 'java', + 'c', + 'cpp', + 'cs', + ].includes(extension) + ) { + return ; + } + + if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) { + return ; + } + + if ( + ['exe', 'msi', 'app', 'apk', 'deb', 'rpm'].includes(extension) || + type.startsWith('application/') + ) { + return ; + } + + return ; +} + +type Direction = 'ltr' | 'rtl'; + +interface FileState { + file: File; + progress: number; + error?: string; + status: 'idle' | 'uploading' | 'error' | 'success'; +} + +interface StoreState { + files: Map; + dragOver: boolean; + invalid: boolean; +} + +type StoreAction = + | { type: 'ADD_FILES'; files: File[] } + | { type: 'SET_FILES'; files: File[] } + | { type: 'SET_PROGRESS'; file: File; progress: number } + | { type: 'SET_SUCCESS'; file: File } + | { type: 'SET_ERROR'; file: File; error: string } + | { type: 'REMOVE_FILE'; file: File } + | { type: 'SET_DRAG_OVER'; dragOver: boolean } + | { type: 'SET_INVALID'; invalid: boolean } + | { type: 'CLEAR' }; + +type Store = { + getState: () => StoreState; + dispatch: (action: StoreAction) => void; + subscribe: (listener: () => void) => () => void; +}; + +const StoreContext = React.createContext(null); + +function useStoreContext(consumerName: string) { + const context = React.useContext(StoreContext); + if (!context) { + throw new Error( + `\`${consumerName}\` must be used within \`${ROOT_NAME}\`` + ); + } + return context; +} + +function useStore(selector: (state: StoreState) => T): T { + const store = useStoreContext('useStore'); + + const lastValueRef = useLazyRef<{ value: T; state: StoreState } | null>( + () => null + ); + + const getSnapshot = React.useCallback(() => { + const state = store.getState(); + const prevValue = lastValueRef.current; + + if (prevValue && prevValue.state === state) { + return prevValue.value; + } + + const nextValue = selector(state); + lastValueRef.current = { value: nextValue, state }; + return nextValue; + }, [store, selector, lastValueRef]); + + return React.useSyncExternalStore( + store.subscribe, + getSnapshot, + getSnapshot + ); +} + +interface FileUploadContextValue { + inputId: string; + dropzoneId: string; + listId: string; + labelId: string; + disabled: boolean; + dir: Direction; + inputRef: React.RefObject; + urlCache: WeakMap; +} + +const FileUploadContext = React.createContext( + null +); + +function useFileUploadContext(consumerName: string) { + const context = React.useContext(FileUploadContext); + if (!context) { + throw new Error( + `\`${consumerName}\` must be used within \`${ROOT_NAME}\`` + ); + } + return context; +} + +interface FileUploadProps extends Omit< + React.ComponentProps<'div'>, + 'defaultValue' | 'onChange' +> { + value?: File[]; + defaultValue?: File[]; + onValueChange?: (files: File[]) => void; + onAccept?: (files: File[]) => void; + onFileAccept?: (file: File) => void; + onFileReject?: (file: File, message: string) => void; + onFileValidate?: (file: File) => string | null | undefined; + onUpload?: ( + files: File[], + options: { + onProgress: (file: File, progress: number) => void; + onSuccess: (file: File) => void; + onError: (file: File, error: Error) => void; + } + ) => Promise | void; + accept?: string; + maxFiles?: number; + maxSize?: number; + dir?: Direction; + label?: string; + name?: string; + asChild?: boolean; + disabled?: boolean; + invalid?: boolean; + multiple?: boolean; + required?: boolean; +} + +function FileUpload(props: FileUploadProps) { + const { + value, + defaultValue, + onValueChange, + onAccept, + onFileAccept, + onFileReject, + onFileValidate, + onUpload, + accept, + maxFiles, + maxSize, + dir: dirProp, + label, + name, + asChild, + disabled = false, + invalid = false, + multiple = false, + required = false, + children, + className, + ...rootProps + } = props; + + const inputId = React.useId(); + const dropzoneId = React.useId(); + const listId = React.useId(); + const labelId = React.useId(); + + const dir = DirectionPrimitive.useDirection(dirProp); + const listeners = useLazyRef(() => new Set<() => void>()).current; + const files = useLazyRef>(() => new Map()).current; + const urlCache = useLazyRef(() => new WeakMap()).current; + const inputRef = React.useRef(null); + const isControlled = value !== undefined; + + const propsRef = useAsRef({ + onValueChange, + onAccept, + onFileAccept, + onFileReject, + onFileValidate, + onUpload, + }); + + const store = React.useMemo(() => { + let state: StoreState = { + files, + dragOver: false, + invalid: invalid, + }; + + function reducer(state: StoreState, action: StoreAction): StoreState { + switch (action.type) { + case 'ADD_FILES': { + for (const file of action.files) { + files.set(file, { + file, + progress: 0, + status: 'idle', + }); + } + + if (propsRef.current.onValueChange) { + const fileList = Array.from(files.values()).map( + (fileState) => fileState.file + ); + propsRef.current.onValueChange(fileList); + } + return { ...state, files }; + } + + case 'SET_FILES': { + const newFileSet = new Set(action.files); + for (const existingFile of files.keys()) { + if (!newFileSet.has(existingFile)) { + files.delete(existingFile); + } + } + + for (const file of action.files) { + const existingState = files.get(file); + if (!existingState) { + files.set(file, { + file, + progress: 0, + status: 'idle', + }); + } + } + return { ...state, files }; + } + + case 'SET_PROGRESS': { + const fileState = files.get(action.file); + if (fileState) { + files.set(action.file, { + ...fileState, + progress: action.progress, + status: 'uploading', + }); + } + return { ...state, files }; + } + + case 'SET_SUCCESS': { + const fileState = files.get(action.file); + if (fileState) { + files.set(action.file, { + ...fileState, + progress: 100, + status: 'success', + }); + } + return { ...state, files }; + } + + case 'SET_ERROR': { + const fileState = files.get(action.file); + if (fileState) { + files.set(action.file, { + ...fileState, + error: action.error, + status: 'error', + }); + } + return { ...state, files }; + } + + case 'REMOVE_FILE': { + const cachedUrl = urlCache.get(action.file); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + urlCache.delete(action.file); + } + + files.delete(action.file); + + if (propsRef.current.onValueChange) { + const fileList = Array.from(files.values()).map( + (fileState) => fileState.file + ); + propsRef.current.onValueChange(fileList); + } + return { ...state, files }; + } + + case 'SET_DRAG_OVER': { + return { ...state, dragOver: action.dragOver }; + } + + case 'SET_INVALID': { + return { ...state, invalid: action.invalid }; + } + + case 'CLEAR': { + for (const file of files.keys()) { + const cachedUrl = urlCache.get(file); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + urlCache.delete(file); + } + } + + files.clear(); + if (propsRef.current.onValueChange) { + propsRef.current.onValueChange([]); + } + return { ...state, files, invalid: false }; + } + + default: + return state; + } + } + + return { + getState: () => state, + dispatch: (action) => { + // eslint-disable-next-line react-hooks/immutability + state = reducer(state, action); + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + }, [listeners, files, invalid, propsRef, urlCache]); + + const acceptTypes = React.useMemo( + () => accept?.split(',').map((t) => t.trim()) ?? null, + [accept] + ); + + const onProgress = useLazyRef(() => { + let frame = 0; + return (file: File, progress: number) => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + store.dispatch({ + type: 'SET_PROGRESS', + file, + progress: Math.min(Math.max(0, progress), 100), + }); + }); + }; + }).current; + + React.useEffect(() => { + if (isControlled) { + store.dispatch({ type: 'SET_FILES', files: value }); + } else if ( + defaultValue && + defaultValue.length > 0 && + !store.getState().files.size + ) { + store.dispatch({ type: 'SET_FILES', files: defaultValue }); + } + }, [value, defaultValue, isControlled, store]); + + React.useEffect(() => { + return () => { + for (const file of files.keys()) { + const cachedUrl = urlCache.get(file); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + } + } + }; + }, [files, urlCache]); + + const onFilesUpload = React.useCallback( + async (files: File[]) => { + try { + for (const file of files) { + store.dispatch({ type: 'SET_PROGRESS', file, progress: 0 }); + } + + if (propsRef.current.onUpload) { + await propsRef.current.onUpload(files, { + onProgress, + onSuccess: (file) => { + store.dispatch({ type: 'SET_SUCCESS', file }); + }, + onError: (file, error) => { + store.dispatch({ + type: 'SET_ERROR', + file, + error: error.message ?? 'Upload failed', + }); + }, + }); + } else { + for (const file of files) { + store.dispatch({ type: 'SET_SUCCESS', file }); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Upload failed'; + for (const file of files) { + store.dispatch({ + type: 'SET_ERROR', + file, + error: errorMessage, + }); + } + } + }, + [store, propsRef, onProgress] + ); + + const onFilesChange = React.useCallback( + (originalFiles: File[]) => { + if (disabled) return; + + let filesToProcess = [...originalFiles]; + let invalid = false; + + if (maxFiles) { + const currentCount = store.getState().files.size; + const remainingSlotCount = Math.max(0, maxFiles - currentCount); + + if (remainingSlotCount < filesToProcess.length) { + const rejectedFiles = + filesToProcess.slice(remainingSlotCount); + invalid = true; + + filesToProcess = filesToProcess.slice( + 0, + remainingSlotCount + ); + + for (const file of rejectedFiles) { + let rejectionMessage = `Maximum ${maxFiles} files allowed`; + + if (propsRef.current.onFileValidate) { + const validationMessage = + propsRef.current.onFileValidate(file); + if (validationMessage) { + rejectionMessage = validationMessage; + } + } + + propsRef.current.onFileReject?.(file, rejectionMessage); + } + } + } + + const acceptedFiles: File[] = []; + const rejectedFiles: { file: File; message: string }[] = []; + + for (const file of filesToProcess) { + let rejected = false; + let rejectionMessage = ''; + + if (propsRef.current.onFileValidate) { + const validationMessage = + propsRef.current.onFileValidate(file); + if (validationMessage) { + rejectionMessage = validationMessage; + propsRef.current.onFileReject?.(file, rejectionMessage); + rejected = true; + invalid = true; + continue; + } + } + + if (acceptTypes) { + const fileType = file.type; + const fileExtension = `.${file.name.split('.').pop()}`; + + if ( + !acceptTypes.some( + (type) => + type === fileType || + type === fileExtension || + (type.includes('/*') && + fileType.startsWith( + type.replace('/*', '/') + )) + ) + ) { + rejectionMessage = 'File type not accepted'; + propsRef.current.onFileReject?.(file, rejectionMessage); + rejected = true; + invalid = true; + } + } + + if (maxSize && file.size > maxSize) { + rejectionMessage = 'File too large'; + propsRef.current.onFileReject?.(file, rejectionMessage); + rejected = true; + invalid = true; + } + + if (!rejected) { + acceptedFiles.push(file); + } else { + rejectedFiles.push({ file, message: rejectionMessage }); + } + } + + if (invalid) { + store.dispatch({ type: 'SET_INVALID', invalid }); + setTimeout(() => { + store.dispatch({ type: 'SET_INVALID', invalid: false }); + }, 2000); + } + + if (acceptedFiles.length > 0) { + store.dispatch({ type: 'ADD_FILES', files: acceptedFiles }); + + if (isControlled && propsRef.current.onValueChange) { + const currentFiles = Array.from( + store.getState().files.values() + ).map((f) => f.file); + propsRef.current.onValueChange([...currentFiles]); + } + + if (propsRef.current.onAccept) { + propsRef.current.onAccept(acceptedFiles); + } + + for (const file of acceptedFiles) { + propsRef.current.onFileAccept?.(file); + } + + if (propsRef.current.onUpload) { + requestAnimationFrame(() => { + onFilesUpload(acceptedFiles); + }); + } + } + }, + [ + store, + isControlled, + propsRef, + onFilesUpload, + maxFiles, + acceptTypes, + maxSize, + disabled, + ] + ); + + const onInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + onFilesChange(files); + event.target.value = ''; + }, + [onFilesChange] + ); + + const contextValue = React.useMemo( + () => ({ + dropzoneId, + inputId, + listId, + labelId, + dir, + disabled, + inputRef, + urlCache, + }), + [dropzoneId, inputId, listId, labelId, dir, disabled, urlCache] + ); + + const RootPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + + {children} + +
+ {label ?? 'File upload'} +
+
+
+
+ ); +} + +interface FileUploadDropzoneProps extends React.ComponentProps<'div'> { + asChild?: boolean; +} + +function FileUploadDropzone(props: FileUploadDropzoneProps) { + const { + asChild, + className, + onClick: onClickProp, + onDragOver: onDragOverProp, + onDragEnter: onDragEnterProp, + onDragLeave: onDragLeaveProp, + onDrop: onDropProp, + onPaste: onPasteProp, + onKeyDown: onKeyDownProp, + ...dropzoneProps + } = props; + + const context = useFileUploadContext(DROPZONE_NAME); + const store = useStoreContext(DROPZONE_NAME); + const dragOver = useStore((state) => state.dragOver); + const invalid = useStore((state) => state.invalid); + + const propsRef = useAsRef({ + onClick: onClickProp, + onDragOver: onDragOverProp, + onDragEnter: onDragEnterProp, + onDragLeave: onDragLeaveProp, + onDrop: onDropProp, + onPaste: onPasteProp, + onKeyDown: onKeyDownProp, + }); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onClick?.(event); + + if (event.defaultPrevented) return; + + const target = event.target; + + const isFromTrigger = + target instanceof HTMLElement && + target.closest('[data-slot="file-upload-trigger"]'); + + if (!isFromTrigger) { + context.inputRef.current?.click(); + } + }, + [context.inputRef, propsRef] + ); + + const onDragOver = React.useCallback( + (event: React.DragEvent) => { + propsRef.current.onDragOver?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: true }); + }, + [store, propsRef] + ); + + const onDragEnter = React.useCallback( + (event: React.DragEvent) => { + propsRef.current.onDragEnter?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: true }); + }, + [store, propsRef] + ); + + const onDragLeave = React.useCallback( + (event: React.DragEvent) => { + propsRef.current.onDragLeave?.(event); + + if (event.defaultPrevented) return; + + const relatedTarget = event.relatedTarget; + if ( + relatedTarget && + relatedTarget instanceof Node && + event.currentTarget.contains(relatedTarget) + ) { + return; + } + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: false }); + }, + [store, propsRef] + ); + + const onDrop = React.useCallback( + (event: React.DragEvent) => { + propsRef.current.onDrop?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: false }); + + const files = Array.from(event.dataTransfer.files); + const inputElement = context.inputRef.current; + if (!inputElement) return; + + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + + // eslint-disable-next-line react-hooks/immutability + inputElement.files = dataTransfer.files; + inputElement.dispatchEvent(new Event('change', { bubbles: true })); + }, + [store, context.inputRef, propsRef] + ); + + const onPaste = React.useCallback( + (event: React.ClipboardEvent) => { + propsRef.current.onPaste?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: 'SET_DRAG_OVER', dragOver: false }); + + const items = event.clipboardData?.items; + if (!items) return; + + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item?.kind === 'file') { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length === 0) return; + + const inputElement = context.inputRef.current; + if (!inputElement) return; + + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + + // eslint-disable-next-line react-hooks/immutability + inputElement.files = dataTransfer.files; + inputElement.dispatchEvent(new Event('change', { bubbles: true })); + }, + [store, context.inputRef, propsRef] + ); + + const onKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + propsRef.current.onKeyDown?.(event); + + if ( + !event.defaultPrevented && + (event.key === 'Enter' || event.key === ' ') + ) { + event.preventDefault(); + context.inputRef.current?.click(); + } + }, + [context.inputRef, propsRef] + ); + + const DropzonePrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + ); +} + +interface FileUploadTriggerProps extends React.ComponentProps<'button'> { + asChild?: boolean; +} + +function FileUploadTrigger(props: FileUploadTriggerProps) { + const { asChild, onClick: onClickProp, ...triggerProps } = props; + + const context = useFileUploadContext(TRIGGER_NAME); + + const propsRef = useAsRef({ + onClick: onClickProp, + }); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onClick?.(event); + + if (event.defaultPrevented) return; + + context.inputRef.current?.click(); + }, + [context.inputRef, propsRef] + ); + + const TriggerPrimitive = asChild ? SlotPrimitive.Slot : 'button'; + + return ( + + ); +} + +interface FileUploadListProps extends React.ComponentProps<'div'> { + orientation?: 'horizontal' | 'vertical'; + asChild?: boolean; + forceMount?: boolean; +} + +function FileUploadList(props: FileUploadListProps) { + const { + className, + orientation = 'vertical', + asChild, + forceMount, + ...listProps + } = props; + + const context = useFileUploadContext(LIST_NAME); + const fileCount = useStore((state) => state.files.size); + const shouldRender = forceMount || fileCount > 0; + + if (!shouldRender) return null; + + const ListPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + ); +} + +interface FileUploadItemContextValue { + id: string; + fileState: FileState | undefined; + nameId: string; + sizeId: string; + statusId: string; + messageId: string; +} + +const FileUploadItemContext = + React.createContext(null); + +function useFileUploadItemContext(consumerName: string) { + const context = React.useContext(FileUploadItemContext); + if (!context) { + throw new Error( + `\`${consumerName}\` must be used within \`${ITEM_NAME}\`` + ); + } + return context; +} + +interface FileUploadItemProps extends React.ComponentProps<'div'> { + value: File; + asChild?: boolean; +} + +function FileUploadItem(props: FileUploadItemProps) { + const { value, asChild, className, ...itemProps } = props; + + const id = React.useId(); + const statusId = `${id}-status`; + const nameId = `${id}-name`; + const sizeId = `${id}-size`; + const messageId = `${id}-message`; + + const context = useFileUploadContext(ITEM_NAME); + const fileState = useStore((state) => state.files.get(value)); + const fileCount = useStore((state) => state.files.size); + const fileIndex = useStore((state) => { + const files = Array.from(state.files.keys()); + return files.indexOf(value) + 1; + }); + + const itemContext = React.useMemo( + () => ({ + id, + fileState, + nameId, + sizeId, + statusId, + messageId, + }), + [id, fileState, statusId, nameId, sizeId, messageId] + ); + + if (!fileState) return null; + + const statusText = fileState.error + ? `Error: ${fileState.error}` + : fileState.status === 'uploading' + ? `Uploading: ${fileState.progress}% complete` + : fileState.status === 'success' + ? 'Upload complete' + : 'Ready to upload'; + + const ItemPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + {props.children} + + {statusText} + + + + ); +} + +interface FileUploadItemPreviewProps extends React.ComponentProps<'div'> { + render?: (file: File, fallback: () => React.ReactNode) => React.ReactNode; + asChild?: boolean; +} + +function FileUploadItemPreview(props: FileUploadItemPreviewProps) { + const { render, asChild, children, className, ...previewProps } = props; + + const itemContext = useFileUploadItemContext(ITEM_PREVIEW_NAME); + const context = useFileUploadContext(ITEM_PREVIEW_NAME); + + const getDefaultRender = React.useCallback( + (file: File) => { + if (itemContext.fileState?.file.type.startsWith('image/')) { + let url = context.urlCache.get(file); + if (!url) { + url = URL.createObjectURL(file); + context.urlCache.set(file, url); + } + + return ( + // biome-ignore lint/performance/noImgElement: dynamic file URLs from user uploads don't work well with Next.js Image optimization + // eslint-disable-next-line @next/next/no-img-element + {file.name} + ); + } + + return getFileIcon(file); + }, + [itemContext.fileState?.file.type, context.urlCache] + ); + + const onPreviewRender = React.useCallback( + (file: File) => { + if (render) { + return render(file, () => getDefaultRender(file)); + } + + return getDefaultRender(file); + }, + [render, getDefaultRender] + ); + + if (!itemContext.fileState) return null; + + const ItemPreviewPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + svg]:size-10', + className + )} + > + {onPreviewRender(itemContext.fileState.file)} + {children} + + ); +} + +interface FileUploadItemMetadataProps extends React.ComponentProps<'div'> { + asChild?: boolean; + size?: 'default' | 'sm'; +} + +function FileUploadItemMetadata(props: FileUploadItemMetadataProps) { + const { + asChild, + size = 'default', + children, + className, + ...metadataProps + } = props; + + const context = useFileUploadContext(ITEM_METADATA_NAME); + const itemContext = useFileUploadItemContext(ITEM_METADATA_NAME); + + if (!itemContext.fileState) return null; + + const ItemMetadataPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + {children ?? ( + <> + + {itemContext.fileState.file.name} + + + {formatBytes(itemContext.fileState.file.size)} + + {itemContext.fileState.error && ( + + {itemContext.fileState.error} + + )} + + )} + + ); +} +interface FileUploadItemProgressProps extends React.ComponentProps<'div'> { + variant?: 'linear' | 'circular' | 'fill'; + size?: number; + asChild?: boolean; + forceMount?: boolean; +} + +function FileUploadItemProgress(props: FileUploadItemProgressProps) { + const { + variant = 'linear', + size = 40, + asChild, + forceMount, + className, + ...progressProps + } = props; + + const itemContext = useFileUploadItemContext(ITEM_PROGRESS_NAME); + + if (!itemContext.fileState) return null; + + const shouldRender = forceMount || itemContext.fileState.progress !== 100; + + if (!shouldRender) return null; + + const ItemProgressPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + switch (variant) { + case 'circular': { + const circumference = 2 * Math.PI * ((size - 4) / 2); + const strokeDashoffset = + circumference - + (itemContext.fileState.progress / 100) * circumference; + + return ( + + + + + + + ); + } + + case 'fill': { + const progressPercentage = itemContext.fileState.progress; + const topInset = 100 - progressPercentage; + + return ( + + ); + } + + default: + return ( + +
+ + ); + } +} + +interface FileUploadItemDeleteProps extends React.ComponentProps<'button'> { + asChild?: boolean; +} + +function FileUploadItemDelete(props: FileUploadItemDeleteProps) { + const { asChild, onClick: onClickProp, ...deleteProps } = props; + + const store = useStoreContext(ITEM_DELETE_NAME); + const itemContext = useFileUploadItemContext(ITEM_DELETE_NAME); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); + + if (!itemContext.fileState || event.defaultPrevented) return; + + store.dispatch({ + type: 'REMOVE_FILE', + file: itemContext.fileState.file, + }); + }, + [store, itemContext.fileState, onClickProp] + ); + + if (!itemContext.fileState) return null; + + const ItemDeletePrimitive = asChild ? SlotPrimitive.Slot : 'button'; + + return ( + + ); +} + +interface FileUploadClearProps extends React.ComponentProps<'button'> { + forceMount?: boolean; + asChild?: boolean; +} + +function FileUploadClear(props: FileUploadClearProps) { + const { + asChild, + forceMount, + disabled, + onClick: onClickProp, + ...clearProps + } = props; + + const context = useFileUploadContext(CLEAR_NAME); + const store = useStoreContext(CLEAR_NAME); + const fileCount = useStore((state) => state.files.size); + + const isDisabled = disabled || context.disabled; + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); + + if (event.defaultPrevented) return; + + store.dispatch({ type: 'CLEAR' }); + }, + [store, onClickProp] + ); + + const shouldRender = forceMount || fileCount > 0; + + if (!shouldRender) return null; + + const ClearPrimitive = asChild ? SlotPrimitive.Slot : 'button'; + + return ( + + ); +} + +export { + FileUpload, + FileUploadClear, + FileUploadDropzone, + FileUploadItem, + FileUploadItemDelete, + FileUploadItemMetadata, + FileUploadItemPreview, + FileUploadItemProgress, + FileUploadList, + type FileUploadProps, + FileUploadTrigger, + useStore as useFileUpload, +}; diff --git a/src/ui/hooks/use-as-ref.ts b/src/ui/hooks/use-as-ref.ts new file mode 100644 index 0000000..133f5c8 --- /dev/null +++ b/src/ui/hooks/use-as-ref.ts @@ -0,0 +1,14 @@ +import { useIsomorphicLayoutEffect } from '@/ui/hooks/use-isomorphic-layout-effect'; +import * as React from 'react'; + +function useAsRef(props: T) { + const ref = React.useRef(props); + + useIsomorphicLayoutEffect(() => { + ref.current = props; + }); + + return ref; +} + +export { useAsRef }; diff --git a/src/ui/hooks/use-isomorphic-layout-effect.ts b/src/ui/hooks/use-isomorphic-layout-effect.ts new file mode 100644 index 0000000..3d01cd5 --- /dev/null +++ b/src/ui/hooks/use-isomorphic-layout-effect.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; + +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; + +export { useIsomorphicLayoutEffect }; diff --git a/src/ui/hooks/use-lazy-ref.ts b/src/ui/hooks/use-lazy-ref.ts new file mode 100644 index 0000000..9868d4e --- /dev/null +++ b/src/ui/hooks/use-lazy-ref.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; + +function useLazyRef(fn: () => T) { + const ref = React.useRef(null); + + if (ref.current === null) { + ref.current = fn(); + } + + return ref as React.RefObject; +} + +export { useLazyRef };