Object props never worked in generated demos, and my two earlier attempts each
traded one failure for another:
- a bare {...} attribute is read by the compiler as an interpolation, so it
parsed JSON as JavaScript and the page 500ed
- parenthesising it compiled, but prop coercion runs JSON.parse on the raw
attribute, so ({...}) threw and every demo rendered empty and silent
- entity-escaping the braces did not help either: the compiler hands the
attribute over without decoding, so JSON.parse still failed
They are now hoisted into page state and bound, which is what the playground
has always done. The state initialiser uses JSON.parse rather than an object
literal because the parser reads a leading brace as the start of a block.
Navbar gains a real profile: a brand, links, a two-column dropdown panel and
calls to action, instead of the generic scaffold samples that made every demo
look identical and showed no dropdown at all.
MegaMenu closed while the pointer travelled to it. The panel sits below the
trigger and that offset belongs to neither element, so crossing it fired
mouseleave on the root. A descendant now covers the gap.
Scrollspy could not be exercised at all: its links pointed at ids that did not
exist on the page. The demo now ships real sections, in page flow because the
runtime observes against the viewport.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
8.9 KiB
Plaintext
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='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">›</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);
|
|
}
|
|
|
|
/*
|
|
* 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.
|
|
*/
|
|
.wire-mega__panel::before {
|
|
content: "";
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 100%;
|
|
height: 0.6rem;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|
|
}
|
|
}
|