busy-office-ui
Menu

File upload

Attach a signed goods-receipt PDF, a vendor contract, a photo of a damaged shipment. A real <input type="file"> — the platform's own picker and keyboard access come free; the framework styles it and adds one small opt-in behavior that turns a whole box into the drop target. Not for one required document with nothing to review — a bare <input type="file"> in a form field is enough there; the file list and its remove controls exist for multi-file attachment.

Looking for a "file open / save panel"? That is a desktop idiom, and on the web it is three different screens the framework already has (roadmap 132.4). Open, from the machine: this component. The picker is the operating system's, which is why it cannot be restyled and must not be re-implemented — a page has no access to the file system to draw one. Open, from the system — a contract already stored, a drawing on a material: that is value help, with documents as the rows and the "folder" as one more filter. Same screen, different data. Save: a browser has no save panel to offer. Either the server emits the artefact and the browser downloads it — the emit side is output form — or the document is stored in your system, and then "save as" is a name field and a destination select in a dialog, which is a form, not a panel.

Basic

View code
<input class="bo-file-input" type="file" aria-label="Attach a file">

::file-selector-button is the one part of a file input CSS can restyle — the "No file chosen" text stays native UA rendering, not something to fight. A visible input like this one is its own drop target with no JavaScript at all; that stops being true the moment you hide it inside a dropzone, which is what the behavior below exists for.

Multiple files, restricted types

View code
<input class="bo-file-input" type="file" multiple accept=".pdf,.jpg,.png" aria-label="Attach receipts">

multiple and accept are native attributes — no framework API for them, because there's nothing to add. accept filters the picker dialog, not a drop: drop a .exe on an accept=".pdf" input and the browser takes it (measured in Chrome, and our forwarded drop matches the platform rather than being stricter than it). Treat it as a convenience for the picker and validate input.files yourself.

Camera evidence on a phone — capture

View code
<!-- Phone evidence: the camera as ONE way in, not the only one.
     Two inputs, because capture on a single input removes the choice. -->
<div class="bo-cluster">
  <label class="bo-btn bo-btn--secondary" for="fu-cam">Take a photo</label>
  <input class="bo-file-input bo-visually-hidden" id="fu-cam" type="file"
      accept="image/*" capture="environment">

  <label class="bo-btn bo-btn--secondary" for="fu-pick">Choose a file</label>
  <input class="bo-file-input bo-visually-hidden" id="fu-pick" type="file"
      accept="image/*,.pdf">
</div>

capture="environment" asks a phone for the rear camera (user asks for the selfie one); desktop browsers ignore it entirely, which is why it is safe to ship on a screen both kinds of user reach. The trap is what it takes away: on many phones capture replaces the file picker rather than adding to it, so a single input with capture means a warehouse worker with the photo already in their gallery cannot attach it. Ship the two entry points above — take one, or choose one — and the choice survives. This is a recipe, not framework surface: capture and accept are native attributes and there is nothing to add. The screen that uses it is goods receipt, where a photo of a damaged pallet is the evidence, and where live barcode reads feed the same bo:scan flow.

Dropzone — bigger drag target opt-in

View code
<label class="bo-file-dropzone" data-file-dropzone>
  <input class="bo-file-input bo-visually-hidden" type="file" multiple
      accept=".pdf,.jpg,.png" aria-labelledby="fu-text" aria-describedby="fu-hint">
  <span id="fu-text">Drop vendor documents here, or click to browse</span>
  <span class="bo-file-dropzone__hint" id="fu-hint">PDF, JPG, or PNG — up to 10 MB each</span>
</label>

Try dragging a file over the box. Click anywhere in it to open the picker (a <label> wrapping an input forwards clicks — no JS needed for that part). The drop half does need initFileDropzone(): the input is bo-visually-hidden, and a clipped input is not a drop target, so without the script the browser opens the dropped file instead. While a droppable drag is over the box the border turns solid as well as blue — the style change is what survives forced-colours mode, where every colour here is replaced by a system one.

Refusing a drop the input would refuse

