mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 06:14:59 +00:00
202 lines
6.1 KiB
TypeScript
202 lines
6.1 KiB
TypeScript
'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'
|