import { UploadIcon, XIcon } from 'lucide-react'; import { useState } from 'react'; import type { DropEvent } from 'react-aria'; import type { FileDropItem, ValidationResult } from 'react-aria-components'; import { Button as AriaButton } from 'react-aria-components/Button'; import { DropZone as AriaDropZone } from 'react-aria-components/DropZone'; import { FileTrigger as AriaFileTrigger, type FileTriggerProps as AriaFileTriggerProps, } from 'react-aria-components/FileTrigger'; import { GridList, GridListItem } from 'react-aria-components/GridList'; import { TooltipTrigger } from 'react-aria-components/Tooltip'; import { twMerge } from 'tailwind-merge'; import { tv } from 'tailwind-variants'; import { Button } from './Button'; import { Description, Label } from './Field'; import { Tooltip } from './Tooltip'; import { focusRing } from './utils'; export interface FileInputProps extends Omit< AriaFileTriggerProps, 'children' | 'onSelect' > { label?: string; description?: string; errorMessage?: string | ((validation: ValidationResult) => string); isRequired?: boolean; isDisabled?: boolean; isInvalid?: boolean; /** * Text to display in the drop zone when no files are selected */ placeholder?: string; /** * Show file size in the selected files list */ showFileSize?: boolean; /** * Custom class for the drop zone container */ className?: string; /** * Controlled value - the current files selected. * When provided, the component becomes controlled. */ value?: File[] | null; /** * Callback when files change. * We use onChange rather than onSelect to make it clear that we are diverting from AriaFileTrigger which doesn't support being controlled */ onChange?: (files: File[] | null) => void; } const labelStyles = tv({ variants: { isRequired: { true: "after:text-warning-500 after:dark:text-warning-300 after:ml-0.5 after:content-['*']", }, }, }); const dropZoneStyles = tv({ base: 'group hover:border-primary-900 dark:hover:border-secondary-600 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed bg-white p-4 text-center transition-colors outline-none hover:bg-zinc-50 dark:bg-zinc-900 dark:hover:bg-zinc-800', variants: { isDisabled: { true: 'cursor-not-allowed border-gray-200 bg-gray-50 hover:border-gray-200 hover:bg-gray-50 dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-zinc-700 dark:hover:bg-zinc-800', false: 'border-zinc-300 dark:border-zinc-600', }, isInvalid: { true: 'border-warning-600 hover:border-warning-700 dark:border-warning-600 dark:hover:border-warning-500', }, isDropTarget: { true: 'border-primary-900 bg-primary-50 dark:border-secondary-600 dark:bg-secondary-900/20', }, }, }); const buttonStyles = tv({ extend: focusRing, base: 'flex w-full flex-col items-center justify-center gap-2 rounded-md font-normal', }); const iconStyles = tv({ base: 'size-8', variants: { isDisabled: { true: 'text-gray-300 dark:text-zinc-600', false: 'text-zinc-400 dark:text-zinc-500', }, }, }); const textStyles = tv({ base: 'text-sm', variants: { isDisabled: { true: 'text-gray-400 dark:text-zinc-600', false: 'text-zinc-600 dark:text-zinc-400', }, }, }); const fileItemStyles = tv({ extend: focusRing, base: 'flex cursor-default items-center justify-between gap-2 rounded-md border bg-white px-3 py-2 text-sm outline-none dark:bg-zinc-800', variants: { isSelected: { true: 'border-primary-900 bg-primary-50 dark:border-secondary-600 dark:bg-secondary-900/20', false: 'border-zinc-200 dark:border-zinc-700', }, isFocusVisible: { true: 'outline-2', false: 'outline-0', }, isDisabled: { true: 'opacity-50', }, }, }); function formatFileSize(bytes: number): string { if (bytes === 0) { return '0 Bytes'; } const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; } function matchesAcceptedType( fileType: string, acceptedTypes: readonly string[], ): boolean { return acceptedTypes.some((accepted) => { if (accepted.endsWith('/*')) { const prefix = accepted.slice(0, -2); return fileType.startsWith(prefix + '/'); } return fileType === accepted; }); } export function FileInput({ label, description, errorMessage, isRequired, isDisabled, isInvalid, placeholder = 'Drag a file here or click to upload', showFileSize = true, className, allowsMultiple, acceptedFileTypes, value, onChange, ...props }: FileInputProps) { const isControlled = value !== undefined; const [internalFiles, setInternalFiles] = useState([]); // Use controlled value if provided, otherwise use internal state const selectedFiles = isControlled ? (value ?? []) : internalFiles; const updateFiles = (files: File[] | null) => { if (!isControlled) { setInternalFiles(files ?? []); } onChange?.(files); }; const handleSelect = (fileList: FileList | null) => { if (fileList) { const files = Array.from(fileList); updateFiles(files); } }; const handleDrop = async (e: DropEvent) => { // Filter for files only const filePromises = e.items .filter((item): item is FileDropItem => item.kind === 'file') .map((item) => item.getFile()); const files = await Promise.all(filePromises); if (files.length > 0) { // If not allowing multiple, only take the first file const filesToAdd = allowsMultiple ? files : files.slice(0, 1); // Filter by accepted file types if specified const filteredFiles = acceptedFileTypes ? filesToAdd.filter((file) => matchesAcceptedType(file.type, acceptedFileTypes), ) : filesToAdd; if (filteredFiles.length > 0) { updateFiles(filteredFiles); } } }; const removeFile = (index: number) => { const updated = selectedFiles.filter((_, i) => i !== index); updateFiles(updated.length === 0 ? null : updated); }; const clearFiles = () => { updateFiles(null); }; return (
{/* We wrap the file trigger in a label to make sure that the label is associated with its hidden input for a11y */} {selectedFiles.length > 0 && !isDisabled && (
{selectedFiles.length}{' '} {selectedFiles.length === 1 ? 'file' : 'files'} selected
{selectedFiles.map((file, index) => ( fileItemStyles(renderProps)} >
{file.name}
{showFileSize && (
{formatFileSize(file.size)}
)}
Remove {file.name}
))}
)} {errorMessage && (
{typeof errorMessage === 'function' ? errorMessage({ isInvalid: isInvalid ?? false, validationErrors: [], validationDetails: { badInput: false, customError: false, patternMismatch: false, rangeOverflow: false, rangeUnderflow: false, stepMismatch: false, tooLong: false, tooShort: false, typeMismatch: false, valueMissing: false, valid: !isInvalid, }, }) : errorMessage}
)}
); }