Bonusway design system

Forms

FileUpload

A dropzone for receipts and other attachments: drag-and-drop or click-to-browse, per-file type/size validation at add time, image thumbnails, and a remove button. Built for the missing-bonus claim flow, but generic. The file also exports the `FileUploadItem` type and a pure `validateFile(file, { accept, maxSizeBytes })` helper — reuse the latter wherever the same file needs re-checking (a server action, another form).

import FileUpload from '@/components/ui/FileUpload';

Source: src/components/ui/FileUpload.tsx

Examples

Add, validate, remove

Fully controlled — like Checkbox, it carries its own label rather than going inside a Field. `files` and its statuses live in the page; FileUpload only hands back the File objects that passed its own accept/maxSizeBytes/maxFiles checks via onAdd. A rejected file never enters the list — its message shows once, inline, under the dropzone.

interactive
Receipt or order confirmation

PDF, JPG, PNG or GIF, up to 4 MB.

    Try dropping a receipt, or a file of the wrong type/size to see the rejection.

    It reads no translations of its own

    `label`, `help`, `error`, `statusLabels` and `messages` are all props — the missing-bonus-claim plan’s "copy via props" call, so this component ships with zero new message keys. The only hardcoded English is the same kind of structural fallback Modal’s "Close" and Toast’s "Dismiss" already are: default status words and validation wording, there so the component and this showcase work without wiring translations, which the real claim page replaces with its own copy.

    validateFile(file, { accept, maxSizeBytes, messages }) takes the same override — pass the page’s translated wording so a client-side rejection and the server action’s 400/413 read identically.

    Props

    <FileUpload> props

    PropTypeDefaultDescription
    filesrequiredFileUploadItem[]

    The controlled list — see FileUploadItem.

    onAddrequired(files: File[]) => void

    Files that passed this component's own add-time checks (type, size, and the maxFiles count). The page assigns ids and appends them to `files`.

    onRemoverequired(id: string) => void
    acceptrequiredstring[]

    Accepted MIME types. Also becomes the native input's `accept` attribute.

    maxSizeBytesrequirednumber
    maxFilesnumber
    disabledbooleanfalse
    labelrequiredstring

    The dropzone's visible text and its accessible name. FileUpload reads no i18n of its own — the missing-bonus-claim plan's "copy via props" decision — so this (and `help`, `error`, `statusLabels`, `messages` below) is how the page supplies translated copy for the ×11 locales. Like Checkbox, FileUpload carries its own label rather than going inside a Field: the dropzone IS the labelled control.

    helpstring

    Helper text under the dropzone — typically the accepted types and size, e.g. "PDF, JPG, PNG or GIF, up to 4 MB." Hidden while `error` is set or a pick was rejected: the problem replaces the guidance rather than stacking beneath it, since the two otherwise repeat each other almost verbatim.

    errorstring

    A form-level error, e.g. "At least one receipt is required." Rendered below the dropzone and linked via aria-describedby, the same contract Input's `error` follows.

    statusLabelsPartial<FileUploadStatusLabels>

    Per-status text next to each row. The defaults are English fallbacks — the same relationship Modal's hardcoded "Close" and Toast's "Dismiss" have to the rest of this app's chrome — so the component and its showcase entry work without translations wired up. The real page passes localized copy here, same as `label`/`help`.

    messagesPartial<FileUploadMessages>

    Override the add-time rejection wording (type/size/count). Same English-fallback relationship as `statusLabels`.

    titlestring

    A field-level name rendered ABOVE the dropzone, styled like `Field`'s label — "what this field is", as distinct from `label`, which is the instruction inside the box ("Choose a file"). Optional: a dropzone whose own text already names the thing needs no title. This exists because FileUpload carries its own label instead of going inside a `Field` (the dropzone is a `<button>`, and a `<label htmlFor>` does not associate with one), so without it a file field is the only control in the system that cannot say both. When set, it joins the dropzone's accessible name ahead of `label`, so a screen reader announces "<title> <label>" rather than dropping one of them.

    requiredbooleanfalse
    idstring
    classNamestring''

    Accessibility

    • The dropzone is a real <button>: Enter/Space open the picker, and it is reachable by Tab. The native file input behind it is hidden and untabbable.
    • aria-describedby links `error`, `help`, and any add-time rejection message to the dropzone (aria-invalid is not part of this, unlike Input — ARIA doesn’t define it for role="button").
    • Each row’s remove control is an IconButton labelled "Remove <filename>", so multiple rows stay distinguishable to a screen reader.

    Usage

    Do

    • Derive `accept`/`maxSizeBytes` from the server’s own requirements when you have them (the claim flow’s attachment response), not a client-only guess.
    • Reuse `validateFile` in the server action too, so the client rejection and the server’s 400/413 agree.

    Don’t

    • Don’t wrap it in a Field — like Checkbox, it labels itself.
    • Don’t invent a percentage progress bar; a fetch-based server action can’t stream one. The four statuses are the honest version of upload progress.
    • Don’t let a validation failure silently vanish — it’s rendered inline, under the dropzone, the same render pass it happened in.

    Related:FieldInput

    Denna webbplats använder cookies.

    Vi använder cookies för att förbättra din upplevelse på vår webbplats och anpassa innehållet vi tillhandahåller dig. Viktiga cookies är nödvändiga för att vår tjänst ska fungera korrekt. Om du går med på att vi samlar in information om dig genom användningen av nödvändiga och marknadsförande cookies, välj "Acceptera alla". Du kan alltid ändra dina preferenser genom att besöka Cookie -inställningar.

    Cookie -inställningar