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:
@@ -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"
|
||||
>›</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: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user