Direct Upload Progress
stimeo--direct-upload
Renders per-file ActiveStorage Direct Upload progress with aria + status announcements.
The stimeo--direct-upload controller subscribes to the ActiveStorage direct-upload:* events (initialize / progress / error / end, which bubble to document) and renders a per-file progress row cloned from the row template. It updates aria-valuenow / aria-valuetext, the [data-field="percent"] text, and the --stimeo-upload-progress custom property, flips data-upload-state to done / error, and reflects the aggregate on data-upload-progress. Completion and failure are announced into the optional status live region using the consumer-provided doneLabel / errorLabel (with %{name} substituted), while per-tick progress is conveyed by the progressbar's aria-valuenow to avoid flooding. Behavior only — no bars are drawn, and the upload itself stays with @rails/activestorage. Listeners are removed on disconnect.
<%# Direct upload demo: there is no server, so demo.js fires the ActiveStorage
direct-upload:* events to drive the rows. The library clones the row template per
file, updates aria-valuenow / data-upload-state / the --stimeo-upload-progress
var, and announces completion into the status live region. This demo styles the
bars; the sr-only status carries the announcement. %>
<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-done-label-value="<%= t('components.direct_upload.demo.done') %>"
data-stimeo--direct-upload-error-label-value="<%= t('components.direct_upload.demo.error') %>">
<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>
<span class="visually-hidden" data-stimeo--direct-upload-target="status"
aria-live="polite"></span>
</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;
}
// Direct upload demo (consumer-side JS).
//
// There is no server here, so this fires the ActiveStorage direct-upload:* events
// the controller subscribes to — initialize, progress (stepping to 100), then end —
// to drive the progress rows. Real apps get these from @rails/activestorage.
document.querySelectorAll(".direct-upload-demo").forEach((root) => {
const startButton = root.querySelector("[data-direct-upload-start]");
if (!startButton) return;
const fire = (type, detail) => {
document.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
};
let run = 0;
startButton.addEventListener("click", () => {
run += 1;
const files = [`photo-${run}.jpg`, `notes-${run}.pdf`];
files.forEach((name, index) => {
const id = `${run}-${index}`;
fire("direct-upload:initialize", { id, file: { name } });
let percent = 0;
const timer = window.setInterval(() => {
percent += 20;
fire("direct-upload:progress", { id, progress: percent });
if (percent >= 100) {
window.clearInterval(timer);
fire("direct-upload:end", { id });
}
}, 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" |
status
|
Optional aria-live region for completion/failure announcements. |
data-stimeo--direct-upload-target="status" |
Values
| Name | Description | Attribute |
|---|---|---|
announce
|
Whether to announce completion/failure into status (default true). |
data-stimeo--direct-upload-announce-value |
removeOnDone
|
Remove a completed row after a short delay (default false). |
data-stimeo--direct-upload-remove-on-done-value |
doneLabel
|
Completion message; %{name} is replaced with the file name. Empty disables it. |
data-stimeo--direct-upload-done-label-value |
errorLabel
|
Failure message; %{name} is replaced with the file name. Empty disables it. |
data-stimeo--direct-upload-error-label-value |
scope
|
Selector for the owning form/root; only events from inputs inside it are handled. | data-stimeo--direct-upload-scope-value |
Events
| Name | Description | Event |
|---|---|---|
progress
|
Fires on each progress update, with detail.id / detail.percent. |
stimeo--direct-upload:progress |
done
|
Fires when a file completes, with detail.id. |
stimeo--direct-upload:done |
error
|
Fires when a file fails, with detail.id / detail.error. |
stimeo--direct-upload:error |
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 (0–100 / "42%"). |
data-upload-state |
Each row | "uploading" / "done" / "error". |
data-upload-progress |
Controller element | Aggregate progress across rows (0–100). |
--stimeo-upload-progress |
Rows and element | Progress percentage for drawing the bar. |