Files
metabuilder/components/fakemui/inputs/FileUpload.tsx
2026-03-09 22:30:41 +00:00

202 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import React, { forwardRef, useCallback, useRef, useState } from 'react'
import { FormLabel } from './FormLabel'
import { FormHelperText } from './FormHelperText'
export interface FileUploadProps {
testId?: string
label?: React.ReactNode
helperText?: React.ReactNode
error?: boolean
value?: File | File[]
onChange?: (files: File[]) => void
onRemove?: (file: File) => void
disabled?: boolean
required?: boolean
className?: string
accept?: string
multiple?: boolean
maxSize?: number
maxFiles?: number
dragDrop?: boolean
showPreview?: boolean
variant?: 'default' | 'button' | 'dropzone'
}
export const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(
(
{
testId,
label,
helperText,
error,
value,
onChange,
onRemove,
disabled,
required,
className = '',
accept,
multiple = false,
maxSize,
maxFiles,
dragDrop = true,
showPreview = true,
variant = 'default',
...props
},
ref
) => {
const inputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<File[]>([])
const [isDragging, setIsDragging] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const validateFiles = useCallback((fileList: File[]): File[] => {
let validFiles = fileList
if (maxSize) {
validFiles = validFiles.filter((file) => {
if (file.size > maxSize) {
setErrorMessage(`File ${file.name} exceeds max size of ${maxSize / 1024 / 1024}MB`)
return false
}
return true
})
}
if (maxFiles && validFiles.length > maxFiles) {
setErrorMessage(`Maximum ${maxFiles} files allowed`)
validFiles = validFiles.slice(0, maxFiles)
}
return validFiles
}, [maxSize, maxFiles])
const handleFiles = useCallback((fileList: FileList | null) => {
if (!fileList) return
setErrorMessage(null)
const newFiles = Array.from(fileList)
const validFiles = validateFiles(newFiles)
if (multiple) {
const combined = [...files, ...validFiles]
const limited = maxFiles ? combined.slice(0, maxFiles) : combined
setFiles(limited)
onChange?.(limited)
} else {
setFiles(validFiles.slice(0, 1))
onChange?.(validFiles.slice(0, 1))
}
}, [files, multiple, maxFiles, onChange, validateFiles])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(e.target.files)
}, [handleFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
if (!disabled && dragDrop) {
setIsDragging(true)
}
}, [disabled, dragDrop])
const handleDragLeave = useCallback(() => {
setIsDragging(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (!disabled && dragDrop) {
handleFiles(e.dataTransfer.files)
}
}, [disabled, dragDrop, handleFiles])
const handleRemove = useCallback((file: File) => {
const newFiles = files.filter((f) => f !== file)
setFiles(newFiles)
onChange?.(newFiles)
onRemove?.(file)
}, [files, onChange, onRemove])
const handleClick = useCallback(() => {
inputRef.current?.click()
}, [])
const displayError = errorMessage || (error ? helperText : null)
return (
<div className={`file-upload file-upload--${variant} ${error || errorMessage ? 'file-upload--error' : ''} ${disabled ? 'file-upload--disabled' : ''} ${className}`} data-testid={testId} aria-label={typeof label === 'string' ? label : undefined}>
{label && <FormLabel required={required}>{label}</FormLabel>}
<div
className={`file-upload__dropzone ${isDragging ? 'file-upload__dropzone--dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={(el) => {
if (typeof ref === 'function') ref(el)
else if (ref) ref.current = el
;(inputRef as React.MutableRefObject<HTMLInputElement | null>).current = el
}}
type="file"
onChange={handleChange}
disabled={disabled}
accept={accept}
multiple={multiple}
className="file-upload__input"
{...props}
/>
<div className="file-upload__content">
<span className="file-upload__icon">📁</span>
<span className="file-upload__text">
{isDragging
? 'Drop files here'
: dragDrop
? 'Drag and drop or click to upload'
: 'Click to upload'}
</span>
{accept && <span className="file-upload__hint">Accepted: {accept}</span>}
</div>
</div>
{showPreview && files.length > 0 && (
<div className="file-upload__preview">
{files.map((file, index) => (
<div key={`${file.name}-${index}`} className="file-upload__file">
<span className="file-upload__filename">{file.name}</span>
<span className="file-upload__size">
{(file.size / 1024).toFixed(1)} KB
</span>
<button
type="button"
className="file-upload__remove"
onClick={(e) => {
e.stopPropagation()
handleRemove(file)
}}
disabled={disabled}
aria-label={`Remove ${file.name}`}
>
×
</button>
</div>
))}
</div>
)}
{displayError && <FormHelperText error>{displayError}</FormHelperText>}
{!displayError && helperText && <FormHelperText>{helperText}</FormHelperText>}
</div>
)
}
)
FileUpload.displayName = 'FileUpload'