478 lines
18 KiB
Plaintext
478 lines
18 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[] = []
|
|
rail: unknown[] = []
|
|
featured: Record<string, unknown> = {}
|
|
variant: string = "default"
|
|
density: string = "default"
|
|
panelWidth: string = "xl"
|
|
panelAlign: string = "start"
|
|
columnCount: number = 0
|
|
fullWidth: boolean = false
|
|
openOnHover: boolean = true
|
|
actionLabel: string = ""
|
|
actionHref: string = ""
|
|
actionIcon: string = "icon-[lucide--arrow-right]"
|
|
footer: string = ""
|
|
defaultOpen: boolean = false
|
|
class: string = ""
|
|
}
|
|
|
|
state visible = defaultOpen
|
|
state closeTimer = null
|
|
|
|
functions {
|
|
shared function columnList() {
|
|
return Array.isArray(columns) ? columns : []
|
|
}
|
|
|
|
shared function itemsOf(column) {
|
|
return column && Array.isArray(column.items) ? column.items : []
|
|
}
|
|
|
|
shared function railList() {
|
|
return Array.isArray(rail) ? rail : []
|
|
}
|
|
|
|
client function showPanel(sourceEvent) {
|
|
cancelScheduledHide()
|
|
visible = true
|
|
output.open({ sourceEvent: sourceEvent })
|
|
}
|
|
|
|
client function hidePanel(reason) {
|
|
cancelScheduledHide()
|
|
visible = false
|
|
output.close({ reason: reason })
|
|
}
|
|
|
|
client function cancelScheduledHide() {
|
|
if (closeTimer) {
|
|
window.clearTimeout(closeTimer)
|
|
closeTimer = null
|
|
}
|
|
}
|
|
|
|
client function scheduleHide() {
|
|
cancelScheduledHide()
|
|
closeTimer = window.setTimeout(function () {
|
|
closeTimer = null
|
|
hidePanel("pointer-leave")
|
|
}, 220)
|
|
}
|
|
|
|
client function togglePanel(sourceEvent) {
|
|
if (visible) {
|
|
hidePanel("toggle")
|
|
} else {
|
|
showPanel(sourceEvent)
|
|
}
|
|
}
|
|
|
|
client function activatePanel(sourceEvent) {
|
|
if (openOnHover) {
|
|
showPanel(sourceEvent)
|
|
} else {
|
|
togglePanel(sourceEvent)
|
|
}
|
|
}
|
|
|
|
client function hoverPanel(sourceEvent) {
|
|
if (openOnHover) {
|
|
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}'
|
|
data-variant='{variant}'
|
|
data-density='{density}'
|
|
data-panel-width='{panelWidth}'
|
|
data-panel-align='{panelAlign}'
|
|
data-full-width='{fullWidth ? "true" : "false"}'
|
|
data-column-count='{columnCount}'
|
|
@keydown='handleKeydown(event)'
|
|
@mouseenter='cancelScheduledHide()'
|
|
@mouseleave='scheduleHide()'
|
|
>
|
|
<button
|
|
type="button"
|
|
class="wrn-mega__trigger"
|
|
aria-haspopup="true"
|
|
aria-expanded='{visible}'
|
|
@click='activatePanel(event)'
|
|
@mouseenter='hoverPanel(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">›</span>
|
|
</button>
|
|
|
|
<div
|
|
class="wrn-mega__panel"
|
|
data-wrn-anchored="true"
|
|
data-show="visible"
|
|
data-wrn-roving="both"
|
|
@mouseenter='cancelScheduledHide()'
|
|
>
|
|
<div class="wrn-mega__layout">
|
|
{#if railList().length > 0}
|
|
<aside class="wrn-mega__rail">
|
|
{#each railList() as item}
|
|
<a class="wrn-mega__rail-link" href='{item.href || "#"}' @click='choose(item)'>
|
|
{#if item.icon}<span class='wrn-mega__rail-icon {item.icon}' aria-hidden="true"></span>{/if}
|
|
<span><strong>{item.label}</strong>{#if item.description}<small>{item.description}</small>{/if}</span>
|
|
</a>
|
|
{/each}
|
|
</aside>
|
|
{/if}
|
|
|
|
<div class="wrn-mega__columns">
|
|
{#each columnList() as column}
|
|
<section class="wrn-mega__column">
|
|
{#if column.eyebrow}<p class="wrn-mega__eyebrow">{column.eyebrow}</p>{/if}
|
|
<h3 class="wrn-mega__heading" data-show="column.heading">{column.heading}</h3>
|
|
{#if column.description}<p class="wrn-mega__column-description">{column.description}</p>{/if}
|
|
{#if column.image}<img class="wrn-mega__column-image" src='{column.image}' alt='{column.imageAlt || ""}' loading="lazy" />{/if}
|
|
<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)'>
|
|
{#if item.image}<img class="wrn-mega__link-image" src='{item.image}' alt="" loading="lazy" />{/if}
|
|
<span class='wrn-mega__link-icon {item.icon}' data-show="item.icon" aria-hidden="true"></span>
|
|
<span class="wrn-mega__link-body">
|
|
{#if item.eyebrow}<span class="wrn-mega__link-eyebrow">{item.eyebrow}</span>{/if}
|
|
<span class="wrn-mega__link-label">{item.label}{#if item.badge}<span class="wrn-mega__badge">{item.badge}</span>{/if}</span>
|
|
<span class="wrn-mega__link-description" data-show="item.description">{item.description}</span>
|
|
</span>
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{#if column.actionLabel}<a class="wrn-mega__column-action" href='{column.actionHref || "#"}'>{column.actionLabel}<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span></a>{/if}
|
|
</section>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if featured.title || featured.image}
|
|
<aside class="wrn-mega__featured">
|
|
{#if featured.eyebrow}<p class="wrn-mega__eyebrow">{featured.eyebrow}</p>{/if}
|
|
{#if featured.image}<img src='{featured.image}' alt='{featured.imageAlt || ""}' loading="lazy" />{/if}
|
|
{#if featured.title}<strong>{featured.title}</strong>{/if}
|
|
{#if featured.description}<p>{featured.description}</p>{/if}
|
|
{#if featured.actionLabel}<a href='{featured.actionHref || "#"}'>{featured.actionLabel}<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span></a>{/if}
|
|
</aside>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if footer || actionLabel}<div class="wrn-mega__footer"><span>{footer}</span>{#if actionLabel}<a href='{actionHref || "#"}'>{actionLabel}<span class='{actionIcon}' aria-hidden="true"></span></a>{/if}</div>{/if}
|
|
<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.85rem;
|
|
}
|
|
|
|
.wrn-mega__panel {
|
|
position: absolute;
|
|
z-index: 1200;
|
|
top: calc(100% + 0.4rem);
|
|
left: 0;
|
|
width: max-content;
|
|
max-width: min(60rem, calc(100vw - 2rem));
|
|
max-height: calc(100dvh - 5.5rem);
|
|
padding: 1rem;
|
|
overflow: hidden;
|
|
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[data-panel-width="lg"] .wrn-mega__panel { width: min(48rem, calc(100vw - 2rem)); }
|
|
.wrn-mega[data-panel-width="xl"] .wrn-mega__panel { width: min(72rem, calc(100vw - 2rem)); }
|
|
.wrn-mega[data-panel-width="2xl"] .wrn-mega__panel { width: min(88rem, calc(100vw - 2rem)); }
|
|
.wrn-mega[data-full-width="true"] .wrn-mega__panel { width: calc(100vw - 2rem); max-width: none; }
|
|
.wrn-mega[data-panel-align="center"] .wrn-mega__panel { left: 50%; transform: translateX(-50%); }
|
|
.wrn-mega[data-panel-align="end"] .wrn-mega__panel { right: 0; left: auto; }
|
|
|
|
.wrn-mega__layout { display: grid; min-height: 0; grid-template-columns: auto minmax(0, 1fr) auto; gap: 1.25rem; overflow: hidden; }
|
|
|
|
.wrn-mega__columns {
|
|
display: grid;
|
|
min-height: 0;
|
|
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
|
gap: 1.25rem;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
}
|
|
|
|
.wrn-mega[data-column-count="2"] .wrn-mega__columns { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.wrn-mega[data-column-count="3"] .wrn-mega__columns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
.wrn-mega[data-column-count="4"] .wrn-mega__columns { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
.wrn-mega[data-column-count="5"] .wrn-mega__columns { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
|
|
|
.wrn-mega__rail { display: grid; min-width: 13rem; min-height: 0; padding: 0.75rem; align-content: start; gap: 0.25rem; overflow-y: auto; overscroll-behavior: contain; border-radius: var(--wrn-radius-lg); background: var(--wrn-color-surface-soft); }
|
|
.wrn-mega__rail-link { display: flex; padding: 0.65rem; align-items: flex-start; gap: 0.65rem; color: var(--wrn-color-text); border-radius: var(--wrn-radius-sm); text-decoration: none; }
|
|
.wrn-mega__rail-link:hover { background: var(--wrn-color-surface); }
|
|
.wrn-mega__rail-link > span:last-child { display: grid; gap: 0.2rem; }
|
|
.wrn-mega__rail-link strong { font-size: 0.82rem; }
|
|
.wrn-mega__rail-link small { max-width: 18ch; color: var(--wrn-color-text-muted); font-size: 0.72rem; line-height: 1.45; }
|
|
.wrn-mega__rail-icon { display: grid; width: 2.4rem; height: 2.4rem; flex: none; place-items: center; color: var(--mega-accent); border-radius: 0.7rem; background: color-mix(in srgb, var(--mega-accent) 12%, transparent); font-size: 1.15rem; }
|
|
|
|
.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__eyebrow, .wrn-mega__link-eyebrow { margin: 0 0 0.35rem; color: var(--wrn-color-text-muted); font-size: 0.65rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
|
.wrn-mega__column-description { margin: -0.25rem 0 0.75rem; color: var(--wrn-color-text-muted); font-size: 0.75rem; line-height: 1.5; }
|
|
.wrn-mega__column-image { width: 100%; aspect-ratio: 16 / 7; margin-bottom: 0.65rem; object-fit: cover; border-radius: var(--wrn-radius-sm); }
|
|
|
|
.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-icon { display: grid; width: 2rem; height: 2rem; flex: none; place-items: center; color: var(--mega-accent); border-radius: 999px; background: color-mix(in srgb, var(--mega-accent) 10%, var(--wrn-color-surface-soft)); }
|
|
.wrn-mega__link-image { width: 2.4rem; height: 2.4rem; flex: none; object-fit: cover; border-radius: 0.65rem; }
|
|
.wrn-mega__badge { display: inline-flex; margin-left: 0.4rem; padding: 0.12rem 0.35rem; align-items: center; color: var(--mega-accent); border: 1px solid color-mix(in srgb, var(--mega-accent) 35%, transparent); border-radius: 999px; font-size: 0.6rem; font-weight: 700; }
|
|
.wrn-mega__column-action, .wrn-mega__featured a, .wrn-mega__footer a { display: inline-flex; margin-top: 0.7rem; align-items: center; gap: 0.35rem; color: var(--mega-accent); font-size: 0.78rem; font-weight: 700; text-decoration: none; }
|
|
|
|
.wrn-mega__featured { display: grid; width: 14rem; padding-left: 1.25rem; align-content: start; gap: 0.55rem; border-left: 1px solid var(--wrn-color-border); }
|
|
.wrn-mega__featured img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: var(--wrn-radius-lg); }
|
|
.wrn-mega__featured strong { font-size: 0.85rem; line-height: 1.35; }
|
|
.wrn-mega__featured p { margin: 0; color: var(--wrn-color-text-muted); font-size: 0.75rem; line-height: 1.5; }
|
|
|
|
.wrn-mega[data-density="compact"] :is(.wrn-mega__panel, .wrn-mega__rail) { padding: 0.7rem; }
|
|
.wrn-mega[data-density="compact"] .wrn-mega__columns { gap: 0.65rem; }
|
|
.wrn-mega[data-variant="icon-grid"] .wrn-mega__list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.35rem; }
|
|
.wrn-mega[data-variant="catalog"] .wrn-mega__link { padding-block: 0.28rem; }
|
|
.wrn-mega[data-variant="catalog"] .wrn-mega__heading { color: var(--wrn-color-text); font-size: 0.78rem; letter-spacing: 0; text-transform: none; }
|
|
.wrn-mega[data-variant="dark"] .wrn-mega__panel { color: #f8fafc; border-color: #2b2b2b; background: #151515; }
|
|
.wrn-mega[data-variant="dark"] :is(.wrn-mega__heading, .wrn-mega__link, .wrn-mega__featured strong) { color: #f8fafc; }
|
|
.wrn-mega[data-variant="dark"] :is(.wrn-mega__link-description, .wrn-mega__column-description, .wrn-mega__eyebrow, .wrn-mega__featured p) { color: #a3a3a3; }
|
|
.wrn-mega[data-variant="dark"] .wrn-mega__link:hover { background: #242424; }
|
|
|
|
.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 {
|
|
display: flex;
|
|
margin: 1rem 0 0;
|
|
padding-top: 0.75rem;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
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%;
|
|
max-height: none;
|
|
overflow: visible;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.wrn-mega__columns {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.wrn-mega__layout { grid-template-columns: 1fr; overflow: visible; }
|
|
.wrn-mega__rail, .wrn-mega__columns { overflow: visible; }
|
|
.wrn-mega__featured { width: auto; padding: 1rem 0 0; border-top: 1px solid var(--wrn-color-border); border-left: 0; }
|
|
.wrn-mega[data-variant="icon-grid"] .wrn-mega__list { grid-template-columns: 1fr; }
|
|
}
|
|
}
|
|
}
|