Upload Composition
Drag and drop, with a real file input underneath. Dropping is an addition, never the only way in — a drop zone that only accepts drops is unusable by keyboard and unreachable on a phone.
Usage
PDFs only, up to 2 MB each, three at most.
<Upload files={files} onFilesChange={setFiles} onReject={(rejections) => notify(rejections)} accept={['.pdf']} maxSize={2 * 1024 * 1024} maxFiles={3}/>One target, one behaviour
The zone is a <label> wrapping the file input, so the whole area opens the picker exactly once
and the keyboard focus ring lands on the control itself. The earlier arrangement — a button beside
a hidden input floating inside the zone — meant the picker could also be opened by clicking what
looked like empty space.
Rejections are reported, not swallowed
Every rejected file comes back with a reason — type, size, or count — through onReject.
A file that disappears without explanation reads as a broken uploader, and the person tries again
with the same file.
Do
Say the limits in the hint before the drop, and surface rejections after it.
Don't
Filter silently on accept alone; a dropped file that never appears looks like data loss.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
| files / onFilesChange | File[] / (files) => void | — | Controlled. The component holds no list of its own. |
| onReject | (rejections) => void | — | Each carries the file and a reason. |
| accept | string[] | — | Extensions or MIME types: ['.pdf', 'image/*']. |
| maxSize | number | — | Bytes, per file. |
| maxFiles | number | — | Counted against what is already held. |
| multiple | boolean | true | false behaves as a limit of one. |