feat(ui): build MegaMenu and rebuild Sidebar on Drawer

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>
This commit is contained in:
2026-08-07 17:24:03 +05:30
co-authored by Claude Opus 5
parent b4d3cb3695
commit f1d1081b67
13 changed files with 1151 additions and 888 deletions
+318 -12
View File
@@ -1,23 +1,329 @@
// MegaMenu -- a trigger and a wide panel of grouped links.
//
// <MegaMenu label="Products" columns='[{"heading":"Platform",
// "items":[{"label":"Runtime","href":"/runtime"}]}]' />
//
// Deliberately one level deep. A mega menu exists to show breadth flat, so
// everything is one click away; nesting inside the panel buries content behind
// hover-within-hover and is close to unusable by keyboard and touch. Use Nav
// when you actually want cascading submenus.
//
// The panel carries data-wrn-anchored so the runtime clamp keeps it inside the
// viewport instead of hanging off the edge of a wide layout.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component MegaMenu {
outputs {
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
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)
open(payload: { sourceEvent: Event })
close(payload: { reason: string })
select(payload: { item: object; value: string })
}
props {
size: string = "default"
color: string = "primary"
label: string = "Mega Menu"
items: unknown[] = []
active: string = ""
orientation: string = "horizontal"
size: string = "default"
label: string = "Menu"
icon: string = ""
columns: unknown[] = []
footer: string = ""
defaultOpen: boolean = false
class: string = ""
}
state visible = defaultOpen
functions {
shared function columnList() {
return Array.isArray(columns) ? columns : []
}
shared function itemsOf(column) {
return column && Array.isArray(column.items) ? column.items : []
}
client function showPanel(sourceEvent) {
visible = true
output.open({ sourceEvent: sourceEvent })
}
client function hidePanel(reason) {
visible = false
output.close({ reason: reason })
}
client function togglePanel(sourceEvent) {
if (visible) {
hidePanel("toggle")
} else {
showPanel(sourceEvent)
}
}
client function handleKeydown(sourceEvent) {
if (sourceEvent.key === "Escape" && visible) {
sourceEvent.preventDefault()
hidePanel("escape")
}
}
client function choose(item) {
if (item.disabled) {
return
}
output.select({ item: item, value: item.value || item.label || "" })
hidePanel("select")
}
}
view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--mega-menu 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}
<slot />
</nav>
<div
{...attrs}
data-ui-component="MegaMenu"
class='wire-mega {class}'
data-color='{color}'
data-size='{size}'
data-open='{visible}'
@keydown='handleKeydown(event)'
@mouseleave='hidePanel("pointer-leave")'
>
<button
type="button"
class="wire-mega__trigger"
aria-haspopup="true"
aria-expanded='{visible}'
@click='togglePanel(event)'
@mouseenter='showPanel(event)'
@focus='showPanel(event)'
>
<span class='wire-mega__trigger-icon {icon}' data-show="icon" aria-hidden="true"></span>
<span class="wire-mega__trigger-label">{label}</span>
<span class="wire-mega__arrow" aria-hidden="true">&#8250;</span>
</button>
<div
class="wire-mega__panel"
data-wrn-anchored="true"
data-show="visible"
data-wrn-roving="both"
>
<div class="wire-mega__columns">
{#each columnList() as column}
<section class="wire-mega__column">
<h3 class="wire-mega__heading" data-show="column.heading">{column.heading}</h3>
<ul class="wire-mega__list">
{#each itemsOf(column) as item}
<li>
<a
class="wire-mega__link"
href='{item.href || "#"}'
data-wrn-roving-item="true"
aria-disabled='{item.disabled ? "true" : "false"}'
@click='choose(item)'
>
<span
class='wire-mega__link-icon {item.icon}'
data-show="item.icon"
aria-hidden="true"
></span>
<span class="wire-mega__link-body">
<span class="wire-mega__link-label">{item.label}</span>
<span class="wire-mega__link-description" data-show="item.description">
{item.description}
</span>
</span>
</a>
</li>
{/each}
</ul>
</section>
{/each}
</div>
<p class="wire-mega__footer" data-show="footer">{footer}</p>
<slot />
</div>
</div>
}
style {
.wire-mega {
--mega-accent: var(--wire-color-primary);
position: relative;
display: inline-block;
max-width: 100%;
}
.wire-mega[data-color="secondary"] {
--mega-accent: var(--wire-color-secondary);
}
.wire-mega[data-color="success"] {
--mega-accent: var(--wire-color-success);
}
.wire-mega[data-color="danger"] {
--mega-accent: var(--wire-color-danger);
}
.wire-mega[data-color="info"] {
--mega-accent: var(--wire-color-info);
}
.wire-mega[data-size="sm"] {
font-size: 0.82rem;
}
.wire-mega[data-size="lg"] {
font-size: 1rem;
}
.wire-mega__trigger {
appearance: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.7rem;
border: 1px solid transparent;
border-radius: var(--wire-radius-sm);
background: transparent;
color: var(--wire-color-text-muted);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.wire-mega__trigger:hover,
.wire-mega[data-open="true"] .wire-mega__trigger {
background: var(--wire-color-surface-soft);
color: var(--wire-color-text);
}
.wire-mega__trigger:focus-visible {
outline: 2px solid var(--mega-accent);
outline-offset: 2px;
}
.wire-mega__arrow {
display: inline-block;
transition: transform 160ms ease;
}
.wire-mega[data-open="true"] .wire-mega__arrow {
transform: rotate(90deg);
}
.wire-mega__panel {
position: absolute;
z-index: 40;
top: calc(100% + 0.4rem);
left: 0;
width: max-content;
max-width: min(60rem, calc(100vw - 2rem));
padding: 1rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-lg);
background: var(--wire-color-surface);
box-shadow: var(--wire-shadow-1);
}
.wire-mega__columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1.25rem;
}
.wire-mega__column {
min-width: 0;
}
.wire-mega__heading {
margin: 0 0 0.5rem;
color: var(--wire-color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wire-mega__list {
display: grid;
gap: 0.15rem;
margin: 0;
padding: 0;
list-style: none;
}
.wire-mega__link {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.45rem 0.5rem;
border-radius: var(--wire-radius-sm);
color: var(--wire-color-text);
text-decoration: none;
}
.wire-mega__link:hover {
background: var(--wire-color-surface-soft);
}
.wire-mega__link:focus-visible {
outline: 2px solid var(--mega-accent);
outline-offset: 2px;
}
.wire-mega__link[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.wire-mega__link-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.wire-mega__link-label {
font-size: 0.88rem;
font-weight: 600;
}
.wire-mega__link-description {
color: var(--wire-color-text-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.wire-mega__footer {
margin: 1rem 0 0;
padding-top: 0.75rem;
border-top: 1px solid var(--wire-color-border);
color: var(--wire-color-text-muted);
font-size: 0.8rem;
}
/*
* On a phone the panel stops floating and becomes part of the flow. A
* pointer-opened overlay pinned to a trigger is unreachable on touch, and
* a 60rem grid has nowhere to go on a 360px screen.
*/
@media (max-width: 767px) {
.wire-mega {
display: block;
}
.wire-mega__panel {
position: static;
width: auto;
max-width: 100%;
box-shadow: none;
}
.wire-mega__columns {
grid-template-columns: 1fr;
}
}
}
}
+302 -49
View File
@@ -1,82 +1,335 @@
// 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; [key: string]: string | number | boolean | null | object })
open(payload: { sourceEvent: Event })
close(payload: { source: string })
select(payload: { item: string | number | boolean | null | object; value: string | number | boolean; level: string })
select(payload: { item: object; value: string; level: number })
}
props {
size: string = "default"
color: string = "primary"
size: string = "default"
label: string = "Sidebar"
items: unknown[] = []
active: string = ""
orientation: string = "horizontal"
mobileLabel: string = "Open navigation"
drawerTitle: string = "Navigation"
class: string = ""
}
state mobileOpen: boolean = false
state drawerOpen = false
functions {
client function toggleSidebar() {
mobileOpen = !mobileOpen
output.toggle({ open: mobileOpen })
output[mobileOpen ? "open" : "close"]({ source: "mobile" })
shared function entryList() {
return Array.isArray(items) ? items : []
}
client function closeSidebar() {
mobileOpen = false
output.close({ source: "mobile" })
// 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 : []
}
client function selectItem(item, level) {
mobileOpen = false
output.select({ item: item, value: item.value || "", level: level })
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 class="wire-sidebar-shell wire-next--color-{color} wire-next--size-{size} {mobileOpen ? 'is-open' : ''} {class}">
<button type="button" class="wire-sidebar-toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleSidebar()">
<span class="icon-[lucide--panel-left-open]" aria-hidden="true"></span>
<span>{label}</span>
<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>
<button type="button" class="wire-sidebar-backdrop" aria-label="Close navigation" @click="closeSidebar()"></button>
<nav class="wire-next wire-next--sidebar wire-next--{orientation} wire-sidebar-panel" aria-label="{label}">
<div class="wire-sidebar-panel__head">
<strong>{label}</strong>
<button type="button" aria-label="Close navigation" @click="closeSidebar()"><span class="icon-[lucide--x]" aria-hidden="true"></span></button>
</div>
<div class="wire-sidebar-items">
{#each items as item}
{#if item.children && item.children.length}
<details class="wire-sidebar-group" name="wire-sidebar-group">
<summary>
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
<span class="icon-[lucide--chevron-down]" aria-hidden="true"></span>
</summary>
<div class="wire-sidebar-group__children">
{#each item.children as child}
<a href="{child.href || '#'}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)">
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
<span>{child.label}</span>
</a>
{/each}
</div>
</details>
{:else}
<a href="{item.href || '#'}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
<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>
{/if}
<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}
</div>
</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;
}
}
}
}