View code
<label class="bo-file-dropzone" data-file-dropzone>
  <input class="bo-file-input bo-visually-hidden" type="file" multiple disabled
      aria-labelledby="fu-dis-text" aria-describedby="fu-dis">
  <span id="fu-dis-text">Attachments are closed for this period</span>
  <span class="bo-file-dropzone__hint" id="fu-dis">Reopen the period to attach documents</span>
</label>

A forwarded drop is never more permissive than the same drop on the plain input. A disabled input refuses it, and so does an input without multiple handed more than one file — the browser refuses that whole drop rather than taking the first, and we match the platform instead of silently discarding files. In each case the box does not highlight, the cursor shows no-drop, and nothing is assigned. Drag anything over the box above to see it stay inert.

Rejected, with the reason

delivery-note.xlsx is not a PDF. Attach the signed PDF.

View code
<div class="bo-form-field">
  <label class="bo-form-field__label" for="fu-inv">Signed delivery note</label>
  <label class="bo-file-dropzone" data-file-dropzone>
    <input class="bo-file-input bo-visually-hidden" id="fu-inv" type="file"
        accept=".pdf" aria-invalid="true" aria-labelledby="fu-inv-text"
        aria-describedby="fu-inv-msg fu-inv-hint">
    <span id="fu-inv-text">Drop the signed note here, or click to browse</span>
    <span class="bo-file-dropzone__hint" id="fu-inv-hint">One PDF</span>
  </label>
  <p class="bo-form-field__message" id="fu-inv-msg">
    delivery-note.xlsx is not a PDF. Attach the signed PDF.
  </p>
</div>

Validation is yours — the framework never inspects a file. What it gives you is where to put the answer: aria-invalid="true" on the input turns the zone's border solid red and reveals the .bo-form-field__message, so the state has a visible non-colour cue and a programmatic one. Point aria-describedby at the message so it is announced, not just seen.

With a selected-file list

  • goods-receipt-88213.pdf 412 KB
  • vendor-signature.jpg 1.1 MB
View code
<label class="bo-file-dropzone" data-file-dropzone>
  <input class="bo-file-input bo-visually-hidden" type="file" multiple
      accept=".pdf,.jpg,.png" aria-labelledby="fu-gr-text"
      aria-describedby="fu-gr-hint">
  <span id="fu-gr-text">Drop goods-receipt documents here, or click to browse</span>
  <span class="bo-file-dropzone__hint" id="fu-gr-hint">PDF, JPG, or PNG — up to 10 MB each</span>
</label>
<ul class="bo-file-list" aria-live="polite">
  <li class="bo-file-list__item">
    <span class="bo-file-list__name">goods-receipt-88213.pdf</span>
    <span class="bo-file-list__size">412 KB</span>
    <button class="bo-btn bo-btn--sm bo-btn--danger-ghost" type="button" aria-label="Remove goods-receipt-88213.pdf">✕</button>
  </li>
  <li class="bo-file-list__item">
    <span class="bo-file-list__name">vendor-signature.jpg</span>
    <span class="bo-file-list__size">1.1 MB</span>
    <button class="bo-btn bo-btn--sm bo-btn--danger-ghost" type="button" aria-label="Remove vendor-signature.jpg">✕</button>
  </li>
</ul>

.bo-file-list styles rows YOUR code renders after reading input.files on change — same "framework does visuals, you do the data" split as everywhere else.

Upload progress, failure and retry — all consumer state

  • goods-receipt-88213.pdf 412 KB Attached
  • pallet-damage.jpg 2.4 MB 62%
  • vendor-contract.pdf 8.7 MB Upload failed
  • scan-0042.tiff 14.2 MB Rejected — over 10 MB
View code
<!-- Every row below is YOUR state, rendered by your code. No
     class here is upload-specific: a row is a .bo-file-list__item, the
     outcome is a .bo-badge, the bar is a <progress class="bo-progress">,
     and retry/cancel are ordinary buttons. -->
