File Upload
Description
Section titled “Description”.bp-file-upload is a custom element (<bp-file-upload>) that wraps a native <label> + <input type="file"> dropzone and a queue of upload items. CSS handles drag feedback via [data-dragover] and per-item state via [data-state]. JS handles drag events, file selection, and an artificial “trickle” progress animation so a spinner never has to hide how far along an upload really is.
Installation
Section titled “Installation”npm
@import '@be-partner-labs/ds/components/file-upload';CDN
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@be-partner-labs/ds/dist/components/file-upload.min.css"/>For the full bundle:
https://cdn.jsdelivr.net/npm/@be-partner-labs/ds/dist/bpl-ds.min.css
Dropzone
<bp-file-upload class="bp-file-upload"><label class="bp-file-upload__dropzone" for="fu-demo"> <input class="bp-file-upload__input" type="file" id="fu-demo" multiple /> <span class="bp-file-upload__icon" aria-hidden="true">⬆</span> <span class="bp-file-upload__copy">Drag files here or <span class="bp-file-upload__browse">browse</span></span></label><ul class="bp-file-upload__list"></ul></bp-file-upload>Queue states
- quarterly-report.pdf 2.4 MB 62%
- brand-assets.zip 18.1 MB 100%
- video-master.mov 340 MB Upload failed
<ul class="bp-file-upload__list"><li class="bp-file-upload__item" data-state="uploading"> <span class="bp-file-upload__name">quarterly-report.pdf</span> <span class="bp-file-upload__size">2.4 MB</span> <progress class="bp-file-upload__progress" value="62" max="100"></progress> <span class="bp-file-upload__percent">62%</span> <button class="bp-file-upload__retry" type="button" hidden>Retry</button></li><li class="bp-file-upload__item" data-state="success"> <span class="bp-file-upload__name">brand-assets.zip</span> <span class="bp-file-upload__size">18.1 MB</span> <progress class="bp-file-upload__progress" value="100" max="100"></progress> <span class="bp-file-upload__percent">100%</span> <button class="bp-file-upload__retry" type="button" hidden>Retry</button></li><li class="bp-file-upload__item" data-state="error"> <span class="bp-file-upload__name">video-master.mov</span> <span class="bp-file-upload__size">340 MB</span> <progress class="bp-file-upload__progress" value="90" max="100"></progress> <span class="bp-file-upload__percent">Upload failed</span> <button class="bp-file-upload__retry" type="button">Retry</button></li></ul>JavaScript
Section titled “JavaScript”The <bp-file-upload> custom element is required for drag state, file selection, and progress. Load it once:
<script type="module" src="https://unpkg.com/@be-partner-labs/ds/js/bp-file-upload.esm.min.js"></script>Or via npm:
import '@be-partner-labs/ds/js/bp-file-upload'What JS handles:
- Sets
[data-dragover]on drag enter/over, clears it on drag leave/drop — three signals (border, glow, copy) before the drop - Builds a queue item (name, formatted size,
<progress>, retry button) for each selected or dropped file - Runs a decelerating “trickle” simulation up to 90% while the real upload is in flight, so users see honest-feeling progress instead of a spinner
- Exposes
complete(item)to snap to 100% and mark success, andfail(item, message)to mark an error and reveal inline retry — the file stays loaded, so retry fires in one tap instead of forcing re-selection (see Retry vs. resume for what “resume” does and doesn’t cover) - Dispatches
bp-file-upload:add,bp-file-upload:retry,bp-file-upload:success,bp-file-upload:errorcustom events so the host app can wire real upload requests
const uploader = document.querySelector('bp-file-upload')uploader.addEventListener('bp-file-upload:add', async ({ detail: { file, item } }) => { try { await uploadFile(file) // your own upload call uploader.complete(item) } catch { uploader.fail(item, 'Upload failed') }})What CSS handles: All visual appearance via [data-dragover] on the wrapper and [data-state] on each item.
Retry vs. resume
Section titled “Retry vs. resume”The component’s built-in retry is a flow-level resume, not a byte-level one:
- The
Fileobject is kept alive in the closure passed throughbp-file-upload:add/bp-file-upload:retry— the user never re-picks the file. trickle(item)readsprogress.valuebefore restarting, so the bar continues from wherever it stopped (e.g. 90%) instead of visually resetting to 0.
That’s as far as a CSS/markup design system can go on its own — it has no opinion on your transport. Whether the retry actually skips bytes already received depends entirely on your upload protocol on the backend. To get true resume (not just “don’t lose the file”), pair the component with a chunked/resumable upload strategy:
- Chunked PUT with
Content-Range— split the file withBlob.slice(), upload sequentially, and track the last acknowledged offset per file. - A resumable protocol — tus or S3 multipart upload, which hand you an upload/session ID your server can resume against.
Track offsets outside the component (e.g. a Map<File, number>) and resume from there on retry. Since your transport now gives you real byte progress, drive the bar directly instead of trickling — trickle() is only for requests where you can’t get real progress events:
function setProgress(item, percent) { item.querySelector('.bp-file-upload__progress').value = percent item.querySelector('.bp-file-upload__percent').textContent = `${percent}%`}
const offsets = new Map() // File -> bytes already confirmed by the server
async function uploadChunked(file, item) { const CHUNK = 5 * 1024 * 1024 let offset = offsets.get(file) ?? 0
while (offset < file.size) { const chunk = file.slice(offset, offset + CHUNK) await fetch(`/uploads/${file.name}`, { method: 'PUT', headers: { 'Content-Range': `bytes ${offset}-${offset + chunk.size - 1}/${file.size}` }, body: chunk, }) offset += chunk.size offsets.set(file, offset) setProgress(item, Math.round((offset / file.size) * 100)) } offsets.delete(file)}
const onUpload = ({ detail: { file, item } }) => uploadChunked(file, item) .then(() => uploader.complete(item)) .catch(() => uploader.fail(item, 'Upload failed'))
uploader.addEventListener('bp-file-upload:add', onUpload)uploader.addEventListener('bp-file-upload:retry', onUpload)There’s no dedicated progress-setting method on the element — setProgress() above (two lines, no magic) is the whole surface. trickle(), complete(), and fail() remain the two moments the component itself owns: “we don’t know exactly how far along this is” and “it’s done or it isn’t.” Real, driven progress is your call once you have real numbers.
Public API
Section titled “Public API”| Variable | Default | Description |
|---|---|---|
--file-upload-border | 2px dashed var(--bp-color-border) | Dropzone border |
--file-upload-border-radius | var(--bp-radius-lg) | Dropzone border radius |
--file-upload-background | var(--bp-color-bg-subtle) | Dropzone background |
--file-upload-padding | var(--bp-space-8) | Dropzone inner padding |
--file-upload-gap | var(--bp-space-2) | Gap between dropzone children |
--file-upload-accent | var(--bp-primary) | Drag-over / focus accent color |
Accessibility
Section titled “Accessibility”- The dropzone is a native
<label for="...">wrapping a real<input type="file">— clickable, keyboard-focusable, and announced correctly without any ARIA. - Progress uses the native
<progress>element, not a styled<div>, so assistive tech gets the role and value for free. - Error state pairs a text message (“Upload failed”) with a visible Retry button — never color alone.
- The file input keeps native focus styles via
:focus-visible; don’t remove the outline without thebox-shadowreplacement already applied by.bp-file-upload__dropzone.
Browser APIs
Section titled “Browser APIs”| API | Availability | Used for | Without it | Polyfill |
|---|---|---|---|---|
DataTransfer / drag events | Widely available Baseline 2015 | Reading dropped files | Falls back to click-to-browse via the native file input | None needed |
<progress> | Widely available Baseline 2015 | Per-file upload progress | None — always supported | None needed |
Internals
Section titled “Internals”--_border,--_border-radius,--_background,--_padding,--_gap,--_accent— component-private, do not set directly.- The trickle timer is stored on the item element (
item._bpTrickleTimer) and cleared bycomplete()andfail().