1666 lines
53 KiB
Markdown
1666 lines
53 KiB
Markdown
# 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(
|
||
`<div data-wrn-roving="horizontal">
|
||
<button data-wrn-roving-item id="a">A</button>
|
||
<button data-wrn-roving-item id="b">B</button>
|
||
<button data-wrn-roving-item id="c">C</button>
|
||
</div>`,
|
||
);
|
||
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(
|
||
`<div data-wrn-roving="vertical">
|
||
<button data-wrn-roving-item id="a">A</button>
|
||
<button data-wrn-roving-item id="b" disabled>B</button>
|
||
<button data-wrn-roving-item id="c">C</button>
|
||
</div>`,
|
||
);
|
||
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(
|
||
`<div data-wrn-roving="horizontal">
|
||
<button data-wrn-roving-item id="a">A</button>
|
||
<button data-wrn-roving-item id="b">B</button>
|
||
</div>`,
|
||
);
|
||
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(
|
||
`<div data-wrn-roving="horizontal">
|
||
<button data-wrn-roving-item id="a">A</button>
|
||
<button data-wrn-roving-item id="b" aria-selected="true">B</button>
|
||
</div>`,
|
||
);
|
||
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(
|
||
`<div data-wrn-roving="horizontal" id="outer">
|
||
<button data-wrn-roving-item id="a">A</button>
|
||
<div data-wrn-roving="vertical" id="inner">
|
||
<button data-wrn-roving-item id="x">X</button>
|
||
<button data-wrn-roving-item id="y">Y</button>
|
||
</div>
|
||
<button data-wrn-roving-item id="b">B</button>
|
||
</div>`,
|
||
);
|
||
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(
|
||
`<div>
|
||
<button id="outside">Outside</button>
|
||
<div data-show="true">
|
||
<section role="dialog" aria-modal="true" tabindex="-1">
|
||
<button id="first">First</button>
|
||
<button id="last">Last</button>
|
||
</section>
|
||
</div>
|
||
</div>`,
|
||
);
|
||
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(
|
||
`<div>
|
||
<button id="outside">Outside</button>
|
||
<div data-show="false">
|
||
<section role="dialog" aria-modal="true" tabindex="-1">
|
||
<button id="first">First</button>
|
||
</section>
|
||
</div>
|
||
</div>`,
|
||
);
|
||
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: `<Pagination page={n} pageSize={n} total={n} variant="compact"|"numbered" siblingCount={n} />` 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.
|
||
//
|
||
// <Pagination page={2} pageSize={10} total={137} variant="numbered" />
|
||
//
|
||
// 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 {
|
||
<nav
|
||
{...attrs}
|
||
data-ui-component="Pagination"
|
||
class='wire-pagination {class}'
|
||
data-variant='{variant}'
|
||
role="navigation"
|
||
aria-label='{label}'
|
||
>
|
||
<p class="wire-pagination__summary" data-show="showSummary">
|
||
{firstShown()} to {lastShown()} of {total}
|
||
</p>
|
||
|
||
<div class="wire-pagination__controls">
|
||
<button
|
||
type="button"
|
||
class="wire-pagination__step"
|
||
aria-label='{previousLabel}'
|
||
@click='goPrevious()'
|
||
>
|
||
<span class="wire-pagination__step-icon" aria-hidden="true">‹</span>
|
||
<span class="wire-pagination__step-label">{previousLabel}</span>
|
||
</button>
|
||
|
||
<ol class="wire-pagination__pages" data-show="variant === 'numbered'">
|
||
{#each pageNumbers() as entry}
|
||
<li>
|
||
<span class="wire-pagination__gap" data-show="entry.gap">{entry.label}</span>
|
||
<button
|
||
type="button"
|
||
class="wire-pagination__page"
|
||
data-show="!entry.gap"
|
||
data-active='{entry.value === currentPage()}'
|
||
aria-current='{entry.value === currentPage() ? "page" : "false"}'
|
||
@click='goToPage(entry.value)'
|
||
>
|
||
{entry.label}
|
||
</button>
|
||
</li>
|
||
{/each}
|
||
</ol>
|
||
|
||
<p class="wire-pagination__compact" data-show="variant !== 'numbered'">
|
||
{currentPage()} / {lastPage()}
|
||
</p>
|
||
|
||
<button
|
||
type="button"
|
||
class="wire-pagination__step"
|
||
aria-label='{nextLabel}'
|
||
@click='goNext()'
|
||
>
|
||
<span class="wire-pagination__step-label">{nextLabel}</span>
|
||
<span class="wire-pagination__step-icon" aria-hidden="true">›</span>
|
||
</button>
|
||
</div>
|
||
|
||
<slot />
|
||
</nav>
|
||
}
|
||
|
||
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: `<Stepper steps={[...]} active={n} orientation="horizontal"|"vertical" clickable={bool} />` 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 `<nav>` of anchors with no `.wire-stepper__step`.
|
||
|
||
- [ ] **Step 3: Replace `packages/ui/components/Stepper.wrn`**
|
||
|
||
```
|
||
// 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>
|
||
component Stepper {
|
||
outputs {
|
||
change(payload: { index: number; step: object })
|
||
}
|
||
|
||
props {
|
||
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"
|
||
}
|
||
|
||
client function selectStep(index, step) {
|
||
if (!clickable) {
|
||
return
|
||
}
|
||
output.change({ index: index, step: step })
|
||
}
|
||
}
|
||
|
||
view {
|
||
<ol
|
||
{...attrs}
|
||
data-ui-component="Stepper"
|
||
class='wire-stepper {class}'
|
||
data-orientation='{orientation}'
|
||
data-clickable='{clickable}'
|
||
data-wrn-roving='{clickable ? (orientation === "vertical" ? "vertical" : "horizontal") : ""}'
|
||
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}'
|
||
@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 />
|
||
</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__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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
```bash
|
||
bun test packages/ui -t "stepper"
|
||
```
|
||
|
||
Expected: PASS, all three tests.
|
||
|
||
- [ ] **Step 5: Add the showcase profile**
|
||
|
||
```js
|
||
Stepper: {
|
||
demos: [
|
||
standard(
|
||
"Horizontal progress",
|
||
"Steps before the active one read as complete, the active one is highlighted, and the rest are muted.",
|
||
{
|
||
steps: [
|
||
{ label: "Account", description: "Your details" },
|
||
{ label: "Billing", description: "Payment method" },
|
||
{ label: "Confirm", description: "Review and submit" },
|
||
],
|
||
active: 1,
|
||
},
|
||
),
|
||
standard(
|
||
"Vertical with icons",
|
||
"Vertical orientation suits a sidebar or a narrow column. Any step may carry an iconify class instead of its number.",
|
||
{
|
||
steps: [
|
||
{ label: "Cloned", icon: "icon-[lucide--git-branch]" },
|
||
{ label: "Built", icon: "icon-[lucide--hammer]" },
|
||
{ label: "Deployed", icon: "icon-[lucide--rocket]" },
|
||
],
|
||
active: 2,
|
||
orientation: "vertical",
|
||
},
|
||
),
|
||
advanced(
|
||
"Clickable steps",
|
||
"With clickable the steps become buttons, emit a change output, and take arrow-key roving focus.",
|
||
{
|
||
steps: [{ label: "One" }, { label: "Two" }, { label: "Three" }],
|
||
active: 0,
|
||
clickable: true,
|
||
},
|
||
),
|
||
],
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/ui/components/Stepper.wrn packages/ui/test/ui.test.ts examples/component-showcase/scripts/showcase-profiles.mjs
|
||
git commit -m "feat(ui): build the Stepper component"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Nav component
|
||
|
||
The largest of the three: multi-level links to a fixed depth of 3, roving focus, animated disclosure arrows, and a mobile toggle.
|
||
|
||
**Files:**
|
||
|
||
- Modify (replace whole file): `packages/ui/components/Nav.wrn`
|
||
- Test: `packages/ui/test/ui.test.ts`
|
||
- Modify: `examples/component-showcase/scripts/showcase-profiles.mjs`
|
||
|
||
**Interfaces:**
|
||
|
||
- Consumes: the roving contract from Task 1.
|
||
- Produces: `<Nav items={[...]} active="value" orientation="horizontal"|"vertical" />` with output `select({item, value})`. Item shape: `{label, href, value, icon, badge, disabled, items}` where a nested `items` array creates a submenu, honoured to 3 levels.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `packages/ui/test/ui.test.ts`:
|
||
|
||
```ts
|
||
test("nav renders links, marks the active one, and shows icons and badges", async () => {
|
||
const source = readFileSync(uiComponentPath("Nav"), "utf8");
|
||
const html = await renderComponent(source, {
|
||
items: [
|
||
{ label: "Home", href: "/", value: "home", icon: "icon-[lucide--house]" },
|
||
{ label: "Inbox", href: "/inbox", value: "inbox", badge: "9" },
|
||
{ label: "Archive", href: "/archive", value: "archive", disabled: true },
|
||
],
|
||
active: "inbox",
|
||
});
|
||
const dom = mountHtml(html);
|
||
|
||
expect(dom.querySelector(".wire-nav")!.getAttribute("role")).toBe("navigation");
|
||
const current = dom.querySelector('[aria-current="page"]');
|
||
expect(current!.textContent).toContain("Inbox");
|
||
expect(dom.querySelector(".wire-nav__badge")!.textContent!.trim()).toBe("9");
|
||
expect(dom.querySelector(".wire-nav__icon")).not.toBeNull();
|
||
expect(dom.querySelector('[aria-disabled="true"]')).not.toBeNull();
|
||
});
|
||
|
||
test("nav renders a submenu with a disclosure arrow and opts into roving focus", async () => {
|
||
const source = readFileSync(uiComponentPath("Nav"), "utf8");
|
||
const html = await renderComponent(source, {
|
||
items: [
|
||
{
|
||
label: "Products",
|
||
value: "products",
|
||
items: [
|
||
{ label: "Overview", href: "/p", value: "p-overview" },
|
||
{
|
||
label: "More",
|
||
value: "p-more",
|
||
items: [{ label: "Deep", href: "/d", value: "deep" }],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
active: "p-overview",
|
||
});
|
||
const dom = mountHtml(html);
|
||
|
||
expect(dom.querySelector("[data-wrn-roving]")).not.toBeNull();
|
||
expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBeGreaterThan(0);
|
||
expect(dom.querySelector(".wire-nav__arrow")).not.toBeNull();
|
||
expect(dom.querySelector(".wire-nav__submenu")).not.toBeNull();
|
||
// Third level is rendered, and nothing deeper is attempted.
|
||
expect(dom.querySelector(".wire-nav__submenu--level3")).not.toBeNull();
|
||
expect(dom.body.innerHTML).toContain("Deep");
|
||
});
|
||
|
||
test("nav renders empty rather than throwing when items is not an array", async () => {
|
||
const source = readFileSync(uiComponentPath("Nav"), "utf8");
|
||
const html = await renderComponent(source, { items: "not-an-array", active: "" });
|
||
const dom = mountHtml(html);
|
||
expect(dom.querySelector(".wire-nav")).not.toBeNull();
|
||
expect(dom.querySelectorAll(".wire-nav__link").length).toBe(0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
```bash
|
||
bun test packages/ui -t "nav "
|
||
```
|
||
|
||
Expected: FAIL — the scaffold renders bare anchors with no `.wire-nav__link`, no submenu and no roving attributes.
|
||
|
||
- [ ] **Step 3: Replace `packages/ui/components/Nav.wrn`**
|
||
|
||
```
|
||
// Nav -- a navigation link list, flat or with submenus.
|
||
//
|
||
// <Nav items='[{"label":"Home","href":"/","value":"home"}]' active="home" />
|
||
//
|
||
// An item with a nested items array becomes a submenu. Depth is capped at
|
||
// three levels: this template language has no component recursion, so each
|
||
// level is written out, and three covers any realistic navigation.
|
||
component Nav {
|
||
outputs {
|
||
select(payload: { item: object; value: string })
|
||
}
|
||
|
||
props {
|
||
items: unknown[] = []
|
||
active: string = ""
|
||
orientation: string = "horizontal"
|
||
label: string = "Main"
|
||
collapsible: boolean = true
|
||
toggleLabel: string = "Menu"
|
||
class: string = ""
|
||
}
|
||
|
||
state expanded = false
|
||
|
||
functions {
|
||
shared function itemList() {
|
||
return Array.isArray(items) ? items : []
|
||
}
|
||
|
||
shared function childrenOf(item) {
|
||
return Array.isArray(item.items) ? item.items : []
|
||
}
|
||
|
||
shared function isActive(item) {
|
||
return Boolean(item.value) && item.value === active
|
||
}
|
||
|
||
client function choose(item) {
|
||
if (item.disabled) {
|
||
return
|
||
}
|
||
output.select({ item: item, value: item.value || "" })
|
||
}
|
||
|
||
client function toggleMenu() {
|
||
expanded = !expanded
|
||
}
|
||
}
|
||
|
||
view {
|
||
<nav
|
||
{...attrs}
|
||
data-ui-component="Nav"
|
||
class='wire-nav {class}'
|
||
data-orientation='{orientation}'
|
||
data-expanded='{expanded}'
|
||
role="navigation"
|
||
aria-label='{label}'
|
||
>
|
||
<button
|
||
type="button"
|
||
class="wire-nav__toggle"
|
||
data-show="collapsible"
|
||
aria-expanded='{expanded}'
|
||
aria-label='{toggleLabel}'
|
||
@click='toggleMenu()'
|
||
>
|
||
<span class="wire-nav__toggle-bar" aria-hidden="true"></span>
|
||
<span class="wire-nav__toggle-text">{toggleLabel}</span>
|
||
</button>
|
||
|
||
<ul
|
||
class="wire-nav__list"
|
||
data-wrn-roving='{orientation === "vertical" ? "vertical" : "horizontal"}'
|
||
>
|
||
{#each itemList() as item}
|
||
<li class="wire-nav__item" data-has-children='{childrenOf(item).length > 0}'>
|
||
<a
|
||
class="wire-nav__link"
|
||
href='{item.href || "#"}'
|
||
data-wrn-roving-item="true"
|
||
data-active='{isActive(item)}'
|
||
aria-current='{isActive(item) ? "page" : "false"}'
|
||
aria-disabled='{item.disabled ? "true" : "false"}'
|
||
@click='choose(item)'
|
||
>
|
||
<span class='wire-nav__icon {item.icon}' data-show="item.icon" aria-hidden="true"></span>
|
||
<span class="wire-nav__label">{item.label}</span>
|
||
<span class="wire-nav__badge" data-show="item.badge">{item.badge}</span>
|
||
<span
|
||
class="wire-nav__arrow"
|
||
data-show="childrenOf(item).length > 0"
|
||
aria-hidden="true"
|
||
>›</span>
|
||
</a>
|
||
|
||
<ul class="wire-nav__submenu" data-show="childrenOf(item).length > 0">
|
||
{#each childrenOf(item) as child}
|
||
<li class="wire-nav__item" data-has-children='{childrenOf(child).length > 0}'>
|
||
<a
|
||
class="wire-nav__link"
|
||
href='{child.href || "#"}'
|
||
data-active='{isActive(child)}'
|
||
aria-current='{isActive(child) ? "page" : "false"}'
|
||
aria-disabled='{child.disabled ? "true" : "false"}'
|
||
@click='choose(child)'
|
||
>
|
||
<span class='wire-nav__icon {child.icon}' data-show="child.icon" aria-hidden="true"></span>
|
||
<span class="wire-nav__label">{child.label}</span>
|
||
<span class="wire-nav__badge" data-show="child.badge">{child.badge}</span>
|
||
<span
|
||
class="wire-nav__arrow"
|
||
data-show="childrenOf(child).length > 0"
|
||
aria-hidden="true"
|
||
>›</span>
|
||
</a>
|
||
|
||
<ul
|
||
class="wire-nav__submenu wire-nav__submenu--level3"
|
||
data-show="childrenOf(child).length > 0"
|
||
>
|
||
{#each childrenOf(child) as leaf}
|
||
<li class="wire-nav__item">
|
||
<a
|
||
class="wire-nav__link"
|
||
href='{leaf.href || "#"}'
|
||
data-active='{isActive(leaf)}'
|
||
aria-current='{isActive(leaf) ? "page" : "false"}'
|
||
aria-disabled='{leaf.disabled ? "true" : "false"}'
|
||
@click='choose(leaf)'
|
||
>
|
||
<span class='wire-nav__icon {leaf.icon}' data-show="leaf.icon" aria-hidden="true"></span>
|
||
<span class="wire-nav__label">{leaf.label}</span>
|
||
</a>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
|
||
<slot />
|
||
</nav>
|
||
}
|
||
|
||
style {
|
||
.wire-nav {
|
||
max-width: 100%;
|
||
}
|
||
|
||
.wire-nav__list {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
margin: 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
}
|
||
|
||
.wire-nav[data-orientation="vertical"] .wire-nav__list {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.wire-nav__item {
|
||
position: relative;
|
||
}
|
||
|
||
.wire-nav__link {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
padding: 0.45rem 0.7rem;
|
||
border-radius: var(--wire-radius-sm);
|
||
color: var(--wire-color-text-muted);
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.wire-nav__link:hover {
|
||
background: var(--wire-color-surface-soft);
|
||
color: var(--wire-color-text);
|
||
}
|
||
|
||
.wire-nav__link[data-active="true"] {
|
||
background: var(--wire-color-primary-soft);
|
||
color: var(--wire-color-primary);
|
||
}
|
||
|
||
.wire-nav__link[aria-disabled="true"] {
|
||
opacity: 0.5;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.wire-nav__badge {
|
||
padding: 0.05rem 0.4rem;
|
||
border-radius: 999px;
|
||
background: var(--wire-color-primary-soft);
|
||
color: var(--wire-color-primary);
|
||
font-size: 0.72rem;
|
||
}
|
||
|
||
.wire-nav__arrow {
|
||
display: inline-block;
|
||
transition: transform 160ms ease;
|
||
}
|
||
|
||
.wire-nav__item:hover > .wire-nav__link > .wire-nav__arrow,
|
||
.wire-nav__item:focus-within > .wire-nav__link > .wire-nav__arrow {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.wire-nav__submenu {
|
||
position: absolute;
|
||
z-index: 30;
|
||
top: 100%;
|
||
left: 0;
|
||
min-width: 12rem;
|
||
margin: 0;
|
||
padding: 0.35rem;
|
||
border: 1px solid var(--wire-color-border);
|
||
border-radius: var(--wire-radius-md);
|
||
background: var(--wire-color-surface);
|
||
box-shadow: var(--wire-shadow-1);
|
||
list-style: none;
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transition: opacity 160ms ease;
|
||
}
|
||
|
||
.wire-nav__item:hover > .wire-nav__submenu,
|
||
.wire-nav__item:focus-within > .wire-nav__submenu {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
}
|
||
|
||
.wire-nav__submenu--level3 {
|
||
top: 0;
|
||
left: 100%;
|
||
}
|
||
|
||
.wire-nav__toggle {
|
||
display: none;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.45rem 0.7rem;
|
||
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.9rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.wire-nav__toggle-bar {
|
||
width: 1rem;
|
||
height: 2px;
|
||
background: currentColor;
|
||
box-shadow: 0 -5px 0 currentColor, 0 5px 0 currentColor;
|
||
}
|
||
|
||
/*
|
||
* On a phone the bar becomes a disclosure: submenus stop being floating
|
||
* overlays and stack inline, because a hover-opened overlay is
|
||
* unreachable on touch.
|
||
*/
|
||
@media (max-width: 767px) {
|
||
.wire-nav__toggle {
|
||
display: inline-flex;
|
||
}
|
||
|
||
.wire-nav__list {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.wire-nav[data-expanded="false"] .wire-nav__list {
|
||
display: none;
|
||
}
|
||
|
||
.wire-nav__submenu,
|
||
.wire-nav__submenu--level3 {
|
||
position: static;
|
||
opacity: 1;
|
||
visibility: visible;
|
||
border: 0;
|
||
box-shadow: none;
|
||
padding-left: 1rem;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
```bash
|
||
bun test packages/ui -t "nav "
|
||
```
|
||
|
||
Expected: PASS, all three tests.
|
||
|
||
- [ ] **Step 5: Add the showcase profile**
|
||
|
||
```js
|
||
Nav: {
|
||
demos: [
|
||
standard(
|
||
"Horizontal links",
|
||
"A flat link bar. The active item is marked with aria-current, and arrow keys move between items.",
|
||
{
|
||
items: [
|
||
{ label: "Home", href: "/", value: "home", icon: "icon-[lucide--house]" },
|
||
{ label: "Inbox", href: "/inbox", value: "inbox", badge: "9" },
|
||
{ label: "Reports", href: "/reports", value: "reports" },
|
||
{ label: "Archive", href: "/archive", value: "archive", disabled: true },
|
||
],
|
||
active: "inbox",
|
||
},
|
||
),
|
||
advanced(
|
||
"Nested submenus",
|
||
"An item carrying its own items array opens a submenu on hover or focus, to a maximum of three levels.",
|
||
{
|
||
items: [
|
||
{ label: "Home", href: "/", value: "home" },
|
||
{
|
||
label: "Products",
|
||
value: "products",
|
||
items: [
|
||
{ label: "Overview", href: "/p", value: "p-overview" },
|
||
{
|
||
label: "Platform",
|
||
value: "p-platform",
|
||
items: [
|
||
{ label: "Runtime", href: "/p/runtime", value: "runtime" },
|
||
{ label: "Compiler", href: "/p/compiler", value: "compiler" },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
active: "p-overview",
|
||
},
|
||
),
|
||
standard(
|
||
"Vertical rail",
|
||
"Vertical orientation switches the arrow keys to up and down.",
|
||
{
|
||
items: [
|
||
{ label: "Dashboard", href: "/", value: "dash", icon: "icon-[lucide--gauge]" },
|
||
{ label: "Team", href: "/team", value: "team", icon: "icon-[lucide--users]" },
|
||
{ label: "Settings", href: "/settings", value: "settings", icon: "icon-[lucide--settings]" },
|
||
],
|
||
active: "team",
|
||
orientation: "vertical",
|
||
collapsible: false,
|
||
},
|
||
),
|
||
],
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/ui/components/Nav.wrn packages/ui/test/ui.test.ts examples/component-showcase/scripts/showcase-profiles.mjs
|
||
git commit -m "feat(ui): build the Nav component with submenus and roving focus"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Regenerate artifacts and close the phase
|
||
|
||
**Files:**
|
||
|
||
- Modify: `packages/ui/component-reference.json`, `packages/ui/component-catalog.json`, `packages/ui/COMPONENTS.md` (generated)
|
||
- Modify: `examples/component-showcase/**` (generated)
|
||
- Modify: `docs/ui-visual-contract-0.8.json` (generated)
|
||
|
||
**Interfaces:**
|
||
|
||
- Consumes: the three finished components from Tasks 3–5.
|
||
- Produces: nothing new; brings generated artifacts back in step.
|
||
|
||
- [ ] **Step 1: Regenerate the reference and showcase**
|
||
|
||
```bash
|
||
node scripts/generate-ui-component-reference.mjs
|
||
```
|
||
|
||
```bash
|
||
node examples/component-showcase/scripts/generate-showcase.mjs
|
||
```
|
||
|
||
- [ ] **Step 2: Regenerate the visual contract**
|
||
|
||
```bash
|
||
bun run generate:ui-visual
|
||
```
|
||
|
||
- [ ] **Step 3: Format**
|
||
|
||
```bash
|
||
bun run format
|
||
```
|
||
|
||
- [ ] **Step 4: Run the full production gate**
|
||
|
||
```bash
|
||
bun run check:production
|
||
```
|
||
|
||
Expected: PASS. If `PERF-RUNTIME-SIZE-REACTIVE` fails, shrink the roving code rather than raising the ceiling.
|
||
|
||
- [ ] **Step 5: Commit and push**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore(ui): regenerate reference and showcase for navigation phase 1"
|
||
```
|
||
|
||
```bash
|
||
git push
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage for phase 1.** Roving focus in the runtime — Task 1. Nav multi-level to depth 3, icons, badges, animated arrows, responsive collapse, `aria-current` — Task 5. Pagination standalone with compact and numbered variants and windowed numbers — Task 3. Stepper horizontal/vertical with indexed named slots and `aria-current="step"` — Task 4. Local `style {}` blocks with `wire-*` classes — Tasks 3, 4, 5. Error handling for non-array `items` and out-of-range `active`/`page` — covered by a test in each of Tasks 3, 4, 5. Showcase profiles and regeneration — Tasks 3–6. Runtime budget check — Tasks 1 and 6.
|
||
|
||
Out of phase 1 by design, carried to later plans: Tabs, Sidebar, MegaMenu (phase 2); Navbar, Breadcrumb, Scrollspy, responsive sweep (phase 3).
|
||
|
||
**Added beyond the spec.** Task 2 fixes the untestable `isDialogVisible` shipped in 0.8.5. It belongs here because the roving code faces the identical happy-dom trap, and fixing both under one rule keeps them consistent.
|
||
|
||
**Type consistency.** `data-wrn-roving` / `data-wrn-roving-item` are spelled identically in Task 1 and consumed identically in Tasks 4 and 5. `pageNumbers()` returns `{value, label, gap}` in Task 3 and the view reads exactly those three fields. `statusFor(index)` returns `"complete" | "current" | "upcoming"` in Task 4 and the tests and CSS assert those three values. `childrenOf(item)` returns an array at all three Nav levels.
|