MegaMenu replaces a scaffold with a trigger and a wide panel of grouped link columns. One level deep on purpose: a mega menu exists to show breadth flat so everything is one click away, and nesting inside the panel buries content behind hover-within-hover. Nav is the component for cascading submenus. The panel is anchored so the runtime clamp keeps it inside the viewport. Sidebar now composes Drawer for its off-canvas presentation instead of a hand-rolled backdrop, inheriting the focus trap and scroll lock from one place. Single items, labelled groups and branches nested to three levels, with vertical roving focus. Sidebar classes move to the BEM naming the rest of the library uses, which is a breaking change; nesting via children still works, since that is what shipped in 0.8.5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
336 lines
9.5 KiB
Plaintext
336 lines
9.5 KiB
Plaintext
// Sidebar -- a vertical navigation rail that becomes a drawer on small screens.
|
|
//
|
|
// <Sidebar items='[{"label":"Dashboard","href":"/","value":"dash"}]' active="dash" />
|
|
//
|
|
// An entry is one of three shapes:
|
|
// { label, href, value } a single link
|
|
// { heading, items: [...] } a labelled group
|
|
// { label, items: [...] } a collapsible branch, nested to 3 levels
|
|
//
|
|
// The off-canvas presentation is Drawer rather than a second implementation,
|
|
// so the focus trap and the body scroll lock come from one place.
|
|
//
|
|
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
|
// and silently swallows the rule that follows it.
|
|
import Drawer from "./Drawer.wrn"
|
|
|
|
component Sidebar {
|
|
outputs {
|
|
toggle(payload: { open: boolean })
|
|
open(payload: { sourceEvent: Event })
|
|
close(payload: { source: string })
|
|
select(payload: { item: object; value: string; level: number })
|
|
}
|
|
|
|
props {
|
|
color: string = "primary"
|
|
size: string = "default"
|
|
label: string = "Sidebar"
|
|
items: unknown[] = []
|
|
active: string = ""
|
|
mobileLabel: string = "Open navigation"
|
|
drawerTitle: string = "Navigation"
|
|
class: string = ""
|
|
}
|
|
|
|
state drawerOpen = false
|
|
|
|
functions {
|
|
shared function entryList() {
|
|
return Array.isArray(items) ? items : []
|
|
}
|
|
|
|
// Accepts children as well as items: children is what shipped in 0.8.5
|
|
// and existing callers should keep working.
|
|
shared function childrenOf(entry) {
|
|
if (!entry) {
|
|
return []
|
|
}
|
|
if (Array.isArray(entry.items)) {
|
|
return entry.items
|
|
}
|
|
return Array.isArray(entry.children) ? entry.children : []
|
|
}
|
|
|
|
shared function isGroup(entry) {
|
|
return Boolean(entry.heading)
|
|
}
|
|
|
|
shared function isActive(entry) {
|
|
return Boolean(entry.value) && entry.value === active
|
|
}
|
|
|
|
client function choose(entry, level) {
|
|
if (entry.disabled) {
|
|
return
|
|
}
|
|
output.select({ item: entry, value: entry.value || "", level: level })
|
|
if (drawerOpen) {
|
|
drawerOpen = false
|
|
output.close({ source: "select" })
|
|
}
|
|
}
|
|
|
|
client function openDrawer(sourceEvent) {
|
|
drawerOpen = true
|
|
output.open({ sourceEvent: sourceEvent })
|
|
output.toggle({ open: true })
|
|
}
|
|
|
|
client function closeDrawer() {
|
|
drawerOpen = false
|
|
output.close({ source: "drawer" })
|
|
output.toggle({ open: false })
|
|
}
|
|
}
|
|
|
|
view {
|
|
<div
|
|
{...attrs}
|
|
data-ui-component="Sidebar"
|
|
class='wire-sidebar {class}'
|
|
data-color='{color}'
|
|
data-size='{size}'
|
|
>
|
|
<button
|
|
type="button"
|
|
class="wire-sidebar__launcher"
|
|
aria-label='{mobileLabel}'
|
|
aria-expanded='{drawerOpen}'
|
|
@click='openDrawer(event)'
|
|
>
|
|
<span class="wire-sidebar__launcher-bar" aria-hidden="true"></span>
|
|
<span class="wire-sidebar__launcher-text">{mobileLabel}</span>
|
|
</button>
|
|
|
|
<nav class="wire-sidebar__rail" aria-label='{label}'>
|
|
<ul class="wire-sidebar__list" data-wrn-roving="vertical">
|
|
{#each entryList() as entry}
|
|
<li class="wire-sidebar__entry" data-group='{isGroup(entry)}'>
|
|
<p class="wire-sidebar__heading" data-show="entry.heading">{entry.heading}</p>
|
|
|
|
<a
|
|
class="wire-sidebar__link"
|
|
data-show="!entry.heading"
|
|
href='{entry.href || "#"}'
|
|
data-wrn-roving-item="true"
|
|
data-active='{isActive(entry)}'
|
|
aria-current='{isActive(entry) ? "page" : "false"}'
|
|
aria-disabled='{entry.disabled ? "true" : "false"}'
|
|
@click='choose(entry, 1)'
|
|
>
|
|
<span
|
|
class='wire-sidebar__icon {entry.icon}'
|
|
data-show="entry.icon"
|
|
aria-hidden="true"
|
|
></span>
|
|
<span class="wire-sidebar__label">{entry.label}</span>
|
|
<span class="wire-sidebar__badge" data-show="entry.badge">{entry.badge}</span>
|
|
</a>
|
|
|
|
<ul class="wire-sidebar__sublist" data-show="childrenOf(entry).length > 0">
|
|
{#each childrenOf(entry) as child}
|
|
<li class="wire-sidebar__entry">
|
|
<a
|
|
class="wire-sidebar__link"
|
|
href='{child.href || "#"}'
|
|
data-active='{isActive(child)}'
|
|
aria-current='{isActive(child) ? "page" : "false"}'
|
|
aria-disabled='{child.disabled ? "true" : "false"}'
|
|
@click='choose(child, 2)'
|
|
>
|
|
<span
|
|
class='wire-sidebar__icon {child.icon}'
|
|
data-show="child.icon"
|
|
aria-hidden="true"
|
|
></span>
|
|
<span class="wire-sidebar__label">{child.label}</span>
|
|
<span class="wire-sidebar__badge" data-show="child.badge">{child.badge}</span>
|
|
</a>
|
|
|
|
<ul
|
|
class="wire-sidebar__sublist wire-sidebar__sublist--level3"
|
|
data-show="childrenOf(child).length > 0"
|
|
>
|
|
{#each childrenOf(child) as leaf}
|
|
<li class="wire-sidebar__entry">
|
|
<a
|
|
class="wire-sidebar__link"
|
|
href='{leaf.href || "#"}'
|
|
data-active='{isActive(leaf)}'
|
|
aria-current='{isActive(leaf) ? "page" : "false"}'
|
|
@click='choose(leaf, 3)'
|
|
>
|
|
<span class="wire-sidebar__label">{leaf.label}</span>
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
<slot />
|
|
</nav>
|
|
|
|
<Drawer
|
|
open={drawerOpen}
|
|
placement="left"
|
|
title={drawerTitle}
|
|
label={label}
|
|
@close="closeDrawer()"
|
|
>
|
|
<slot name="drawer"></slot>
|
|
</Drawer>
|
|
</div>
|
|
}
|
|
|
|
style {
|
|
.wire-sidebar {
|
|
--sidebar-accent: var(--wire-color-primary);
|
|
max-width: 100%;
|
|
}
|
|
|
|
.wire-sidebar[data-color="secondary"] {
|
|
--sidebar-accent: var(--wire-color-secondary);
|
|
}
|
|
|
|
.wire-sidebar[data-color="success"] {
|
|
--sidebar-accent: var(--wire-color-success);
|
|
}
|
|
|
|
.wire-sidebar[data-color="danger"] {
|
|
--sidebar-accent: var(--wire-color-danger);
|
|
}
|
|
|
|
.wire-sidebar[data-color="info"] {
|
|
--sidebar-accent: var(--wire-color-info);
|
|
}
|
|
|
|
.wire-sidebar[data-size="sm"] {
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.wire-sidebar[data-size="lg"] {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.wire-sidebar__rail {
|
|
width: 100%;
|
|
}
|
|
|
|
.wire-sidebar__list,
|
|
.wire-sidebar__sublist {
|
|
display: grid;
|
|
gap: 0.1rem;
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
|
|
.wire-sidebar__sublist {
|
|
margin-left: 0.85rem;
|
|
padding-left: 0.5rem;
|
|
border-left: 1px solid var(--wire-color-border);
|
|
}
|
|
|
|
.wire-sidebar__entry[data-group="true"] + .wire-sidebar__entry {
|
|
margin-top: 0.35rem;
|
|
}
|
|
|
|
.wire-sidebar__heading {
|
|
margin: 0.9rem 0 0.35rem;
|
|
color: var(--wire-color-text-muted);
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.wire-sidebar__link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.45rem 0.6rem;
|
|
border-radius: var(--wire-radius-sm);
|
|
color: var(--wire-color-text-muted);
|
|
font-size: 0.88rem;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.wire-sidebar__link:hover {
|
|
background: var(--wire-color-surface-soft);
|
|
color: var(--wire-color-text);
|
|
}
|
|
|
|
.wire-sidebar__link:focus-visible {
|
|
outline: 2px solid var(--sidebar-accent);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.wire-sidebar__link[data-active="true"] {
|
|
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
|
|
color: var(--sidebar-accent);
|
|
}
|
|
|
|
.wire-sidebar__link[aria-disabled="true"] {
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.wire-sidebar__label {
|
|
min-width: 0;
|
|
}
|
|
|
|
.wire-sidebar__badge {
|
|
margin-left: auto;
|
|
padding: 0.05rem 0.4rem;
|
|
border-radius: 999px;
|
|
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
|
|
color: var(--sidebar-accent);
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
.wire-sidebar__launcher {
|
|
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-sidebar__launcher-bar {
|
|
width: 1rem;
|
|
height: 2px;
|
|
background: currentColor;
|
|
box-shadow:
|
|
0 -5px 0 currentColor,
|
|
0 5px 0 currentColor;
|
|
}
|
|
|
|
/*
|
|
* Below the tablet breakpoint the rail is replaced by the drawer launcher.
|
|
* A permanent rail eats most of a phone screen, and Drawer already handles
|
|
* the focus trap and the scroll lock.
|
|
*/
|
|
@media (max-width: 767px) {
|
|
.wire-sidebar__launcher {
|
|
display: inline-flex;
|
|
}
|
|
|
|
.wire-sidebar__rail {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
}
|