"use client"
import * as React from "react"
import { Loader2 } from "lucide-react"
import { ImagePlusIcon } from "../icons-v2-generated/audio-and-visual/image-plus-icon"
import { Download02Icon } from "../icons-v2-generated/interface/download-02-icon"
import { TrashIcon } from "../icons-v2-generated/interface/trash-icon"
import { FileIcon } from "../icons-v2-generated/documents/file-icon"
import { cn } from "../../utils/cn"
import { FieldWrapper } from "./field-wrapper"
/** A managed file entry for async upload workflows (e.g. presigned URL uploads) */
export interface ManagedFileEntry {
/** Unique identifier for this file (e.g. temp attachment ID from backend) */
id: string
/** Display file name */
fileName: string
/** File size in bytes */
fileSize: number
/** Upload status */
status: "uploading" | "uploaded" | "error"
/** Error message when status is "error" */
error?: string
}
/**
* The metadata this component needs to validate and list a file. A browser
* `File` satisfies it structurally, and so can a host's own file handle: a
* native shell's OS picker hands back a path plus metadata and never a `File`,
* because the bytes deliberately stay outside the WebView. See `pickFiles`.
*/
export interface FileUploadCandidate {
name: string
size: number
type: string
}
/**
* A file this component can hand back. Dropping, and the built-in
* ``, always produce browser `File`s no matter what
* `pickFiles` returns — so `File` is part of the contract even when `T` is a
* host's own handle type. Collapses to plain `File` in the default case.
*/
export type FileUploadValue = T | File
export interface FileUploadProps {
/** Currently selected file(s) — use for simple, synchronous file handling */
value?: FileUploadValue | FileUploadValue[]
/** Callback when files change — used with `value` for simple mode */
onChange: (files: FileUploadValue | FileUploadValue[] | undefined) => void
/**
* Managed file entries for async upload workflows.
* When provided, the file list renders from these entries instead of `value`.
* The dropzone still triggers `onChange` with new `File` objects so the consumer can upload them.
*/
managedFiles?: ManagedFileEntry[]
/** Callback to remove a managed file by its id. Required when `managedFiles` is provided. */
onRemoveManagedFile?: (id: string) => void
/** When provided, an uploaded managed file shows a download action that calls this with its id. */
onDownloadManagedFile?: (id: string) => void
/** Accepted MIME types (e.g., "image/*", ".pdf,.doc") */
accept?: string
/** Maximum file size in bytes. Default: 10MB */
maxSize?: number
/** Maximum number of files (only used when multiple=true). Default: 10 */
maxFiles?: number
/** Allow multiple file selection. Default: false */
multiple?: boolean
/** Primary label text. Default: "Upload Files" */
label?: string
/** Description text below label. Default: "(Click Here or Drag and Drop)" */
description?: string
/** Field label above the component */
fieldLabel?: string
/** Whether the component is disabled */
disabled?: boolean
/** Error message */
error?: string
/** Additional className */
className?: string
/** Custom icon element */
icon?: React.ReactNode
/** Max height for the file list area (e.g., 200 or "200px"). When set, the file list scrolls independently. */
maxListHeight?: number | string
/**
* When true, files dropped anywhere in the window are routed to this component,
* and the browser's default file-open behavior is suppressed. Use on screens/modals
* where this is the only drop target. Default: false.
*/
acceptWindowDrops?: boolean
/**
* Replaces the built-in `` as the source of files. When set,
* clicking the dropzone calls this instead of opening the input, and whatever it
* resolves with runs through the same accept/size/count validation before
* reaching `onChange`. Resolve with an empty array when the user cancels; a
* rejection surfaces in this component's own error slot.
*
* For hosts whose picker a WebView cannot provide — a native shell's OS picker
* returns file handles rather than `File` objects, which is what widens `T`.
* Leave unset on the web, where the built-in input is the right answer.
*/
pickFiles?: () => Promise
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
function matchesAccept(file: FileUploadCandidate, accept: string): boolean {
if (!accept || accept === "*/*") return true
const patterns = accept
.split(",")
.map((p) => p.trim().toLowerCase())
.filter(Boolean)
if (patterns.length === 0) return true
const fileType = (file.type || "").toLowerCase()
const fileName = file.name.toLowerCase()
return patterns.some((pattern) => {
if (pattern.startsWith(".")) return fileName.endsWith(pattern)
if (pattern.endsWith("/*")) return fileType.startsWith(pattern.slice(0, -1))
return fileType === pattern
})
}
function dragHasFiles(e: DragEvent): boolean {
const types = e.dataTransfer?.types
if (!types) return false
for (let i = 0; i < types.length; i++) {
if (types[i] === "Files") return true
}
return false
}
export function FileUpload({
value,
onChange,
managedFiles,
onRemoveManagedFile,
onDownloadManagedFile,
accept = "*/*",
maxSize = 10 * 1024 * 1024,
maxFiles = 10,
multiple = false,
label = "Upload Files",
description = "(Click Here or Drag and Drop)",
fieldLabel,
disabled = false,
error,
className,
icon,
maxListHeight,
acceptWindowDrops = false,
pickFiles,
}: FileUploadProps) {
const [dragActive, setDragActive] = React.useState(false)
const [validationError, setValidationError] = React.useState(null)
const fileInputRef = React.useRef(null)
const isManaged = managedFiles !== undefined
const files = React.useMemo(() => {
if (!value) return []
return Array.isArray(value) ? value : [value]
}, [value])
const currentCount = isManaged ? managedFiles.length : files.length
const validateFiles = (
incoming: FileUploadValue[],
): { accepted: FileUploadValue[]; error: string | null } => {
if (incoming.length === 0) return { accepted: [], error: null }
const candidates = multiple ? incoming : incoming.slice(0, 1)
for (const file of candidates) {
if (!matchesAccept(file, accept)) {
return { accepted: [], error: `File "${file.name}" is not an accepted type` }
}
if (file.size > maxSize) {
return {
accepted: [],
error: `File "${file.name}" exceeds maximum size of ${formatFileSize(maxSize)}`,
}
}
}
if (multiple && currentCount + candidates.length > maxFiles) {
return {
accepted: [],
error: `You can attach at most ${maxFiles} ${maxFiles === 1 ? "file" : "files"}`,
}
}
return { accepted: candidates, error: null }
}
const handleFiles = (incoming: FileList | FileUploadValue[]) => {
setValidationError(null)
const fileArray: FileUploadValue[] = Array.isArray(incoming) ? incoming : Array.from(incoming)
if (fileArray.length === 0) return
const { accepted, error: validationErr } = validateFiles(fileArray)
if (validationErr) {
setValidationError(validationErr)
return
}
if (accepted.length === 0) return
if (isManaged) {
onChange(multiple ? accepted : accepted[0])
} else if (multiple) {
onChange([...files, ...accepted])
} else {
onChange(accepted[0])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleFilesRef = React.useRef(handleFiles)
handleFilesRef.current = handleFiles
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabled) return
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true)
} else if (e.type === "dragleave") {
setDragActive(false)
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
if (disabled) return
if (e.dataTransfer.files?.length) {
handleFiles(e.dataTransfer.files)
}
}
React.useEffect(() => {
if (!acceptWindowDrops || disabled) return
let dragCounter = 0
const onWindowDragEnter = (e: DragEvent) => {
if (!dragHasFiles(e)) return
e.preventDefault()
dragCounter++
if (dragCounter === 1) setDragActive(true)
}
const onWindowDragOver = (e: DragEvent) => {
if (!dragHasFiles(e)) return
e.preventDefault()
}
const onWindowDragLeave = (e: DragEvent) => {
if (!dragHasFiles(e)) return
e.preventDefault()
dragCounter = Math.max(0, dragCounter - 1)
if (dragCounter === 0) setDragActive(false)
}
const onWindowDrop = (e: DragEvent) => {
if (!dragHasFiles(e)) return
e.preventDefault()
dragCounter = 0
setDragActive(false)
if (e.dataTransfer?.files?.length) {
handleFilesRef.current(e.dataTransfer.files)
}
}
const onWindowDragEnd = () => {
dragCounter = 0
setDragActive(false)
}
window.addEventListener("dragenter", onWindowDragEnter)
window.addEventListener("dragover", onWindowDragOver)
window.addEventListener("dragleave", onWindowDragLeave)
window.addEventListener("drop", onWindowDrop)
window.addEventListener("dragend", onWindowDragEnd)
return () => {
window.removeEventListener("dragenter", onWindowDragEnter)
window.removeEventListener("dragover", onWindowDragOver)
window.removeEventListener("dragleave", onWindowDragLeave)
window.removeEventListener("drop", onWindowDrop)
window.removeEventListener("dragend", onWindowDragEnd)
}
}, [acceptWindowDrops, disabled])
const handleFileSelect = (e: React.ChangeEvent) => {
if (e.target.files?.length) {
handleFiles(e.target.files)
}
}
const handleRemoveFile = (index: number) => {
const updated = files.filter((_, i) => i !== index)
if (updated.length === 0) {
onChange(undefined)
} else {
onChange(multiple ? updated : updated[0])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const openDialog = async () => {
if (disabled) return
if (!pickFiles) {
fileInputRef.current?.click()
return
}
// A host picker is a real async operation that can fail (a second picker
// already on screen, an unreadable selection), and this runs from a click
// handler where a rejection would go unhandled.
try {
// Skip the empty (cancelled) case rather than routing it through
// handleFiles, whose first act is to clear a validation message the user
// may still be reading.
const picked = await pickFiles()
if (picked.length > 0) handleFiles(picked)
} catch (err) {
setValidationError(err instanceof Error ? err.message : "Could not open the file picker")
}
}
const displayError = error || validationError || undefined
const hasFiles = isManaged ? managedFiles.length > 0 : files.length > 0
// Single-file mode hides the dropzone once a file is picked (no more to add);
// multiple keeps it visible to keep attaching.
const showDropzone = multiple || !hasFiles
return (
{/* Rendered even when `pickFiles` supersedes it: whether a host has a
native picker is only knowable on the client, and dropping this node
from the tree would make the prerendered HTML and the hydrated render
disagree. It is inert — nothing clicks it once `pickFiles` is set. */}
{/* File list (above) + upload dropzone — single bordered list, file-type icon + name/size + actions per row */}