Skip to content
166 linesCodeBlameRaw
1import { FileText, Upload, X } from "lucide-react";
2import { type ChangeEvent, type DragEvent, type ReactNode, useEffect, useId, useRef, useState } from "react";
3
4import { 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
12type Chosen = { name: string; size: number };
13
14/** Bytes as a person reads them: 812 B, 14.2 KB, 1.3 MB. */
15function 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
21export 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}