Files
WRNexusJS/packages/ui/components/MegaMenu.wrn
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

345 lines
8.9 KiB
Plaintext

// 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 })
close(payload: { reason: string })
select(payload: { item: object; value: string })
}
props {
color: string = "primary"
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 {
<div
{...attrs}
data-ui-component="MegaMenu"
class='wrn-mega {class}'
data-color='{color}'
data-size='{size}'
data-open='{visible}'
@keydown='handleKeydown(event)'
@mouseleave='hidePanel("pointer-leave")'
>
<button
type="button"
class="wrn-mega__trigger"
aria-haspopup="true"
aria-expanded='{visible}'
@click='togglePanel(event)'
@mouseenter='showPanel(event)'
@focus='showPanel(event)'
>
<span class='wrn-mega__trigger-icon {icon}' data-show="icon" aria-hidden="true"></span>
<span class="wrn-mega__trigger-label">{label}</span>
<span class="wrn-mega__arrow" aria-hidden="true">&#8250;</span>
</button>
<div
class="wrn-mega__panel"
data-wrn-anchored="true"
data-show="visible"
data-wrn-roving="both"
>
<div class="wrn-mega__columns">
{#each columnList() as column}
<section class="wrn-mega__column">
<h3 class="wrn-mega__heading" data-show="column.heading">{column.heading}</h3>
<ul class="wrn-mega__list">
{#each itemsOf(column) as item}
<li>
<a
class="wrn-mega__link"
href='{item.href || "#"}'
data-wrn-roving-item="true"
aria-disabled='{item.disabled ? "true" : "false"}'
@click='choose(item)'
>
<span
class='wrn-mega__link-icon {item.icon}'
data-show="item.icon"
aria-hidden="true"
></span>
<span class="wrn-mega__link-body">
<span class="wrn-mega__link-label">{item.label}</span>
<span class="wrn-mega__link-description" data-show="item.description">
{item.description}
</span>
</span>
</a>
</li>
{/each}
</ul>
</section>
{/each}
</div>
<p class="wrn-mega__footer" data-show="footer">{footer}</p>
<slot />
</div>
</div>
}
style {
.wrn-mega {
--mega-accent: var(--wrn-color-primary);
position: relative;
display: inline-block;
max-width: 100%;
}
.wrn-mega[data-color="secondary"] {
--mega-accent: var(--wrn-color-secondary);
}
.wrn-mega[data-color="success"] {
--mega-accent: var(--wrn-color-success);
}
.wrn-mega[data-color="danger"] {
--mega-accent: var(--wrn-color-danger);
}
.wrn-mega[data-color="info"] {
--mega-accent: var(--wrn-color-info);
}
.wrn-mega[data-size="sm"] {
font-size: 0.82rem;
}
.wrn-mega[data-size="lg"] {
font-size: 1rem;
}
.wrn-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(--wrn-radius-sm);
background: transparent;
color: var(--wrn-color-text-muted);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.wrn-mega__trigger:hover,
.wrn-mega[data-open="true"] .wrn-mega__trigger {
background: var(--wrn-color-surface-soft);
color: var(--wrn-color-text);
}
.wrn-mega__trigger:focus-visible {
outline: 2px solid var(--mega-accent);
outline-offset: 2px;
}
.wrn-mega__arrow {
display: inline-block;
transition: transform 160ms ease;
}
.wrn-mega[data-open="true"] .wrn-mega__arrow {
transform: rotate(90deg);
}
/*
* The panel is offset below the trigger for looks, which used to close the
* menu on the way to it: that offset is dead space belonging to neither
* element, so crossing it fired mouseleave on the root. The bridge below
* covers the gap with a descendant, so the pointer never actually leaves.
*/
.wrn-mega__panel::before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 100%;
height: 0.6rem;
}
.wrn-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(--wrn-color-border);
border-radius: var(--wrn-radius-lg);
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-1);
}
.wrn-mega__columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1.25rem;
}
.wrn-mega__column {
min-width: 0;
}
.wrn-mega__heading {
margin: 0 0 0.5rem;
color: var(--wrn-color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wrn-mega__list {
display: grid;
gap: 0.15rem;
margin: 0;
padding: 0;
list-style: none;
}
.wrn-mega__link {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.45rem 0.5rem;
border-radius: var(--wrn-radius-sm);
color: var(--wrn-color-text);
text-decoration: none;
}
.wrn-mega__link:hover {
background: var(--wrn-color-surface-soft);
}
.wrn-mega__link:focus-visible {
outline: 2px solid var(--mega-accent);
outline-offset: 2px;
}
.wrn-mega__link[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.wrn-mega__link-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.wrn-mega__link-label {
font-size: 0.88rem;
font-weight: 600;
}
.wrn-mega__link-description {
color: var(--wrn-color-text-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.wrn-mega__footer {
margin: 1rem 0 0;
padding-top: 0.75rem;
border-top: 1px solid var(--wrn-color-border);
color: var(--wrn-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) {
.wrn-mega {
display: block;
}
.wrn-mega__panel {
position: static;
width: auto;
max-width: 100%;
box-shadow: none;
}
.wrn-mega__columns {
grid-template-columns: 1fr;
}
}
}
}