Every bundled component must expose color and size; the rewrites dropped them, which the library-wide invariant test caught. Rather than re-adding them as dead props, each component now maps color onto an accent variable that its active, current and focus affordances actually use, and size onto the root font scale. Regenerates the component reference, showcase and visual contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
9.6 KiB
Plaintext
350 lines
9.6 KiB
Plaintext
// 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: { item: object; value: string })
|
|
}
|
|
|
|
props {
|
|
color: string = "primary"
|
|
size: string = "default"
|
|
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
|
|
{...attrs}
|
|
data-ui-component="Nav"
|
|
class='wire-nav {class}'
|
|
data-orientation='{orientation}'
|
|
data-color='{color}'
|
|
data-size='{size}'
|
|
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 {
|
|
--nav-accent: var(--wire-color-primary);
|
|
max-width: 100%;
|
|
}
|
|
|
|
.wire-nav[data-color="secondary"] {
|
|
--nav-accent: var(--wire-color-secondary);
|
|
}
|
|
|
|
.wire-nav[data-color="success"] {
|
|
--nav-accent: var(--wire-color-success);
|
|
}
|
|
|
|
.wire-nav[data-color="danger"] {
|
|
--nav-accent: var(--wire-color-danger);
|
|
}
|
|
|
|
.wire-nav[data-color="info"] {
|
|
--nav-accent: var(--wire-color-info);
|
|
}
|
|
|
|
.wire-nav[data-size="sm"] {
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.wire-nav[data-size="lg"] {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.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(--nav-accent);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.wire-nav__link[data-active="true"] {
|
|
background: color-mix(in srgb, var(--nav-accent) 16%, transparent);
|
|
color: var(--nav-accent);
|
|
}
|
|
|
|
.wire-nav__link[aria-disabled="true"] {
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.wire-nav__badge {
|
|
padding: 0.05rem 0.4rem;
|
|
border-radius: 999px;
|
|
background: color-mix(in srgb, var(--nav-accent) 16%, transparent);
|
|
color: var(--nav-accent);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|