<ul class="bo-file-list" aria-live="polite">
  <li class="bo-file-list__item">
    <span class="bo-file-list__name">goods-receipt-88213.pdf</span>
    <span class="bo-file-list__size">412 KB</span>
    <span class="bo-badge bo-badge--success">Attached</span>
    <button class="bo-btn bo-btn--sm bo-btn--danger-ghost" type="button" aria-label="Remove goods-receipt-88213.pdf">✕</button>
  </li>
  <li class="bo-file-list__item">
    <span class="bo-file-list__name">pallet-damage.jpg</span>
    <span class="bo-file-list__size">2.4 MB</span>
    <progress class="bo-progress" value="62" max="100" aria-label="Uploading pallet-damage.jpg">62%</progress>
    <button class="bo-btn bo-btn--sm bo-btn--ghost" type="button" aria-label="Cancel upload of pallet-damage.jpg">Cancel</button>
  </li>
  <li class="bo-file-list__item">
    <span class="bo-file-list__name">vendor-contract.pdf</span>
    <span class="bo-file-list__size">8.7 MB</span>
    <span class="bo-badge bo-badge--danger">Upload failed</span>
    <button class="bo-btn bo-btn--sm bo-btn--secondary" type="button">Retry vendor-contract.pdf</button>
  </li>
  <li class="bo-file-list__item">
    <span class="bo-file-list__name">scan-0042.tiff</span>
    <span class="bo-file-list__size">14.2 MB</span>
    <span class="bo-badge bo-badge--danger">Rejected — over 10 MB</span>
    <button class="bo-btn bo-btn--sm bo-btn--danger-ghost" type="button" aria-label="Remove scan-0042.tiff">✕</button>
  </li>
</ul>

Selecting a file is not uploading one, so nothing here is framework behaviour: there is no XHR, no retry policy and no progress simulation in this package. Every row above is markup your code renders from state your code owns, composed from badge, progress and button — no upload-specific class exists, because none would be reusable anywhere else. Give the <progress> an aria-label naming its file, and keep aria-live="polite" on the list so a status change is announced once.

Markup

<!-- The input is the real control. The <label> makes the whole box
     clickable — that part is native, labels forward clicks with no JS.
     The DROP half is not native here: bo-visually-hidden clips the input
     to 1px, and a clipped input is not a drop target, so without the
     script a dropped file lands on the page and the browser opens it.
     Ship initFileDropzone() with the zone, or show the input.

     Name the input FROM its own visible line with aria-labelledby, and put
     the subject in that line ("Drop vendor documents here"): the accessible
     name is then the visible text by construction (WCAG 2.5.3 Label in
     Name), a voice-control user can say what they see, and a screen reader
     hears WHAT is being attached. A separate aria-label wins over the
     wrapping label and drifts from the words on screen. aria-describedby
     points at the hint so the constraint is announced too, not only seen. -->
<label class="bo-file-dropzone" data-file-dropzone>
  <input class="bo-file-input bo-visually-hidden" type="file" multiple
      accept=".pdf,.jpg,.png"
      aria-labelledby="doc-text" aria-describedby="doc-hint">
  <span id="doc-text">Drop vendor documents here, or click to browse</span>
  <span class="bo-file-dropzone__hint" id="doc-hint">PDF, JPG, or PNG — up to 10 MB each</span>
</label>
<script type="module">
  import { initFileDropzone } from "@busy-office/ui/js";
  initFileDropzone();
</script>
<!-- File list + progress are YOUR code: read input.files on 'change',
     render .bo-file-list rows, upload however you upload. Put
     aria-live="polite" on the <ul> — a screen-reader user who just
     dropped or removed a file otherwise gets no confirmation it
     registered (same live-region need as the data-table selection
     count).

     Two things a drop shares with a pick, and one it does not:
       * input.files is REPLACED, never appended — keep your own array
         if the user should be able to add in several goes.
       * picking the SAME file twice fires nothing the second time
         unless you clear input.value = '' after reading the files.
       * accept filters the PICKER dialog, not a drop — neither ours nor
         the browser's own. Validate input.files yourself. -->

Quick reference generated

ClassRole
.bo-file-dropzone block (root)
.bo-file-dropzone__hint part: hint
.bo-file-input block (root)
.bo-file-list block (root)
.bo-file-list__item part: item
.bo-file-list__name part: name
.bo-file-list__size part: size

State attributes: data-dragoveraria-invalid

API generated from CSS

