import { useRef, useState, type ReactNode } from "react"; import clsx from "clsx"; import { Pressable } from "./Pressable"; // A place to drop files onto, which is also the button that opens the native file dialog: the // dashed surface an upload starts from. It owns what each such surface needed and the first one // got right only after a flicker: dragenter/dragleave fire as the cursor crosses the zone's OWN // children (an icon, a line of text), so a plain boolean flickers — enter and leave are counted // and the highlight clears at depth 0. The caller draws the inside, told whether a drag is over it. export function DropZone({ onFiles, multiple = false, accept, disabled = false, invalid = false, describedBy, className, children, }: { /** The files dropped or picked, never empty. One unless `multiple`. */ onFiles: (files: [File, ...File[]]) => void; multiple?: boolean; /** The picker's filter (``); a drop is not filtered by it. */ accept?: string; disabled?: boolean; /** What the zone holds is refused: the border turns danger and `describedBy` says why. */ invalid?: boolean; describedBy?: string; /** Lays the zone out (its padding); the border and the drag paint are the zone's. */ className?: string; children: (dragOver: boolean) => ReactNode; }) { const [dragOver, setDragOver] = useState(false); const depth = useRef(0); const inputRef = useRef(null); const take = (list: FileList | null | undefined) => { const [first, ...rest] = [...(list ?? [])]; if (first) onFiles(multiple ? [first, ...rest] : [first]); }; return ( <> inputRef.current?.click()} onDragEnter={(e) => { e.preventDefault(); depth.current += 1; setDragOver(true); }} onDragOver={(e) => e.preventDefault()} onDragLeave={() => { depth.current = Math.max(0, depth.current - 1); if (depth.current === 0) setDragOver(false); }} onDrop={(e) => { e.preventDefault(); depth.current = 0; setDragOver(false); if (!disabled) take(e.dataTransfer.files); }} disabled={disabled} // The refusal is tied to the control it invalidates, not left to a one-shot announcement: // the zone reads as invalid and points at the sentence, so a reader who tabs back to it // hears WHY. (Measured, because `aria-invalid` is not in `button`'s ARIA-supported list: // Chromium's accessibility node for a native button does carry `invalid` from it.) The // signal that is NOT colour is the caller's sentence — the border only reinforces it. aria-invalid={invalid} aria-describedby={describedBy} // `dragOver` outranks `invalid`: a drag is transient and the refusal repaints the instant // the drop lands, whereas a zone that stays red under the pointer tells the user their drop // target is dead — in the one state where they are trying to replace the file. className={clsx( "w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed transition disabled:opacity-60", dragOver ? "border-accent bg-accent/5" : invalid ? "border-danger" : "border-border hover:border-fg/40", className, )} > {children(dragOver)} { take(e.target.files); e.target.value = ""; // let re-picking the same file fire onChange again }} /> ); }