From 7d737ec0a0176c3ccc310f8736a372723d82f512 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 24 Aug 2026 22:18:30 +0530 Subject: [PATCH] fix(ui): make catalog mega menus interactive --- bun.lock | 2 +- docs/ui-visual-contract-0.8.json | 4 +- packages/ui/COMPONENTS.md | 4 +- packages/ui/component-reference.json | 13 +++- packages/ui/components/MegaMenu.wrn | 89 ++++++++++++++++++++++++---- packages/ui/components/Navbar.wrn | 1 + packages/ui/package.json | 2 +- packages/ui/src/navigation.ts | 1 + packages/ui/test/ui.test.ts | 27 ++++++++- 9 files changed, 124 insertions(+), 19 deletions(-) diff --git a/bun.lock b/bun.lock index 225a0c6d..cf3a21b2 100644 --- a/bun.lock +++ b/bun.lock @@ -688,7 +688,7 @@ }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.8.32", + "version": "0.8.33", "dependencies": { "@wrnexus/core": "workspace:*", }, diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json index add62132..6ffcafee 100644 --- a/docs/ui-visual-contract-0.8.json +++ b/docs/ui-visual-contract-0.8.json @@ -55,12 +55,12 @@ "packages/ui/components/Map.wrn": "3e6d5de5a21dd050c5c65950d6fb2f89af4024e1856a48f055b65b88fcd7b890", "packages/ui/components/MarketingSectionHeader.wrn": "39b79499b77dc1c7d34e4e5ecb45a9ddc6182f78c2a2586671c4fa07f44679ad", "packages/ui/components/Marquee.wrn": "81cd80732be34c8d7e4bf28b00347e1a9273166afdf66589d9f5d5774b69403b", - "packages/ui/components/MegaMenu.wrn": "1fdd962bfc30a5f0eaa563e5b055760d86f9c737f0d664286977894485d7b045", + "packages/ui/components/MegaMenu.wrn": "ea15f4aa31a3c52943cdd9f726f49386937d177de5ddec1d69e57644c0495c8f", "packages/ui/components/MetricCard.wrn": "513a6e99be3d9db46d96af9942b51331a0a048b184603e77e814506f0a52bdc5", "packages/ui/components/MetricGrid.wrn": "1fcebf51dcbe8520fe16bda904c1ef206c25c3900438d53877492c64ea21a1a5", "packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2", "packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a", - "packages/ui/components/Navbar.wrn": "f80c8799a6e0b751c85b8c0ec611c84a694ad4a0d0e2223e5a46afe250904e42", + "packages/ui/components/Navbar.wrn": "73c1212b14fc20e53dfecc9eddf9e121c4d9c56d00f1ef3599be8bc1ef0281c2", "packages/ui/components/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54", "packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39", "packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602", diff --git a/packages/ui/COMPONENTS.md b/packages/ui/COMPONENTS.md index d2e32dac..d894cc90 100644 --- a/packages/ui/COMPONENTS.md +++ b/packages/ui/COMPONENTS.md @@ -808,9 +808,9 @@ Show responsive hierarchical navigation with home support, separators, current-p Theme-aware, responsive mega menu component. - Mount: `data-component="MegaMenu"` -- Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Menu"`, `icon: string = ""`, `columns: unknown[] = []`, `rail: unknown[] = []`, `featured: Record = {}`, `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 = ""` +- Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Menu"`, `icon: string = ""`, `columns: unknown[] = []`, `rail: unknown[] = []`, `featured: Record = {}`, `variant: string = "default"`, `density: string = "default"`, `panelWidth: string = "xl"`, `panelAlign: string = "start"`, `columnCount: number = 0`, `fullWidth: boolean = false`, `openOnHover: boolean = true`, `activeCategory: string = ""`, `actionLabel: string = ""`, `actionHref: string = ""`, `actionIcon: string = "icon-[lucide--arrow-right]"`, `footer: string = ""`, `defaultOpen: boolean = false`, `class: string = ""` - Slots: `default` -- Outputs: `open({ sourceEvent: Event })`, `close({ reason: string })`, `select({ item: object; value: string })` +- Outputs: `open({ sourceEvent: Event })`, `close({ reason: string })`, `select({ item: object; value: string })`, `category({ item: object; value: string; index: number })` ### Nav diff --git a/packages/ui/component-reference.json b/packages/ui/component-reference.json index e6d14bbc..50f01a16 100644 --- a/packages/ui/component-reference.json +++ b/packages/ui/component-reference.json @@ -8408,6 +8408,13 @@ "default": "true", "options": [] }, + { + "name": "activeCategory", + "type": "string", + "required": false, + "default": "\"\"", + "options": [] + }, { "name": "actionLabel", "type": "string", @@ -8464,9 +8471,13 @@ { "name": "select", "payloadType": "{ item: object; value: string }" + }, + { + "name": "category", + "payloadType": "{ item: object; value: string; index: number }" } ], - "events": ["open", "close", "select"], + "events": ["open", "close", "select", "category"], "source": "components/MegaMenu.wrn" }, { diff --git a/packages/ui/components/MegaMenu.wrn b/packages/ui/components/MegaMenu.wrn index d5869732..19f6e590 100644 --- a/packages/ui/components/MegaMenu.wrn +++ b/packages/ui/components/MegaMenu.wrn @@ -18,6 +18,7 @@ component MegaMenu { open(payload: { sourceEvent: Event }) close(payload: { reason: string }) select(payload: { item: object; value: string }) + category(payload: { item: object; value: string; index: number }) } props { @@ -35,6 +36,7 @@ component MegaMenu { columnCount: number = 0 fullWidth: boolean = false openOnHover: boolean = true + activeCategory: string = "" actionLabel: string = "" actionHref: string = "" actionIcon: string = "icon-[lucide--arrow-right]" @@ -45,6 +47,8 @@ component MegaMenu { state visible = defaultOpen state closeTimer = null + state openTimer = null + state selectedCategory = activeCategory functions { shared function columnList() { @@ -59,13 +63,44 @@ component MegaMenu { return Array.isArray(rail) ? rail : [] } + shared function categoryValue(item) { + return item ? String(item.value || item.href || item.label || "") : "" + } + + shared function activeCategoryIndex() { + const entries = railList() + if (!entries.length) { + return 0 + } + if (!selectedCategory) { + return 0 + } + for (let index = 0; index < entries.length; index += 1) { + if (categoryValue(entries[index]) === selectedCategory) { + return index + } + } + return 0 + } + + shared function visibleColumns() { + const entries = columnList() + if (variant !== "catalog") { + return entries + } + const selected = entries[activeCategoryIndex()] + return selected ? [selected] : [] + } + client function showPanel(sourceEvent) { + cancelScheduledOpen() cancelScheduledHide() visible = true output.open({ sourceEvent: sourceEvent }) } client function hidePanel(reason) { + cancelScheduledOpen() cancelScheduledHide() visible = false output.close({ reason: reason }) @@ -78,6 +113,22 @@ component MegaMenu { } } + client function cancelScheduledOpen() { + if (openTimer) { + window.clearTimeout(openTimer) + openTimer = null + } + } + + client function scheduleOpen(sourceEvent) { + cancelScheduledOpen() + cancelScheduledHide() + openTimer = window.setTimeout(function () { + openTimer = null + showPanel(sourceEvent) + }, 140) + } + client function scheduleHide() { cancelScheduledHide() closeTimer = window.setTimeout(function () { @@ -104,10 +155,15 @@ component MegaMenu { client function hoverPanel(sourceEvent) { if (openOnHover) { - showPanel(sourceEvent) + scheduleOpen(sourceEvent) } } + client function chooseCategory(item, index) { + selectedCategory = categoryValue(item) + output.category({ item: item, value: selectedCategory, index: index }) + } + client function handleKeydown(sourceEvent) { if (sourceEvent.key === "Escape" && visible) { sourceEvent.preventDefault() @@ -148,7 +204,7 @@ component MegaMenu { aria-haspopup="true" aria-expanded='{visible}' @click='activatePanel(event)' - @mouseenter='hoverPanel(event)' + @mousemove='hoverPanel(event)' > {label} @@ -165,17 +221,24 @@ component MegaMenu {
{#if railList().length > 0} {/if}
- {#each columnList() as column} + {#each visibleColumns() as column}
{#if column.eyebrow}

{column.eyebrow}

{/if}

{column.heading}

@@ -310,6 +373,8 @@ component MegaMenu { max-width: min(60rem, calc(100vw - 2rem)); max-height: calc(100dvh - 5.5rem); padding: 1rem; + display: flex; + flex-direction: column; overflow: hidden; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius-lg); @@ -324,7 +389,7 @@ component MegaMenu { .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__layout { display: grid; min-height: 0; max-height: calc(100dvh - 8rem); flex: 1; grid-template-columns: auto minmax(0, 1fr) auto; gap: 1.25rem; overflow: hidden; } .wrn-mega__columns { display: grid; @@ -341,8 +406,9 @@ component MegaMenu { .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 { appearance: none; display: flex; width: 100%; padding: 0.65rem; align-items: flex-start; gap: 0.65rem; color: var(--wrn-color-text); border: 0; border-radius: var(--wrn-radius-sm); background: transparent; font: inherit; text-align: left; text-decoration: none; cursor: pointer; } .wrn-mega__rail-link:hover { background: var(--wrn-color-surface); } + .wrn-mega__rail-link[data-active="true"] { color: var(--mega-accent); background: var(--wrn-color-surface); box-shadow: inset 3px 0 0 var(--mega-accent); } .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; } @@ -398,6 +464,7 @@ component MegaMenu { .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="catalog"] .wrn-mega__columns { grid-template-columns: minmax(0, 1fr); } .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; } @@ -468,7 +535,7 @@ component MegaMenu { .wrn-mega__columns { grid-template-columns: 1fr; } - .wrn-mega__layout { grid-template-columns: 1fr; overflow: visible; } + .wrn-mega__layout { max-height: none; 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; } diff --git a/packages/ui/components/Navbar.wrn b/packages/ui/components/Navbar.wrn index 84d0d8ef..c418faef 100644 --- a/packages/ui/components/Navbar.wrn +++ b/packages/ui/components/Navbar.wrn @@ -197,6 +197,7 @@ size: string = "default" columnCount="{megaData(item).columnCount || 0}" fullWidth="{megaData(item).fullWidth || false}" openOnHover="{openOnHover}" + activeCategory="{megaData(item).activeCategory || ''}" actionLabel="{megaData(item).actionLabel || ''}" actionHref="{megaData(item).actionHref || ''}" actionIcon="{megaData(item).actionIcon || 'icon-[lucide--arrow-right]'}" diff --git a/packages/ui/package.json b/packages/ui/package.json index 3efde1af..3e8195c2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.8.32", + "version": "0.8.33", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/src/navigation.ts b/packages/ui/src/navigation.ts index a0b383ce..fb225ab2 100644 --- a/packages/ui/src/navigation.ts +++ b/packages/ui/src/navigation.ts @@ -80,6 +80,7 @@ export interface NavbarMegaMenuData { panelWidth?: MegaMenuPanelWidth; panelAlign?: MegaMenuPanelAlign; fullWidth?: boolean; + activeCategory?: string; columnCount?: number; columns: MegaMenuColumn[]; rail?: MegaMenuRailItem[]; diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index a617f94a..f970f12a 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -2948,6 +2948,30 @@ test("mega menu columns take roving focus and survive an empty column list", asy expect(dom.querySelector('[data-wrn-roving="both"]')).not.toBeNull(); }); +test("catalog mega menu renders only the active rail category", async () => { + const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); + const html = await renderComponent(source, { + label: "Products", + variant: "catalog", + activeCategory: "sales", + rail: [ + { label: "Platform", value: "platform" }, + { label: "Sales", value: "sales" }, + ], + columns: [ + { heading: "Platform", items: [{ label: "Home", href: "/home" }] }, + { heading: "Sales", items: [{ label: "CRM", href: "/crm" }] }, + ], + }); + const dom = mountHtml(html); + + expect(dom.querySelectorAll(".wrn-mega__rail-link")).toHaveLength(2); + expect(dom.querySelectorAll(".wrn-mega__column")).toHaveLength(1); + expect(dom.querySelector(".wrn-mega__heading")?.textContent).toContain("Sales"); + expect(dom.querySelector('[aria-pressed="true"]')?.textContent).toContain("Sales"); + expect(source).toContain("overflow-y: auto"); +}); + test("mega menu supports rails, icons, badges, media, featured content and footer actions", async () => { const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); const html = await renderComponent(source, { @@ -3128,7 +3152,8 @@ test("mega menu keeps hover activation and click activation from cancelling each const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); expect(source).toContain("openOnHover: boolean = true"); expect(source).toContain("@click='activatePanel(event)'"); - expect(source).toContain("@mouseenter='hoverPanel(event)'"); + expect(source).toContain("@mousemove='hoverPanel(event)'"); + expect(source).toContain("}, 140)"); expect(source).not.toContain("@focus='showPanel(event)'"); expect(source).toContain("max-height: calc(100dvh - 5.5rem)"); });