Blocks bo-file-dropzonebo-file-inputbo-file-list
Parts bo-file-dropzone__hintbo-file-list__itembo-file-list__namebo-file-list__size
data-* hooks data-dragover="true"
ARIA styled aria-invalid
JS required Optional but effectively required for a dropzone: initFileDropzone() — highlights a [data-file-dropzone] label while a droppable drag is over it and forwards a drop anywhere in it into the input's FileList, dispatching input then change. The documented zone hides its input, and a hidden input is not a drop target, so without this the box is clickable but not droppable.
  • Name the input from its own visible line with aria-labelledby, and put the subject in that line ("Drop vendor documents here"): the name is then the visible text by construction (WCAG 2.5.3). A separate aria-label wins over the wrapping label and drifts from what is on screen. Point aria-describedby at the hint so the constraint is announced too.
  • A forwarded drop is never more permissive than the same drop on the plain input: a disabled input refuses it, and so does an input without multiple handed more than one file (the browser refuses that drop outright — we match it rather than taking the first).
  • accept filters the picker dialog, not a drop — neither ours nor the browser's own. File type and size limits are your validation after reading input.files; nothing this component enforces.
  • A drop REPLACES input.files rather than appending, and re-picking the same file fires nothing unless you clear input.value = '' after reading it.
  • No file list, upload, or progress bar shipped — read input.files on change and render .bo-file-list rows yourself; compose Progress and Badge for in-flight and failed rows.
  • Put aria-live="polite" on the .bo-file-list — a screen-reader user who just dropped or removed a file gets no confirmation it registered otherwise (same live-region need as the data-table selection count).
Contrast — 29 AA pairs this component uses (generated, CI-gated)
TextonLightDark
text-primary bg-surface 17.74 16.15
text-primary bg-canvas 16.98 18.08
text-secondary bg-surface 10.31 11.46
text-secondary bg-muted 9.37 9.75
text-muted bg-surface 7.56 6.65
text-muted bg-muted 6.87 5.66
text-inverse accent-solid 5.47 5.47
bg-surface text-primary 17.74 16.15
danger-text bg-surface 6.47 8.89
success-text bg-surface 5.02 12.02
accent bg-surface 5.47 9.07
text-primary bg-hover 16.12 13.75
text-primary bg-selected 17.01 11.86
text-secondary bg-selected 9.88 8.41
accent-text bg-selected 7.27 8.38
text-primary bg-surface-raised 17.74 14.51
text-secondary bg-canvas 9.86 12.82
border-control bg-surface 4.83 6.65
accent bg-selected 5.25 6.66
--bo-state-error-color bg-surface 4.83 6.1
border-control bg-muted 4.39 5.66
focus-ring bg-canvas 3.58 5.05
accent bg-muted 4.97 7.72
warning-strong bg-muted 4.56 6.69
warning-strong bg-surface 5.02 7.86
danger bg-muted 4.39 5.19
text-primary bg-muted 16.12 13.75
success-text bg-muted 4.56 10.23
danger-text bg-muted 5.88 7.57

Maturity generated

Introduced
v0.3.0
CSS floor
Chrome/Edge 119 · Firefox 121 · Safari 16.5
Alignment scored
2026-08-21
Assistive-tech evidence
None recorded

Pin this component's first release with npm i @busy-office/ui@0.3.0. The floor is derived from this component's stylesheet alone — set by :user-invalid — so it sits at or below the whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5. No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour. What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see accessibility.

Design-system alignment generated

100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.

Rule Score Evidence
Typography 3 / 3 no raw font-size
Colour 3 / 3 zero raw hex
Spacing 3 / 3 zero raw dimension literals — all tokens
Interaction 3 / 3 a real <input type=file>: platform picker and keyboard access come free; the drop half needs initFileDropzone(), because the documented input is visually hidden and a hidden input is not a drop target (cite corrected 373.1, 2026-09-19; blind re-score 2026-09-23 per 373.10 HELD AT 3 — the page draws the platform/behaviour line in six places and names what omitting the JS costs, and both halves are executable in check:claims: the parity harness's native visible-input column for what the platform gives, and the file's last claim for what it does not)
Content 3 / 3 the opener refuses its own use for one required document with nothing to review — a bare <input type="file"> in a form field — and scopes the file list to multi-file (94.8)
Fit 3 / 3 ::file-selector-button is the one restylable part and the docs scope themselves to it
↑↓ move Enter open Esc close