Direct Upload Progress
stimeo--direct-upload
Shows upload progress per file and announces when each one finishes or fails.
Shows the progress of a Rails direct upload as one row per file. Rows are cloned from your template, so the look is entirely yours. Progress appears both as text and as a value CSS can read, so a filling bar is easy to draw, and a screen reader can check how far along it is. The file name comes from the upload itself. A row that has failed is never rewritten as finished by the trailing event. Overall progress is published separately and goes away once everything is done. Finishing and failing are announced through the page's shared announcer with wording you choose, while the ticking progress is never announced, so it does not become noise.
Keyboard
This component has no keyboard interactions of its own.
<%# Direct upload demo: there is no server, so demo.js fires the ActiveStorage
direct-upload:* events to drive the rows — including one upload that fails
(error followed by end, the order ActiveStorage really uses). The library
clones the row template per file, updates aria-valuenow / aria-valuetext /
data-upload-state / the --stimeo--upload-progress var, and hands completion
and failure announcements to the shared stimeo--announcer your app seats once,
in its layout (completion and failure are the transitions worth reading;
per-tick progress stays on each row's aria-valuenow). This demo styles the
bars. %>
<% done_text = t("components.direct_upload.demo.done") %>
<% error_text = t("components.direct_upload.demo.error") %>
<div class="direct-upload-demo">
<button type="button" class="demo-trigger" data-direct-upload-start>
<%= t("components.direct_upload.demo.start") %>
</button>
<div
class="direct-upload"
data-controller="stimeo--direct-upload"
data-stimeo--direct-upload-announce-done-text-value="<%= done_text %>"
data-stimeo--direct-upload-announce-error-text-value="<%= error_text %>">
<div class="direct-upload__list" data-stimeo--direct-upload-target="list"></div>
<template data-stimeo--direct-upload-target="row">
<div class="direct-upload__row" role="progressbar" aria-valuemin="0" aria-valuemax="100">
<span class="direct-upload__name" data-field="name"></span>
<span class="direct-upload__track"><span class="direct-upload__bar"></span></span>
<span class="direct-upload__percent" data-field="percent"></span>
</div>
</template>
</div>
</div>
/*
* Presentation-only styles for the direct-upload demo.
* The library sets aria-valuenow, data-upload-state, and the
* --stimeo--upload-progress custom property; this CSS draws the bar from that var
* and colors the done / error states.
*/
.direct-upload-demo {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 30rem;
}
.direct-upload__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.direct-upload__row {
display: grid;
grid-template-columns: 8rem 1fr 3rem;
align-items: center;
gap: 0.5rem;
}
.direct-upload__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.85rem;
}
.direct-upload__track {
height: 0.5rem;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
.direct-upload__bar {
display: block;
height: 100%;
width: var(--stimeo--upload-progress, 0%);
background: var(--accent);
transition: width 0.2s ease;
}
.direct-upload__row[data-upload-state="done"] .direct-upload__bar {
background: var(--leaf-500);
}
.direct-upload__row[data-upload-state="error"] .direct-upload__bar {
background: var(--danger-500);
}
.direct-upload__percent {
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Non-color state cues (WCAG 1.4.1); progressbar children are presentational,
* so the glyphs stay out of the accessibility tree. */
.direct-upload__row[data-upload-state="done"] .direct-upload__percent::after {
content: " ✓";
color: var(--leaf-500);
}
.direct-upload__row[data-upload-state="error"] .direct-upload__percent::after {
content: " ⚠";
color: var(--danger-500);
}
// Direct upload demo (consumer-side JS).
//
// There is no server here, so this fires the ActiveStorage direct-upload:* events
// the controller subscribes to. Real apps get these from @rails/activestorage.
// Two uploads complete (initialize, progress to 100, end); the third fails midway
// with error followed by end — the order ActiveStorage really dispatches — so the
// errored row demonstrably survives the trailing end event.
document.querySelectorAll(".direct-upload-demo").forEach((root) => {
const startButton = root.querySelector("[data-direct-upload-start]");
if (!startButton) return;
const fire = (type, detail) => {
// cancelable mirrors ActiveStorage; the library cancels a rendered
// direct-upload:error to suppress the native alert().
document.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, cancelable: true }));
};
let run = 0;
startButton.addEventListener("click", () => {
run += 1;
const uploads = [
{ name: `photo-${run}.jpg`, failAt: null },
{ name: `notes-${run}.pdf`, failAt: null },
{ name: `report-${run}.docx`, failAt: 60 },
];
uploads.forEach(({ name, failAt }, index) => {
const id = `${run}-${index}`;
const file = { name };
fire("direct-upload:initialize", { id, file });
let percent = 0;
const timer = window.setInterval(() => {
percent += 20;
if (failAt !== null && percent >= failAt) {
window.clearInterval(timer);
fire("direct-upload:error", { id, file, error: "Network error" });
fire("direct-upload:end", { id, file });
return;
}
fire("direct-upload:progress", { id, file, progress: percent });
if (percent >= 100) {
window.clearInterval(timer);
fire("direct-upload:end", { id, file });
}
}, 300);
});
});
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--direct-upload"
Targets
| Name | Description | Attribute |
|---|---|---|
list
required
|
Where progress rows are inserted. | data-stimeo--direct-upload-target="list" |
row
required
|
A <template> cloned once per file. |
data-stimeo--direct-upload-target="row" |
Values
| Name | Description | Attribute |
|---|---|---|
removeOnDone
|
Remove a completed row after a short delay (default false). |
data-stimeo--direct-upload-remove-on-done-value |
announceDoneText
|
Completion message sent to the shared Announcer; {name} expands to the file name. Empty disables it. |
data-stimeo--direct-upload-announce-done-text-value |
announceErrorText
|
Failure message sent to the shared Announcer; {name} expands to the file name. Empty disables it. |
data-stimeo--direct-upload-announce-error-text-value |
scope
|
Selector for the owning form/root; only events from inputs inside it are handled. An unparsable selector falls back to the default (handle all). | data-stimeo--direct-upload-scope-value |
Events
| Name | Description | Event |
|---|---|---|
progress
|
Fires on each progress update, with detail.id / detail.percent (clamped). |
stimeo--direct-upload:progress |
done
|
Fires when a file completes successfully, with detail.id. Never fires for a failed row. |
stimeo--direct-upload:done |
error
|
Fires when a file fails, with detail.id / detail.error. When the failure is rendered as a row, ActiveStorage's native alert() is suppressed; otherwise the alert stays as the fallback. |
stimeo--direct-upload:error |
reconcile
|
Fires when the Turbo cache rewind discards in-flight uploads; detail carries the discarded ids. |
stimeo--direct-upload:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-valuenow / aria-valuetext |
Each row | The row's progress (rounded and clamped to 0–100 / "42%"; 0 / "0%" on creation). |
aria-label |
Each row | Accessible name = the file name (a template-authored label wins). |
data-upload-state |
Each row | "uploading" / "done" / "error". A settled row (done / error) is never rewritten by later events. |
data-upload-progress |
Controller element | Aggregate progress across rows (0–100); recomputed on add / update / remove and withdrawn at zero rows. |
--stimeo--upload-progress |
Rows and element | Progress percentage for drawing the bar; the element-side value is withdrawn at zero rows. |