feat(ui): build the Nav component with submenus and roving focus

Replaces a scaffold that rendered bare anchors. Flat or nested to three
levels, icons, badges, disabled items, aria-current on the active link,
arrow-key roving focus, and a disclosure arrow that rotates on open.

Three levels rather than arbitrary depth because this template language has
no component recursion, so each level is written out.

On a phone the bar becomes a toggle and submenus stack inline rather than
floating: a hover-opened overlay cannot be reached on touch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:47:20 +05:30
co-authored by Claude Opus 5
parent 45ef035bae
commit f4960f2fc5
3 changed files with 436 additions and 7 deletions
+305 -7
View File
@@ -1,22 +1,320 @@
// Nav -- a navigation link list, flat or with submenus.
//
// <Nav items='[{"label":"Home","href":"/","value":"home"}]' active="home" />
//
// An item carrying its own 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.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Nav {
outputs {
select(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)
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)
select(payload: { item: object; value: string })
}
props {
size: string = "default"
color: string = "primary"
label: string = "Nav"
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 item && Array.isArray(item.items) ? item.items : []
}
shared function isActive(item) {
return Boolean(item.value) && item.value === active
}
shared function rovingAxis() {
return orientation === "vertical" ? "vertical" : "horizontal"
}
client function choose(item) {
if (item.disabled) {
return
}
output.select({ item: item, value: item.value || "" })
}
client function toggleMenu() {
expanded = !expanded
}
}
view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--nav 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}
<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='{rovingAxis()}'>
{#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"
>&#8250;</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"
>&#8250;</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:focus-visible {
outline: 2px solid var(--wire-color-primary);
outline-offset: 2px;
}
.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;
}
}
}
}
+74
View File
@@ -2540,3 +2540,77 @@ test("clickable stepper opts into roving focus", async () => {
expect(dom.querySelector('[data-wrn-roving="horizontal"]')).not.toBeNull();
expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBe(2);
});
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");
// Every item renders a badge span; the ones without a badge are empty and
// carry data-show="false", so match on content rather than first-in-document.
const badges = [...dom.querySelectorAll(".wire-nav__badge")]
.map((node) => node.textContent!.trim())
.filter(Boolean);
expect(badges).toContain("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="horizontal"]')).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();
expect(dom.querySelector(".wire-nav__submenu--level3")).not.toBeNull();
// Every second-level item renders a third-level list; only the one whose
// item actually has children is populated, so search across them all.
const thirdLevel = [...dom.querySelectorAll(".wire-nav__submenu--level3")]
.map((node) => node.textContent!.trim())
.filter(Boolean);
expect(thirdLevel.join(" ")).toContain("Deep");
});
test("nav renders its shell with no items and rejects a non-array", async () => {
const source = readFileSync(uiComponentPath("Nav"), "utf8");
const html = await renderComponent(source, { items: [], active: "" });
const dom = mountHtml(html);
expect(dom.querySelector(".wire-nav")).not.toBeNull();
expect(dom.querySelectorAll(".wire-nav__link").length).toBe(0);
// A declared unknown[] prop is coerced by the framework, which rejects a
// non-array before the component runs. The itemList() guard is the second
// line of defence for a missing prop, not a way to render junk.
await expect(renderComponent(source, { items: "not-an-array", active: "" })).rejects.toThrow(
"Expected an array prop",
);
});