FileUpload
A drop zone that takes files by drag and drop or the file picker, turns away the wrong ones with a reason, and lists the rest.
- trace-webkit.zip182 kB
- ci-run-1287.log7.1 kB
- Click the zone or drop files on it; a file already in the list is not added twice.
- Drop a PDF, or a file over 10 MB: it stays out, and the reason shows under the zone.
- Remove a file and focus moves to the next one, or the one above when it was last, or back to the zone when none are left.
Field marks
- Import
import { FileUpload } from "cennge-ui";- Size
- 5.6 KB gzip for this import, its dependencies aside
- Pattern
- Native
<button>that opens a hidden<input type="file"> - Keyboard
- Enter Space
- State
- Controlled with
valueandonValueChange(an array ofFile), or uncontrolled - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Checks
- Type, size and count, each refused file with its reason
- Forms
- With
name, a plain submit sends the listed files - Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: file-upload
When to use#
Use a FileUpload where people attach files to something: screenshots to an issue, a log from a failed run, an image for a repository. It checks each file against accept, maxSize, minSize and maxFiles as it arrives, keeps the ones that pass and says why the others did not.
It uploads nothing by itself. You get the File objects from onValueChange and send them where they go, or give the field a name and let the form send them.
Similar#
- A plain
<input type="file">opens the same picker, but shows no list of what was picked and gives no reasons. With aname, FileUpload keeps one inside that holds exactly the files in its list, so FormData reads what the reader sees.
Type, size and count#
accept takes MIME types, wildcards and extensions, like the native attribute, as a string or an array. A refused file stays out of the list with its reason under the zone. onReject receives each one with its codes: file-invalid-type, file-too-large, file-too-small or too-many-files.
import { useState } from "react";
import { FileUpload } from "cennge-ui";
export default function Screenshots() {
const [files, setFiles] = useState<File[]>([]);
return (
<FileUpload
label="Screenshots"
description="PNG or JPG, up to 2 MB, at most 3"
accept={["image/png", "image/jpeg"]}
maxSize={2 * 1024 * 1024}
maxFiles={3}
value={files}
onValueChange={setFiles}
className="w-full max-w-96"
/>
);
}Images get a preview. Its object URL is released when the file leaves the list.
One file, compact#
variant="button" is the small form for tight places; it still takes drops. With multiple={false} a new file replaces the one in the list.
import { useState } from "react";
import { FileUpload } from "cennge-ui";
export default function ReleaseAsset() {
const [asset, setAsset] = useState<File[]>([]);
return (
<FileUpload
label="Release asset"
description="The .tgz that npm pack wrote"
variant="button"
multiple={false}
accept=".tgz"
value={asset}
onValueChange={setAsset}
className="w-full max-w-96"
/>
);
}Sent with the form#
With a name, the files are set on a real file input inside the field, so new FormData(form) and a plain form submit carry them. Remove one from the list and it is gone from the form too. Inside a Form, an error your server returns for logs shows under the field.
import { useState } from "react";
import { Button, FileUpload } from "cennge-ui";
export default function BugReport() {
const [sent, setSent] = useState<string[] | null>(null);
return (
<form
className="flex w-full max-w-96 flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
// Send it as it is: fetch(url, { method: "POST", body: data })
const logs = data.getAll("logs") as File[];
setSent(logs.filter((file) => file.name).map((file) => file.name));
}}
>
<FileUpload
label="Logs"
name="logs"
accept={[".log", ".txt"]}
description="From the failed CI run"
/>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" color="primary">
Send report
</Button>
{sent ? <p className="text-sm text-muted">Sent: {sent.length ? sent.join(", ") : "no files"}</p> : null}
</div>
</form>
);
}In another language#
messages replaces the field's own words: the prompt, the button, the names of the list and of the remove buttons, and the reasons. File sizes are formatted for the locale of CenngeProvider.
import { CenngeProvider, FileUpload, type FileErrorCode } from "cennge-ui";
const reasons: Record<FileErrorCode, string> = {
"file-invalid-type": "nur PDF",
"file-too-large": "größer als 5 MB",
"file-too-small": "zu klein",
"too-many-files": "zu viele Dateien",
};
export default function GermanAttachments() {
return (
// The provider's locale formats the file sizes: 1,2 MB.
<CenngeProvider locale="de-DE">
<FileUpload
label="Anhänge"
description="PDF, bis 5 MB"
accept=".pdf"
maxSize={5 * 1024 * 1024}
messages={{
prompt: "Dateien hierher ziehen oder",
browse: "auswählen",
dropping: "Zum Hinzufügen loslassen",
fileList: "Ausgewählte Dateien",
remove: (name) => `${name} entfernen`,
error: (code, file) => `${file.name}: ${reasons[code]}`,
}}
className="w-full max-w-96"
/>
</CenngeProvider>
);
}Accessibility#
The zone is a <button>, so Enter and Space open the picker; the label names it, and the prompt and the hint describe it. Reasons for refused files appear in a role="alert" region that is always in the page, so screen readers announce each new one. Every file has a remove button named after it ("Remove trace-webkit.zip"). After a removal, focus moves to the remove button of the file now in that place, or of the one above when the last file went, and to the zone when the list is empty.