From e8e1a2623babb150058b9801bafc0d3690b2e5df Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Fri, 7 Aug 2026 16:35:42 +0530 Subject: [PATCH] docs: navigation phase 1 implementation plan Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-07-navigation-phase-1.md | 1665 +++++++++++++++++ 1 file changed, 1665 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-07-navigation-phase-1.md diff --git a/docs/superpowers/plans/2026-08-07-navigation-phase-1.md b/docs/superpowers/plans/2026-08-07-navigation-phase-1.md new file mode 100644 index 00000000..cb7cf144 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-navigation-phase-1.md @@ -0,0 +1,1665 @@ +# Navigation Group — Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship runtime-owned roving arrow-key focus, then build Nav, Pagination and Stepper as real components on that foundation. + +**Architecture:** One declarative attribute pair (`data-wrn-roving` on a container, `data-wrn-roving-item` on children) implements ARIA roving focus once in the reactive runtime, the same way modal dialog focus already lives there. The three components then declare intent with a single attribute instead of carrying five near-identical `@keydown` handlers. Each component uses a local `style {}` block with `wire-*` classes, declares its `outputs`, and takes props in / reports outputs out with no global state. + +**Tech Stack:** Bun, TypeScript, `.wrn` components, happy-dom for runtime tests, `@wrnexus/test` (`renderComponent`, `mountHtml`) for component tests. + +Source spec: `docs/superpowers/specs/2026-08-07-navigation-components-design.md` + +## Global Constraints + +These apply to every task. Each has already cost this codebase real debugging time. + +- **No apostrophes in `.wrn` comments.** The brace scanner breaks on them and reports a misleading unbalanced-brace error pointing at a different block. +- **Inside `style {}` blocks use `/* */` only.** `//` is not a CSS comment and silently swallows the rule that follows it. +- **`packages/csr/src/reactive-runtime.ts` is a `String.raw` template.** A raw backtick terminates it. Escape as `` \` ``. Avoid template literals and regex literals with escapes inside added code. +- **No deferred state writes in client functions.** State written after the function returns — inside a `setTimeout`, a promise callback, or an observer — is dropped. Anything asynchronous belongs in the runtime. +- **Never call a peer function after an application callback.** The peer wrapper flushes the entry-time state snapshot. +- **No boolean attributes bound to loop variables** (`checked`, `selected`, `disabled` referencing an `{#each}` variable). They compile to server expressions where the loop variable does not exist. +- **Package components need explicit imports** (`import X from "./X.wrn"`), enforced by `bun run check:component-imports`. +- **happy-dom returns zeros from `getBoundingClientRect()`.** Never gate behaviour on measured size in code that needs a test. Use the `hidden` attribute or a `[data-show="false"]` ancestor instead. +- **Runtime size budget is 175000 bytes** for `reactive-runtime.ts`, currently ~167k. Check with `node scripts/security-performance-audit.mjs` before committing. +- Every task ends green on `bun run test`. The phase ends green on `bun run check:production`. + +--- + +## File Structure + +| File | Responsibility | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `packages/csr/src/reactive-runtime.ts` | Add `setupRovingFocus()` and fix `isDialogVisible()` to be testable. Modify only. | +| `packages/csr/test/reactive.test.ts` | Roving focus tests and the missing dialog-behaviour tests. | +| `packages/ui/components/Pagination.wrn` | Replace the scaffold. Page controls, compact + numbered. | +| `packages/ui/components/Stepper.wrn` | Replace the scaffold. Ordered steps, indexed named slots. | +| `packages/ui/components/Nav.wrn` | Replace the scaffold. Multi-level links, roving, responsive. | +| `packages/ui/test/ui.test.ts` | Per-component assertions. Modify only. | +| `examples/component-showcase/scripts/showcase-profiles.mjs` | Demo profiles for the three components. Modify only. | + +Task order matters: Task 1 produces the roving attribute contract that Tasks 3 and 4 consume. + +--- + +### Task 1: Roving focus in the runtime + +**Files:** + +- Modify: `packages/csr/src/reactive-runtime.ts` (add near the existing `setupModalDialogs`) +- Test: `packages/csr/test/reactive.test.ts` + +**Interfaces:** + +- Consumes: nothing. +- Produces: the DOM contract used by Tasks 3 and 4 — + - `data-wrn-roving="horizontal" | "vertical" | "both"` on a container element + - `data-wrn-roving-item` on each focusable descendant + - The runtime sets `tabindex="0"` on the active item and `tabindex="-1"` on the rest, and moves focus on ArrowLeft/Right (horizontal), ArrowUp/Down (vertical), both axes for `"both"`, plus Home and End, wrapping at the ends and skipping disabled items. + - Active item is the one with `aria-selected="true"`, `aria-current="page"`, or `aria-current="step"`; otherwise the first item. + +- [ ] **Step 1: Write the failing tests** + +Append to `packages/csr/test/reactive.test.ts`: + +```ts +test("roving focus moves with arrow keys and wraps", () => { + const win = mount( + `
+ + + +
`, + ); + const doc = win.document; + const a = doc.querySelector("#a") as HTMLElement; + const c = doc.querySelector("#c") as HTMLElement; + + expect(a.getAttribute("tabindex")).toBe("0"); + expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("-1"); + + a.focus(); + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(doc.activeElement!.id).toBe("b"); + expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("0"); + expect(a.getAttribute("tabindex")).toBe("-1"); + + doc + .querySelector("#b")! + .dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + expect(doc.activeElement!.id).toBe("a"); + + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + expect(doc.activeElement!.id).toBe("c"); + + c.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(doc.activeElement!.id).toBe("a"); +}); + +test("roving focus honours Home and End and skips disabled items", () => { + const win = mount( + `
+ + + +
`, + ); + const doc = win.document; + const a = doc.querySelector("#a") as HTMLElement; + a.focus(); + + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + expect(doc.activeElement!.id).toBe("c"); + + doc + .querySelector("#c")! + .dispatchEvent(new win.KeyboardEvent("keydown", { key: "Home", bubbles: true })); + expect(doc.activeElement!.id).toBe("a"); + + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "End", bubbles: true })); + expect(doc.activeElement!.id).toBe("c"); +}); + +test("horizontal roving ignores vertical arrows so the page still scrolls", () => { + const win = mount( + `
+ + +
`, + ); + const a = win.document.querySelector("#a") as HTMLElement; + a.focus(); + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + expect(win.document.activeElement!.id).toBe("a"); +}); + +test("roving tabindex starts on the selected item, not the first", () => { + const win = mount( + `
+ + +
`, + ); + expect(win.document.querySelector("#b")!.getAttribute("tabindex")).toBe("0"); + expect(win.document.querySelector("#a")!.getAttribute("tabindex")).toBe("-1"); +}); + +test("nested roving groups do not capture the outer group items", () => { + const win = mount( + `
+ +
+ + +
+ +
`, + ); + const doc = win.document; + const a = doc.querySelector("#a") as HTMLElement; + a.focus(); + a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(doc.activeElement!.id).toBe("b"); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +bun test packages/csr/test/reactive.test.ts +``` + +Expected: FAIL. Arrow keys do nothing, so `activeElement` stays `a` and no `tabindex` is set. + +- [ ] **Step 3: Implement the roving manager** + +In `packages/csr/src/reactive-runtime.ts`, insert immediately before `function hydrateScopes(root) {`: + +```js +/* + * Roving arrow-key focus, the ARIA pattern shared by tabs, menus, sidebars + * and steppers. A container marked data-wrn-roving owns its + * [data-wrn-roving-item] descendants: exactly one carries tabindex="0" so + * Tab reaches the group once, and the arrow keys move focus within it. + * + * This lives here rather than in each component because it is the same code + * five times over, and because visibility and focus bookkeeping cannot be + * held in component state -- a client function writing after it returns has + * that write dropped. + */ +var ROVING_SELECTOR = "[data-wrn-roving]"; +var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]"; + +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]; + // A nested group owns its own items; do not steal them. + if (candidate.closest(ROVING_SELECTOR) !== container) continue; + if (candidate.hasAttribute("disabled")) continue; + if (candidate.getAttribute("aria-disabled") === "true") continue; + // Deliberately not a size check: the test DOM reports every element as + // zero-sized, so measuring here would make the whole feature untestable. + if (candidate.hasAttribute("hidden")) continue; + if (candidate.closest('[data-show="false"]')) continue; + found.push(candidate); + } + return found; +} + +function rovingActiveIndex(items) { + for (var index = 0; index < items.length; index += 1) { + var item = items[index]; + if (item.getAttribute("aria-selected") === "true") return index; + var current = item.getAttribute("aria-current"); + if (current === "page" || current === "step" || current === "true") return index; + } + return 0; +} + +function applyRovingTabindex(items, activeIndex) { + for (var index = 0; index < items.length; index += 1) { + items[index].setAttribute("tabindex", index === activeIndex ? "0" : "-1"); + } +} + +function syncRovingGroup(container) { + var items = rovingItems(container); + if (!items.length) return; + applyRovingTabindex(items, rovingActiveIndex(items)); +} + +function syncRovingGroups() { + var groups = document.querySelectorAll(ROVING_SELECTOR); + for (var index = 0; index < groups.length; index += 1) syncRovingGroup(groups[index]); +} + +function handleRovingKeydown(event) { + var target = event.target; + if (!target || !target.closest) return; + var item = target.closest(ROVING_ITEM_SELECTOR); + if (!item) return; + var container = item.closest(ROVING_SELECTOR); + if (!container) return; + + var items = rovingItems(container); + var index = items.indexOf(item); + if (index === -1) return; + + var orientation = container.getAttribute("data-wrn-roving") || "horizontal"; + var horizontal = orientation === "horizontal" || orientation === "both"; + var vertical = orientation === "vertical" || orientation === "both"; + var key = event.key; + var next = -1; + + if ((horizontal && key === "ArrowRight") || (vertical && key === "ArrowDown")) { + next = (index + 1) % items.length; + } else if ((horizontal && key === "ArrowLeft") || (vertical && key === "ArrowUp")) { + next = (index - 1 + items.length) % items.length; + } else if (key === "Home") { + next = 0; + } else if (key === "End") { + next = items.length - 1; + } else { + return; + } + + event.preventDefault(); + applyRovingTabindex(items, next); + if (items[next].focus) items[next].focus(); +} + +function setupRovingFocus() { + if (window.__wrnexusRovingBound) return; + window.__wrnexusRovingBound = true; + + document.addEventListener("keydown", handleRovingKeydown, true); + + if (typeof MutationObserver === "function") { + new MutationObserver(function () { + window.setTimeout(syncRovingGroups, 0); + }).observe(document.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["aria-selected", "aria-current", "disabled", "aria-disabled", "hidden"], + }); + } + + syncRovingGroups(); +} +``` + +Then register it next to the existing call: + +```js +setupAnchoredOverlays(); +setupModalDialogs(); +setupRovingFocus(); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +bun test packages/csr/test/reactive.test.ts +``` + +Expected: PASS, all five new tests. + +- [ ] **Step 5: Check the runtime size budget** + +```bash +node scripts/security-performance-audit.mjs +``` + +Expected: `ok PERF-RUNTIME-SIZE-REACTIVE`. If it fails, the roving code must shrink — do not raise the ceiling again in this task. + +- [ ] **Step 6: Commit** + +```bash +git add packages/csr/src/reactive-runtime.ts packages/csr/test/reactive.test.ts +git commit -m "feat(csr): runtime-owned roving arrow-key focus" +``` + +--- + +### Task 2: Make dialog visibility testable and cover it + +Closes a gap shipped in 0.8.5: `isDialogVisible` gates on `getBoundingClientRect`, which is always zero in happy-dom, so the modal focus trap and scroll lock have no test coverage at all. + +**Files:** + +- Modify: `packages/csr/src/reactive-runtime.ts` (the `isDialogVisible` function) +- Test: `packages/csr/test/reactive.test.ts` + +**Interfaces:** + +- Consumes: `openDialogs`, `isDialogVisible`, `syncDialogs` from the existing dialog block. +- Produces: no new contract. Behaviour is unchanged in a real browser; only the visibility test becomes DOM-implementation independent. + +- [ ] **Step 1: Write the failing tests** + +Append to `packages/csr/test/reactive.test.ts`: + +```ts +test("opening a modal dialog traps Tab inside it", () => { + const win = mount( + `
+ +
+
+ + +
+
+
`, + ); + const doc = win.document; + const last = doc.querySelector("#last") as HTMLElement; + last.focus(); + last.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(doc.activeElement!.id).toBe("first"); +}); + +test("a hidden dialog does not trap Tab", () => { + const win = mount( + `
+ +
+
+ +
+
+
`, + ); + const doc = win.document; + const outside = doc.querySelector("#outside") as HTMLElement; + outside.focus(); + outside.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(doc.activeElement!.id).toBe("outside"); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +bun test packages/csr/test/reactive.test.ts -t "dialog" +``` + +Expected: the trap test FAILS — `getBoundingClientRect` reports zero size, so the dialog is never treated as open. + +- [ ] **Step 3: Replace the visibility check** + +In `packages/csr/src/reactive-runtime.ts`, replace the body of `isDialogVisible`: + +```js +function isDialogVisible(dialog) { + if (!dialog || !dialog.isConnected) return false; + if (dialog.hasAttribute("hidden")) return false; + if (dialog.closest('[data-show="false"]')) return false; + /* + * Deliberately no getBoundingClientRect: the test DOM reports every + * element as zero-sized, which made the trap and the scroll lock + * impossible to cover. The data-show marker is what actually expresses + * open/closed for these components anyway. + */ + return true; +} +``` + +- [ ] **Step 4: Run the full runtime suite** + +```bash +bun test packages/csr +``` + +Expected: PASS, including the two new dialog tests and every pre-existing test. + +- [ ] **Step 5: Commit** + +```bash +git add packages/csr/src/reactive-runtime.ts packages/csr/test/reactive.test.ts +git commit -m "fix(csr): make dialog visibility testable and cover the focus trap" +``` + +--- + +### Task 3: Pagination component + +Simplest of the three and depends on nothing from Task 1, so it establishes the component pattern. + +**Files:** + +- Modify (replace whole file): `packages/ui/components/Pagination.wrn` +- Test: `packages/ui/test/ui.test.ts` +- Modify: `examples/component-showcase/scripts/showcase-profiles.mjs` + +**Interfaces:** + +- Consumes: nothing. +- Produces: `` with outputs `change({page, pageSize})`, `previous({page})`, `next({page})`. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/ui/test/ui.test.ts`: + +```ts +test("pagination renders windowed page numbers and emits change", async () => { + const source = readFileSync(uiComponentPath("Pagination"), "utf8"); + const html = await renderComponent(source, { + page: 5, + pageSize: 10, + total: 200, + variant: "numbered", + siblingCount: 1, + }); + const dom = mountHtml(html); + const root = dom.querySelector(".wire-pagination") as HTMLElement; + + expect(root.getAttribute("role")).toBe("navigation"); + const current = dom.querySelector('.wire-pagination__page[aria-current="page"]'); + expect(current!.textContent!.trim()).toBe("5"); + expect(root.textContent).toContain("41"); + expect(root.textContent).toContain("50"); + expect(root.textContent).toContain("200"); +}); + +test("pagination clamps an out-of-range page instead of rendering nothing", async () => { + const source = readFileSync(uiComponentPath("Pagination"), "utf8"); + const html = await renderComponent(source, { page: 99, pageSize: 10, total: 30 }); + const dom = mountHtml(html); + const current = dom.querySelector('[aria-current="page"]'); + expect(current!.textContent!.trim()).toBe("3"); +}); + +test("pagination survives a non-array or empty dataset", async () => { + const source = readFileSync(uiComponentPath("Pagination"), "utf8"); + const html = await renderComponent(source, { page: 1, pageSize: 10, total: 0 }); + const dom = mountHtml(html); + expect(dom.querySelector(".wire-pagination")).not.toBeNull(); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +bun test packages/ui -t "pagination" +``` + +Expected: FAIL — the scaffold has no `.wire-pagination` class and no page numbers. + +- [ ] **Step 3: Replace `packages/ui/components/Pagination.wrn`** + +``` +// Pagination -- page controls over a known total. +// +// +// +// The component owns no data. It reports the requested page through its +// change output and lets the caller fetch or slice. +component Pagination { + outputs { + change(payload: { page: number; pageSize: number }) + previous(payload: { page: number }) + next(payload: { page: number }) + } + + props { + page: number = 1 + pageSize: number = 10 + total: number = 0 + variant: string = "compact" + siblingCount: number = 1 + showSummary: boolean = true + label: string = "Pagination" + previousLabel: string = "Previous" + nextLabel: string = "Next" + class: string = "" + } + + functions { + shared function lastPage() { + var size = Number(pageSize) > 0 ? Number(pageSize) : 10 + var count = Number(total) > 0 ? Number(total) : 0 + return Math.max(1, Math.ceil(count / size)) + } + + // Out-of-range values arrive routinely: page travels as an HTML attribute + // and callers compute it from data that may have shrunk. + shared function currentPage() { + var value = Number(page) + if (!value || value < 1) { + return 1 + } + return Math.min(value, lastPage()) + } + + shared function firstShown() { + if (Number(total) < 1) { + return 0 + } + return (currentPage() - 1) * (Number(pageSize) || 10) + 1 + } + + shared function lastShown() { + return Math.min(currentPage() * (Number(pageSize) || 10), Number(total) || 0) + } + + shared function pageNumbers() { + var last = lastPage() + var current = currentPage() + var siblings = Math.max(0, Number(siblingCount) || 0) + var start = Math.max(1, current - siblings) + var end = Math.min(last, current + siblings) + var pages = [] + if (start > 1) { + pages.push({ value: 1, label: "1", gap: false }) + if (start > 2) { + pages.push({ value: 0, label: "...", gap: true }) + } + } + for (var index = start; index <= end; index += 1) { + pages.push({ value: index, label: String(index), gap: false }) + } + if (end < last) { + if (end < last - 1) { + pages.push({ value: 0, label: "...", gap: true }) + } + pages.push({ value: last, label: String(last), gap: false }) + } + return pages + } + + client function goToPage(target) { + var next = Math.min(Math.max(1, Number(target) || 1), lastPage()) + output.change({ page: next, pageSize: Number(pageSize) || 10 }) + } + + client function goPrevious() { + var target = Math.max(1, currentPage() - 1) + output.previous({ page: target }) + output.change({ page: target, pageSize: Number(pageSize) || 10 }) + } + + client function goNext() { + var target = Math.min(lastPage(), currentPage() + 1) + output.next({ page: target }) + output.change({ page: target, pageSize: Number(pageSize) || 10 }) + } + } + + view { + + } + + style { + .wire-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + max-width: 100%; + } + + .wire-pagination__summary { + margin: 0; + color: var(--wire-color-text-muted); + font-size: 0.82rem; + } + + .wire-pagination__controls { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; + } + + .wire-pagination__pages { + display: flex; + align-items: center; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; + } + + .wire-pagination__page, + .wire-pagination__step { + appearance: none; + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 2.25rem; + justify-content: center; + padding: 0.4rem 0.6rem; + border: 1px solid var(--wire-color-border); + border-radius: var(--wire-radius-sm); + background: var(--wire-color-surface); + color: var(--wire-color-text); + font: inherit; + font-size: 0.85rem; + cursor: pointer; + } + + .wire-pagination__page:hover, + .wire-pagination__step:hover { + background: var(--wire-color-surface-soft); + } + + .wire-pagination__page[data-active="true"] { + border-color: var(--wire-color-primary); + background: var(--wire-color-primary); + color: var(--wire-color-primary-contrast); + font-weight: 700; + } + + .wire-pagination__gap { + padding: 0 0.35rem; + color: var(--wire-color-text-muted); + } + + .wire-pagination__compact { + margin: 0; + padding: 0 0.5rem; + color: var(--wire-color-text-muted); + font-size: 0.85rem; + } + + /* Below the small breakpoint the word labels crowd the arrows out. */ + @media (max-width: 639px) { + .wire-pagination { + justify-content: center; + } + + .wire-pagination__step-label { + display: none; + } + + .wire-pagination__summary { + width: 100%; + text-align: center; + } + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +bun test packages/ui -t "pagination" +``` + +Expected: PASS, all three tests. + +- [ ] **Step 5: Add the showcase profile** + +In `examples/component-showcase/scripts/showcase-profiles.mjs`, add a `Pagination` key alongside `DataTable`: + +```js + Pagination: { + demos: [ + standard( + "Compact arrows", + "The default: previous and next with a page counter, and a summary of the range in view.", + { page: 2, pageSize: 10, total: 137, variant: "compact" }, + ), + standard( + "Numbered pages", + "Page numbers windowed around the current page with ellipsis gaps, so a large set never renders hundreds of buttons.", + { page: 5, pageSize: 10, total: 200, variant: "numbered", siblingCount: 1 }, + ), + advanced( + "Wider window", + "siblingCount widens how many pages sit either side of the current one.", + { page: 8, pageSize: 25, total: 900, variant: "numbered", siblingCount: 2 }, + ), + ], + }, +``` + +- [ ] **Step 6: Commit** + +```bash +git add packages/ui/components/Pagination.wrn packages/ui/test/ui.test.ts examples/component-showcase/scripts/showcase-profiles.mjs +git commit -m "feat(ui): build the Pagination component" +``` + +--- + +### Task 4: Stepper component + +**Files:** + +- Modify (replace whole file): `packages/ui/components/Stepper.wrn` +- Test: `packages/ui/test/ui.test.ts` +- Modify: `examples/component-showcase/scripts/showcase-profiles.mjs` + +**Interfaces:** + +- Consumes: the roving contract from Task 1 (`data-wrn-roving`, `data-wrn-roving-item`) when `clickable` is true. +- Produces: `` with output `change({index, step})`. Per-step custom content via `data-slot="step-0"`, `step-1`, … on the mount. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/ui/test/ui.test.ts`: + +```ts +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]")).not.toBeNull(); + expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBe(2); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +bun test packages/ui -t "stepper" +``` + +Expected: FAIL — the scaffold renders a `