Component contract
Renders a progress indicator. Determinate vs indeterminate is driven by whether value is provided.
value?: number— 0..100. When undefined, renders indeterminate.label: string— required. Describes what's progressing ("Uploading address-book.csv"). Used as aria-label and visible text below the bar.showLabel?: boolean— defaults true; even when false, label is still aria-label.showValue?: boolean— when determinate, show "{value}%" inside or beside the bar. Defaults to true.tone?: "default" | "success" | "warning" | "danger"— visual tone; "success" used when finalizing.striped?: boolean— animated stripes during indeterminate; respects prefers-reduced-motion.
Interaction surface
-
Determinate bar fills 0..100.
Bar fill width = value%. Smooth transition between value updates (200ms ease-out). aria-valuenow updates accordingly.
-
Indeterminate bar animates a sliding fill.
When value is undefined, the bar shows a sliding/pulsing motion. aria-valuenow is omitted (or the bar uses aria-busy=true).
-
Value clamps to 0..100.
value=-5 clamps to 0; value=150 clamps to 100; value=NaN renders as indeterminate. Component never panics on bad input; it normalizes silently.
-
Value never decreases.
If a parent reports value=80 then value=60, the visual bar stays at 80 (the highest value seen). Receding progress is a contract bug; the component shields the UI from it.
-
prefers-reduced-motion disables stripes + smooth transitions.
Under reduce, indeterminate uses a static "in progress" indicator (e.g., a pulsing dot, but with prefers-reduced-motion the pulse is disabled too — falls back to a static "Working" text). Determinate transitions are instant.
Failure modes
Bar reaches 100% but operation isn't done
Trigger: parent reports value=100 prematurely; bar shows full + green; user thinks done but the artifact isn't ready.
The component cannot know "done" — that's the parent's responsibility. But the parent contract (enforced by ui-async-job-tracker) is to clamp at 99 until the terminal state. Soft contract, documented at branch level.
Bar regresses backwards
Trigger: parent's progress estimator gives 80 then 60; user sees the bar shrink and panics.
Component tracks max-seen value internally; bar visual never shrinks. Harness: dispatch value=80 then value=60, assert visual width corresponds to 80 (use computed-style or aria-valuenow).
Negative or NaN value crashes
Trigger: parent passes value=-5 or value=NaN, component throws.
Negative clamps to 0; NaN renders as indeterminate; over-100 clamps to 100. Harness: each input rendered without crash, value normalized.
Indeterminate bar pulses under prefers-reduced-motion
Trigger: motion-reduce active but the indeterminate animation still plays.
Under reduce, indeterminate falls back to a static "Working" text or a non-animated dot. Harness: emulate motion-reduce, indeterminate render, assert no animation property in computed style.
Label is hidden by overflow
Trigger: long label like "Uploading address-book-very-long-filename-2026-04-28-final-revised.csv" gets cut off.
Label truncates with ellipsis; full label is the bar's title attribute (or aria-label) so it's accessible. Harness: long label, assert visible text overflows + ellipsis OR is clipped, and aria-label contains full text.
aria-valuenow not updating in real time
Trigger: bar visual transitions smoothly but aria-valuenow updates only at the end of the transition.
aria-valuenow updates immediately when value prop changes (matching the destination, not the in-flight visual position). Harness: dispatch value=50, assert aria-valuenow=50 within next render frame.
Visible value text out of sync with bar
Trigger: bar smoothly animates from 50 to 80, but the "%" text jumps instantly to 80 — looks misaligned.
"%" text either tweens to match the bar OR snaps to the destination value. Either is acceptable; mixed (text snaps, bar tweens) is not. Harness: dispatch value=80 from value=50, screenshot mid-transition (~200ms), assert the displayed text matches or is between the two endpoints.
Color tone alone signals state to a colorblind user
Trigger: tone=danger renders a red bar with no other indicator; user can't tell it's an error condition.
Tone is reinforced by an icon (warning triangle for warning, X for danger) AND the label text reflecting the state. Color is not the only differentiator. Harness: render tone=danger, screenshot under grayscale filter, assert state still discernible from icon + label.
Accessibility
role="progressbar".- Determinate:
aria-valuenow,aria-valuemin=0,aria-valuemax=100. - Indeterminate:
aria-busy="true", no aria-valuenow. aria-labelfrom the label prop; visible label has its own id and aria-labelledby.- Color contrast: bar fill against track ≥ 3:1, value text ≥ 4.5:1 against its background.
- Animation respects prefers-reduced-motion.
- axe-clean at severity ≥ serious.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-progress-bar | Outer wrapper | data-mode=determinate|indeterminate; data-tone |
ui-progress-bar-track | The track background | Visual background |
ui-progress-bar-fill | The filled portion | Width = value% |
ui-progress-bar-label | Visible label below bar | Visible when showLabel=true |
ui-progress-bar-value | Numeric % display | Visible when determinate AND showValue=true |
ui-progress-bar-icon | Tone icon | Visible when tone is non-default |
Agent test plan
Standalone probes against /admin-test/ui-progress-bar-fixture with variants: determinate-50, determinate-100, indeterminate, regressing-value, negative-value, NaN-value, danger-tone, with-prefers-reduced-motion.
Probe list
- determinate-fill-width: value=50 → fill width corresponds to 50% (test via computed style or bbox)
- determinate-aria-valuenow: value=50 → aria-valuenow=50, valuemin=0, valuemax=100
- indeterminate-aria-busy: value=undefined → aria-busy=true, no aria-valuenow
- value-clamps-negative: value=-5 → renders as 0
- value-clamps-over-100: value=150 → renders as 100
- value-NaN-indeterminate: value=NaN → renders indeterminate
- value-never-regresses: dispatch 80 then 60, visual stays at 80
- aria-valuenow-updates-immediately: dispatch 50, next render aria-valuenow=50
- prefers-reduced-motion-no-animation: motion-reduce + indeterminate, no animation in computed style
- long-label-truncates: 80-char label, visible text clipped + aria-label has full
- tone-icon-visible-non-default: tone=danger → ui-progress-bar-icon visible
- tone-disambiguates-without-color: grayscale filter, danger still discernible from icon + label
- color-contrast-fill-track: ≥ 3:1
- color-contrast-value-text: ≥ 4.5:1
- axe-clean-serious: no serious violations