| 1 | import { FileText, Upload, X } from "lucide-react"; |
| 2 | import { type ChangeEvent, type DragEvent, type ReactNode, useEffect, useId, useRef, useState } from "react"; |
| 3 | |
| 4 | import { cn } from "../../lib/cn"; |
| 5 | |
| 6 | // A file field as a dropzone. The real <input type="file"> is inside it, |
| 7 | // visually hidden, so its name, accept, multiple and required work as they |
| 8 | // always do and the form posts the files exactly as a bare input would. A |
| 9 | // click or Enter on the zone opens the picker; files dropped on it are put |
| 10 | // on the input and a change event is sent, as if they had been picked. |
| 11 | |
| 12 | type Chosen = { name: string; size: number }; |
| 13 | |
| 14 | /** Bytes as a person reads them: 812 B, 14.2 KB, 1.3 MB. */ |
| 15 | function fileSize(bytes: number): string { |
| 16 | if (bytes < 1024) return `${bytes} B`; |
| 17 | if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; |
| 18 | return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; |
| 19 | } |
| 20 | |
| 21 | export function FileDrop({ |
| 22 | id, |
| 23 | name, |
| 24 | accept, |
| 25 | multiple = false, |
| 26 | required, |
| 27 | hint, |
| 28 | label, |
| 29 | compact = false, |
| 30 | className, |
| 31 | onChange, |
| 32 | "aria-describedby": describedBy, |
| 33 | }: { |
| 34 | id?: string; |
| 35 | name?: string; |
| 36 | accept?: string; |
| 37 | multiple?: boolean; |
| 38 | required?: boolean; |
| 39 | /** A line under the prompt, such as the types and the size allowed. */ |
| 40 | hint?: ReactNode; |
| 41 | /** The field's name for a screen reader, when nothing else labels it. */ |
| 42 | label?: string; |
| 43 | /** One row, for a tight spot beside other controls. */ |
| 44 | compact?: boolean; |
| 45 | className?: string; |
| 46 | onChange?: (event: ChangeEvent<HTMLInputElement>) => void; |
| 47 | "aria-describedby"?: string; |
| 48 | }) { |
| 49 | const generated = useId(); |
| 50 | const inputId = id ?? generated; |
| 51 | const hintId = `${inputId}-hint`; |
| 52 | const input = useRef<HTMLInputElement>(null); |
| 53 | const [files, setFiles] = useState<Chosen[]>([]); |
| 54 | const [dragging, setDragging] = useState(false); |
| 55 | |
| 56 | // A form reset empties the input; the list follows it. |
| 57 | useEffect(() => { |
| 58 | const form = input.current?.form; |
| 59 | if (!form) return; |
| 60 | const onReset = () => setFiles([]); |
| 61 | form.addEventListener("reset", onReset); |
| 62 | return () => form.removeEventListener("reset", onReset); |
| 63 | }, []); |
| 64 | |
| 65 | const changed = (event: ChangeEvent<HTMLInputElement>) => { |
| 66 | setFiles(Array.from(event.currentTarget.files ?? [], (file) => ({ name: file.name, size: file.size }))); |
| 67 | onChange?.(event); |
| 68 | }; |
| 69 | |
| 70 | const drop = (event: DragEvent<HTMLLabelElement>) => { |
| 71 | event.preventDefault(); |
| 72 | setDragging(false); |
| 73 | const field = input.current; |
| 74 | const dropped = event.dataTransfer.files; |
| 75 | if (!field || dropped.length === 0) return; |
| 76 | if (multiple || dropped.length === 1) { |
| 77 | field.files = dropped; |
| 78 | } else { |
| 79 | const one = new DataTransfer(); |
| 80 | one.items.add(dropped[0]); |
| 81 | field.files = one.files; |
| 82 | } |
| 83 | field.dispatchEvent(new Event("change", { bubbles: true })); |
| 84 | }; |
| 85 | |
| 86 | const clear = () => { |
| 87 | const field = input.current; |
| 88 | if (!field) return; |
| 89 | field.value = ""; |
| 90 | field.dispatchEvent(new Event("change", { bubbles: true })); |
| 91 | field.focus(); |
| 92 | }; |
| 93 | |
| 94 | const describedByIds = [describedBy, hint ? hintId : null].filter(Boolean).join(" ") || undefined; |
| 95 | const plural = multiple; |
| 96 | |
| 97 | return ( |
| 98 | <div className={cn("min-w-0", className)}> |
| 99 | <label |
| 100 | htmlFor={inputId} |
| 101 | onDragEnter={(event) => { |
| 102 | event.preventDefault(); |
| 103 | setDragging(true); |
| 104 | }} |
| 105 | onDragOver={(event) => { |
| 106 | event.preventDefault(); |
| 107 | event.dataTransfer.dropEffect = "copy"; |
| 108 | }} |
| 109 | onDragLeave={(event) => { |
| 110 | if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); |
| 111 | }} |
| 112 | onDrop={drop} |
| 113 | className={cn( |
| 114 | "relative flex cursor-pointer rounded-lg border border-dashed border-line-strong bg-bg text-sm text-muted transition-colors", |
| 115 | "hover:border-accent/50 hover:bg-surface has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent has-[:focus-visible]:ring-offset-1 has-[:focus-visible]:ring-offset-bg", |
| 116 | compact ? "min-h-11 items-center gap-2 px-3 py-1.5 sm:min-h-9" : "flex-col items-center justify-center gap-1.5 px-4 py-6 text-center", |
| 117 | dragging && "border-accent bg-accent/5 hover:border-accent hover:bg-accent/5", |
| 118 | )} |
| 119 | > |
| 120 | <input |
| 121 | ref={input} |
| 122 | id={inputId} |
| 123 | type="file" |
| 124 | name={name} |
| 125 | accept={accept} |
| 126 | multiple={multiple} |
| 127 | required={required} |
| 128 | aria-describedby={describedByIds} |
| 129 | onChange={changed} |
| 130 | className="sr-only" |
| 131 | /> |
| 132 | {label && <span className="sr-only">{label}</span>} |
| 133 | <Upload size={compact ? 14 : 18} aria-hidden className={cn("shrink-0", dragging ? "text-accent" : "text-faint")} /> |
| 134 | <span className="min-w-0"> |
| 135 | <span className="font-medium text-accent">{plural ? "Choose files" : "Choose a file"}</span> or drop {plural ? "them" : "it"} here |
| 136 | </span> |
| 137 | {hint && ( |
| 138 | <span id={hintId} className={cn("text-xs text-faint", compact && "sr-only")}> |
| 139 | {hint} |
| 140 | </span> |
| 141 | )} |
| 142 | </label> |
| 143 | {files.length > 0 && ( |
| 144 | <div className="mt-2 flex items-start gap-2 rounded-lg border border-line bg-surface py-1 pr-1 pl-3"> |
| 145 | <ul aria-label="Chosen files" className="min-w-0 grow"> |
| 146 | {files.map((file, index) => ( |
| 147 | <li key={`${file.name}-${index}`} className="flex min-h-8 items-center gap-2 text-sm"> |
| 148 | <FileText size={14} aria-hidden className="shrink-0 text-faint" /> |
| 149 | <span className="min-w-0 grow truncate">{file.name}</span> |
| 150 | <span className="shrink-0 text-xs text-faint tabular-nums">{fileSize(file.size)}</span> |
| 151 | </li> |
| 152 | ))} |
| 153 | </ul> |
| 154 | <button |
| 155 | type="button" |
| 156 | onClick={clear} |
| 157 | className="inline-flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-xs text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent" |
| 158 | > |
| 159 | <X size={13} aria-hidden /> |
| 160 | Clear |
| 161 | </button> |
| 162 | </div> |
| 163 | )} |
| 164 | </div> |
| 165 | ); |
| 166 | } |