feat(ui): build the Stepper component

Replaces a scaffold that rendered bare anchors with ordered steps: complete,
current and upcoming status derived from the active index, horizontal or
vertical, optional icons, and indexed named slots (step-0, step-1, ...) for
authoring a step body by hand.

Also tightens the roving contract from the previous commit. A template writes
data-wrn-roving="" or data-wrn-roving-item="false" to mean not this time, but
a bare [attr] selector matches either, so a read-only stepper would still have
taken arrow-key focus. The container now requires a named axis, while a bare
item marker still counts as opted in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:44:12 +05:30
co-authored by Claude Opus 5
parent b67a5e43eb
commit 45ef035bae
5 changed files with 337 additions and 13 deletions
+27 -1
View File
@@ -3069,11 +3069,35 @@ export const REACTIVE_RUNTIME = String.raw`
var ROVING_SELECTOR = "[data-wrn-roving]";
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
/*
* These attributes are written by templates, so they arrive stringified:
* data-wrn-roving="" or data-wrn-roving-item="false" is how a component
* says "not this time". A bare [attr] selector matches either, so the value
* has to be checked -- otherwise a stepper with clickable=false still takes
* arrow-key focus.
*/
// A bare data-wrn-roving-item means yes; only an explicit "false" opts out.
function rovingItemOff(value) {
return value === null || value === "false";
}
/*
* The container is stricter: it must name an axis. An empty value is what a
* template emits for {cond ? "horizontal" : ""}, so empty means off rather
* than defaulting to horizontal.
*/
function rovingOrientation(container) {
var value = container.getAttribute("data-wrn-roving");
if (value === null || value === "" || value === "false") return "";
return value;
}
function rovingItems(container) {
var found = [];
var candidates = container.querySelectorAll(ROVING_ITEM_SELECTOR);
for (var index = 0; index < candidates.length; index += 1) {
var candidate = candidates[index];
if (rovingItemOff(candidate.getAttribute("data-wrn-roving-item"))) continue;
// A nested group owns its own items; do not steal them.
if (candidate.closest(ROVING_SELECTOR) !== container) continue;
if (candidate.hasAttribute("disabled")) continue;
@@ -3107,6 +3131,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
function syncRovingGroup(container) {
if (!rovingOrientation(container)) return;
var items = rovingItems(container);
if (!items.length) return;
applyRovingTabindex(items, rovingActiveIndex(items));
@@ -3129,7 +3154,8 @@ export const REACTIVE_RUNTIME = String.raw`
var index = items.indexOf(item);
if (index === -1) return;
var orientation = container.getAttribute("data-wrn-roving") || "horizontal";
var orientation = rovingOrientation(container);
if (!orientation) return;
var horizontal = orientation === "horizontal" || orientation === "both";
var vertical = orientation === "vertical" || orientation === "both";
var key = event.key;
+15
View File
@@ -1030,3 +1030,18 @@ test("a hidden dialog does not trap Tab", () => {
outside.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
expect(doc.activeElement!.id).toBe("outside");
});
test("an empty or false roving attribute opts the group out entirely", () => {
const win = mount(
`<div data-wrn-roving="">
<button data-wrn-roving-item="false" id="a">A</button>
<button data-wrn-roving-item="false" id="b">B</button>
</div>`,
);
const doc = win.document;
const a = doc.querySelector("#a") as unknown as HTMLElement;
expect(a.getAttribute("tabindex")).toBeNull();
a.focus();
a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
expect(doc.activeElement!.id).toBe("a");
});
+210 -12
View File
@@ -1,24 +1,222 @@
// Stepper -- ordered progress through a sequence.
//
// <Stepper steps='[{"label":"Account"},{"label":"Billing"}]' active={1} />
//
// Each step can be authored by hand instead of using the built-in body, by
// passing a slot named for its index:
//
// <Stepper steps={steps} active={1}>
// <div data-slot="step-1">...anything...</div>
// </Stepper>
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Stepper {
outputs {
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
previous(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
next(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
complete(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
change(payload: { index: number; step: object })
}
props {
size: string = "default"
color: string = "primary"
label: string = "Stepper"
items: unknown[] = []
active: string = ""
steps: unknown[] = []
active: number = 0
orientation: string = "horizontal"
clickable: boolean = false
label: string = "Progress"
class: string = ""
}
functions {
shared function stepList() {
return Array.isArray(steps) ? steps : []
}
shared function activeIndex() {
var count = stepList().length
if (count < 1) {
return 0
}
var value = Number(active)
if (!value || value < 0) {
return 0
}
return Math.min(value, count - 1)
}
shared function statusFor(index) {
if (index < activeIndex()) {
return "complete"
}
if (index === activeIndex()) {
return "current"
}
return "upcoming"
}
// An empty orientation is how the roving runtime is told to stay out of
// the way, so a read-only stepper never takes arrow-key focus.
shared function rovingAxis() {
if (!clickable) {
return ""
}
return orientation === "vertical" ? "vertical" : "horizontal"
}
client function selectStep(index, step) {
if (!clickable) {
return
}
output.change({ index: index, step: step })
}
}
view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--stepper wire-next--{orientation} {class}" aria-label="{label}">
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
<ol
{...attrs}
data-ui-component="Stepper"
class='wire-stepper {class}'
data-orientation='{orientation}'
data-clickable='{clickable}'
data-wrn-roving='{rovingAxis()}'
aria-label='{label}'
>
{#each stepList() as step, index}
<li
class="wire-stepper__step"
data-status='{statusFor(index)}'
aria-current='{statusFor(index) === "current" ? "step" : "false"}'
>
<button
type="button"
class="wire-stepper__button"
data-wrn-roving-item='{clickable ? "true" : "false"}'
@click='selectStep(index, step)'
>
<span class="wire-stepper__marker" aria-hidden="true">
<span class='wire-stepper__icon {step.icon}' data-show="step.icon"></span>
<span class="wire-stepper__number" data-show="!step.icon">{index + 1}</span>
</span>
<span class="wire-stepper__body">
<span class="wire-stepper__label">{step.label}</span>
<span class="wire-stepper__description" data-show="step.description">
{step.description}
</span>
</span>
</button>
<span class="wire-stepper__custom">
<slot name="step-{index}"></slot>
</span>
</li>
{/each}
<slot />
</nav>
</ol>
}
style {
.wire-stepper {
display: flex;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
max-width: 100%;
}
.wire-stepper[data-orientation="vertical"] {
flex-direction: column;
}
.wire-stepper__step {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
gap: 0.35rem;
}
.wire-stepper__button {
appearance: none;
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem;
border: 0;
border-radius: var(--wire-radius-sm);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: default;
}
.wire-stepper[data-clickable="true"] .wire-stepper__button {
cursor: pointer;
}
.wire-stepper[data-clickable="true"] .wire-stepper__button:hover {
background: var(--wire-color-surface-soft);
}
.wire-stepper__button:focus-visible {
outline: 2px solid var(--wire-color-primary);
outline-offset: 2px;
}
.wire-stepper__marker {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 2rem;
height: 2rem;
border: 1px solid var(--wire-color-border);
border-radius: 999px;
background: var(--wire-color-surface);
font-size: 0.85rem;
font-weight: 700;
}
.wire-stepper__step[data-status="complete"] .wire-stepper__marker {
border-color: var(--wire-color-primary);
background: var(--wire-color-primary);
color: var(--wire-color-primary-contrast);
}
.wire-stepper__step[data-status="current"] .wire-stepper__marker {
border-color: var(--wire-color-primary);
color: var(--wire-color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wire-color-primary) 22%, transparent);
}
.wire-stepper__step[data-status="upcoming"] .wire-stepper__marker {
color: var(--wire-color-text-muted);
}
.wire-stepper__body {
display: flex;
flex-direction: column;
min-width: 0;
}
.wire-stepper__label {
font-size: 0.9rem;
font-weight: 600;
}
.wire-stepper__description {
color: var(--wire-color-text-muted);
font-size: 0.78rem;
}
.wire-stepper__step[data-status="upcoming"] .wire-stepper__label {
color: var(--wire-color-text-muted);
}
/* A horizontal stepper cannot stay side by side on a phone. */
@media (max-width: 639px) {
.wire-stepper {
flex-direction: column;
}
}
}
}
+47
View File
@@ -2493,3 +2493,50 @@ test("pagination survives an empty dataset", async () => {
const dom = mountHtml(html);
expect(dom.querySelector(".wire-pagination")).not.toBeNull();
});
test("stepper marks complete, current and upcoming steps", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const html = await renderComponent(source, {
steps: [
{ label: "Account", description: "Your details" },
{ label: "Billing", description: "Payment method" },
{ label: "Confirm", description: "Review and submit" },
],
active: 1,
});
const dom = mountHtml(html);
const items = [...dom.querySelectorAll(".wire-stepper__step")];
expect(items).toHaveLength(3);
expect(items[0]!.getAttribute("data-status")).toBe("complete");
expect(items[1]!.getAttribute("data-status")).toBe("current");
expect(items[2]!.getAttribute("data-status")).toBe("upcoming");
expect(items[1]!.getAttribute("aria-current")).toBe("step");
expect(dom.querySelector(".wire-stepper")!.tagName.toLowerCase()).toBe("ol");
});
test("stepper renders vertically and clamps an out-of-range active index", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const html = await renderComponent(source, {
steps: [{ label: "One" }, { label: "Two" }],
active: 99,
orientation: "vertical",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wire-stepper") as HTMLElement;
expect(root.getAttribute("data-orientation")).toBe("vertical");
const items = [...dom.querySelectorAll(".wire-stepper__step")];
expect(items[1]!.getAttribute("data-status")).toBe("current");
});
test("clickable stepper opts into roving focus", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const html = await renderComponent(source, {
steps: [{ label: "One" }, { label: "Two" }],
active: 0,
clickable: true,
});
const dom = mountHtml(html);
expect(dom.querySelector('[data-wrn-roving="horizontal"]')).not.toBeNull();
expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBe(2);
});