Tabs was the only component in the library styled with Tailwind utilities, so it could not be themed like the rest and assumed Tailwind was present. It also fired raw CustomEvents instead of declaring outputs, and set a roving tabindex with no keydown handler at all -- which left every inactive tab unreachable by Tab while the arrows did nothing. It now uses wire-* classes and a local style block, declares change and select outputs, and opts into the roving runtime. mode=url mirrors the selection into a query parameter via pushState. Back and forward are handled in the runtime, which activates the matching tab rather than assigning to component state: a popstate listener writing state would be writing after the client function returned, and that write is dropped. The round trip is marked so the component does not push a second history entry for a navigation that came from history. Also anchors Nav submenus so the viewport clamp can pull them back on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
355 lines
9.7 KiB
Plaintext
355 lines
9.7 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-wrn-anchored="true"
|
|
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-wrn-anchored="true"
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|