Component contract
Renders a file-picker / drop-zone region. The parent supplies an upload function; the component manages per-file state and progress.
onUpload: (file) => Promise<UploadResult>— required. Parent owns the actual transport. Component calls one upload per file.accept?: string— MIME types or extensions, e.g."image/png,image/jpeg"or".csv,.xlsx".maxSizeBytes?: number— files larger than this are rejected before upload starts.maxFiles?: number— defaults to 1; cap on simultaneous queue.multiple?: boolean— derived from maxFiles > 1.onComplete?: (results) => void— fires once when ALL queued uploads settle (success or fail). Partial-success semantics: results array always reflects every queued file.onAbort?: (file) => void— called when user aborts an in-flight file.label?: ReactNode— drop-zone primary text, e.g. "Drop CSV here or click to choose".helperText?: ReactNode— secondary hint, e.g. "Up to 10 MB, .csv or .xlsx".idempotencyKey?: string— when provided, the parent's onUpload should send this header to dedupe retries.
Interaction surface
-
Idle: drop zone with label + helper text + click target.
Click anywhere in the drop zone opens the OS file picker. Keyboard: Enter or Space when focused. Drag-over highlights the zone.
-
File selected: validate before upload starts.
Check accept (MIME + extension), maxSizeBytes, maxFiles count. Rejected files surface inline as a list of "rejected: filename — reason" entries. Accepted files queue and start uploading immediately.
-
Per-file progress row.
Each file in the queue renders: filename, size, status (queued/uploading/done/failed/aborted), progress bar, and an action button (cancel during upload; remove after settled).
-
Abort mid-upload.
Cancel button on an uploading file aborts the request (component owns the AbortController), calls onAbort, removes the file from queue. Other in-flight files continue.
-
Failed upload offers retry.
Failed file row shows a retry button. Retry calls onUpload again with the same file (and same idempotencyKey if provided). Retry count is tracked per file; after 3 retries, the retry button is replaced with "Remove and try a different file".
-
All settled: onComplete fires once.
After every queued file is in a terminal state (done, failed, or aborted), onComplete fires with the full results array. Component does NOT auto-remove succeeded rows — the parent decides when to clear.
Failure modes
Oversized file uploaded anyway
Trigger: maxSizeBytes is set but a 50MB file under a 10MB cap is selected and the upload starts (then fails server-side).
Size validation runs synchronously on selection, BEFORE onUpload is called. Harness: maxSizeBytes=1000, drop a 5000-byte file, assert onUpload NOT called, rejection row visible with "exceeds 1 KB" message.
MIME mismatch slips through
Trigger: accept="image/png" but a .pdf is dropped and uploaded.
Component validates the file's type property AND extension against accept. Browser's native file picker honors accept for click-to-select but drop bypasses it; the component must re-validate post-drop. Harness: accept="image/png", drop a .pdf, assert onUpload NOT called, rejection visible.
maxFiles silently dropped extras
Trigger: maxFiles=3 but 7 files are dropped; 4 silently disappear.
Excess files surface as rejection rows ("max 3 files; 4 not added"). User sees what was dropped and what was not. Harness: maxFiles=3, drop 7, assert 3 in queue + 4 rejection rows.
Abort doesn't actually abort
Trigger: cancel button removes the file from the visible queue but the underlying fetch continues to completion.
Cancel calls AbortController.abort() on the in-flight request. Network panel shows the request canceled, not completed. Harness: start an upload that resolves after 5s, click cancel after 1s, assert the fetch was aborted (no request body delivered server-side, or delivered then ignored).
Retry creates a duplicate upload server-side
Trigger: failed upload retries without idempotencyKey, server now has 2 partial copies.
When idempotencyKey is provided, the parent's onUpload includes it as a header (Idempotency-Key: <key>-<file-hash>). Server dedupes by that key. Harness: simulate retry, both requests carry the same Idempotency-Key header.
onComplete fires multiple times
Trigger: onComplete is called per-file instead of once-after-all-settled.
onComplete fires exactly once per "session" (from first file added to all settled). If the user adds more files after onComplete fires, that's a new session. Harness: queue 3 files, wait for all to settle, assert onComplete called exactly once with 3 results.
Drag-over highlights propagate to nested droppable zones
Trigger: an outer drop zone and an inner drop zone both highlight when dragging over the inner.
Component stops drag-event propagation. Inner zone owns the highlight; outer zone clears its own highlight when dragLeave fires. Harness: nested drop zones, hover inner, assert outer NOT highlighted.
Network drop mid-upload leaves "uploading" stuck forever
Trigger: connection drops, the fetch never resolves or rejects, the row spins indefinitely.
Component sets a watchdog timeout (default 60s of no progress). On timeout, marks file as failed with "Upload timed out" reason and offers retry. Harness: stub network to never respond, assert after 60s the row transitions to failed state.
Empty drop registers as a file
Trigger: dropping a folder or empty file (0 bytes) is queued as a 0-byte upload.
0-byte files and folders are rejected with "empty or not a file" message. Folders detected via the absence of type AND a size of 0 (heuristic; the FileSystemAccess API gives more signal where supported). Harness: drop an empty file, assert rejection row.
Accessibility
- Drop zone is a
<button>witharia-describedbypointing at helper text. - Hidden file input has
aria-hidden="true"; activation routed through the button. - Per-file rows have
role="listitem"inside arole="list"container. - Progress bars use the underlying ui-progress-bar with
aria-valuenowupdating during upload. - Status changes announced via
role="status"region for queue-level messages (e.g., "All files uploaded"). - Color contrast: drop-zone label ≥ 4.5:1, helper text ≥ 4.5:1, status text ≥ 4.5:1.
- axe-clean at severity ≥ serious.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-file-uploader | Outer wrapper | Component identity |
ui-file-uploader-drop-zone | Click/drop target | Primary interaction |
ui-file-uploader-input | Hidden file input | For programmatic file injection in tests |
ui-file-uploader-queue | List of queued files | Visible only when files are queued |
ui-file-uploader-row | Per-file row | data-file-name + data-status attrs |
ui-file-uploader-progress | Progress bar inside row | Visible during uploading state |
ui-file-uploader-cancel | Cancel button | Visible only on uploading rows |
ui-file-uploader-retry | Retry button | Visible only on failed rows |
ui-file-uploader-remove | Remove button | Visible after settle (done/failed/aborted) |
ui-file-uploader-rejection | Rejection row | For files rejected before upload |
ui-file-uploader-rejection-reason | Reason text | Always visible inside rejection row |
Agent test plan
Standalone probes against /admin-test/ui-file-uploader-fixture with variants: idle, single file, multiple files, oversize, mime-mismatch, max-files-exceeded, abort-mid-flight, failure-with-retry, network-drop-watchdog, empty-file, folder-drop, idempotency-key-set.
Probe list
- click-opens-picker: click drop zone, file input click triggered
- drop-validates-mime: accept=image/png, drop a pdf, onUpload not called, rejection row visible
- drop-validates-size: maxSizeBytes=1000, drop 5000-byte file, onUpload not called, rejection visible
- max-files-rejects-extras: maxFiles=3, drop 7, queue=3 + 4 rejection rows
- progress-bar-updates: stub onUpload to emit progress, assert ui-file-uploader-progress aria-valuenow advances
- abort-cancels-fetch: stub a 5s upload, cancel at 1s, assert AbortController.abort called, onAbort fired
- abort-other-files-continue: 3 in-flight, cancel one, other 2 still uploading
- retry-with-idempotency-key: idempotencyKey="abc", failed upload, retry, second request includes Idempotency-Key header
- retry-after-3-attempts-replaced: 3 failures, retry button replaced with "Remove and try a different file"
- on-complete-once-per-session: queue 3, wait for all settled, onComplete called exactly once with results.length=3
- empty-file-rejected: drop 0-byte file, rejection visible
- folder-rejected: simulate folder drop, rejection visible
- watchdog-timeout: stub fetch to hang, after 60s row state=failed reason="timed out"
- nested-drop-zone-scoping: nested drop zones, drag over inner, outer not highlighted
- color-contrast-label: ≥ 4.5:1
- axe-clean-serious: no serious violations