From 0618782355c7c7c8d3a2fcc2dde32e370057df0a Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 24 Aug 2026 21:52:25 +0530 Subject: [PATCH] fix(ui): make mega menus work under strict CSP --- bun.lock | 4 +-- docs/ui-visual-contract-0.8.json | 4 +-- packages/csr/package.json | 2 +- packages/csr/src/index.ts | 2 +- packages/csr/src/reactive-runtime.ts | 42 +++++++++++++++++++++++++--- packages/csr/test/reactive.test.ts | 7 +++++ packages/ui/COMPONENTS.md | 2 +- packages/ui/component-reference.json | 7 +++++ packages/ui/components/MegaMenu.wrn | 36 +++++++++++++++++++----- packages/ui/components/Navbar.wrn | 1 + packages/ui/package.json | 2 +- packages/ui/test/ui.test.ts | 9 ++++++ 12 files changed, 99 insertions(+), 19 deletions(-) diff --git a/bun.lock b/bun.lock index 01a19c7c..fb485882 100644 --- a/bun.lock +++ b/bun.lock @@ -342,7 +342,7 @@ }, "packages/csr": { "name": "@wrnexus/csr", - "version": "0.8.33", + "version": "0.8.34", "dependencies": { "@wrnexus/core": "workspace:*", }, @@ -688,7 +688,7 @@ }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.8.29", + "version": "0.8.30", "dependencies": { "@wrnexus/core": "workspace:*", }, diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json index 17519a3b..514c4dc5 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": "ab065c8e837da5cb69f70f095a2dbed7fc0170f7ae10e758299464b1bf3b6350", + "packages/ui/components/MegaMenu.wrn": "c86a18489b5316047466b54a7c0b439e1b56539ab446ac88eb413a0d2c33781b", "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": "e8f6a6367d2fe4086dc1f0fbbadc49828470a3c78ad27e30220368a5682392e5", + "packages/ui/components/Navbar.wrn": "cf956970f9b239d0cd2ff1e74554e07c74712a3514b2aa427352a73e5db02882", "packages/ui/components/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54", "packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39", "packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602", diff --git a/packages/csr/package.json b/packages/csr/package.json index bb288d49..95505b29 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.8.33", + "version": "0.8.34", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/index.ts b/packages/csr/src/index.ts index 47e85ac6..6bae3639 100644 --- a/packages/csr/src/index.ts +++ b/packages/csr/src/index.ts @@ -90,7 +90,7 @@ const CONTROLLER_LOADER = String.raw` if (!componentControllerPromise) { componentControllerPromise = new Promise(function (resolve, reject) { var script = document.createElement("script"); - script.src = componentControllerUrl; + script.src = trustedScriptURL(componentControllerUrl); script.defer = true; script.onload = resolve; script.onerror = reject; diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 47cfd755..be91445b 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -26,6 +26,40 @@ export const REACTIVE_RUNTIME = String.raw` var behaviorObserver; var clientModuleCache = new Map(); + /* + * Production enables require-trusted-types-for 'script'. Every HTML write + * below uses compiler-emitted template markup (or the explicit data-html + * escape hatch), and controller URLs are generated by the framework. Route + * those known sinks through the policy allowed by the default security + * preset instead of letting the browser reject hydration midway through a + * state update. + */ + var wrnexusTrustedTypesPolicy = null; + if (window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") { + try { + wrnexusTrustedTypesPolicy = window.trustedTypes.createPolicy("wrnexus", { + createHTML: function (value) { return value; }, + createScriptURL: function (value) { return value; } + }); + } catch (_) { + wrnexusTrustedTypesPolicy = window.trustedTypes.defaultPolicy || null; + } + } + + function trustedHTML(value) { + var source = value == null ? "" : String(value); + return wrnexusTrustedTypesPolicy + ? wrnexusTrustedTypesPolicy.createHTML(source) + : source; + } + + function trustedScriptURL(value) { + var source = value == null ? "" : String(value); + return wrnexusTrustedTypesPolicy + ? wrnexusTrustedTypesPolicy.createScriptURL(source) + : source; + } + /* * Two builtin chains the runtime reaches for constantly. Aliasing them is * not only shorter: hasOwn keeps prototype keys from reading as data, and @@ -1818,7 +1852,7 @@ export const REACTIVE_RUNTIME = String.raw` var next = markup == null ? "" : String(markup); if (htmlNode.innerHTML !== next) { - htmlNode.innerHTML = next; + htmlNode.innerHTML = trustedHTML(next); } }); runHtml(); @@ -2002,7 +2036,7 @@ export const REACTIVE_RUNTIME = String.raw` } function clearControlContent() { - if (!rangeEnd) { block.innerHTML = ""; return; } + if (!rangeEnd) { block.innerHTML = trustedHTML(""); return; } while (block.nextSibling && block.nextSibling !== rangeEnd) { block.parentNode.removeChild(block.nextSibling); } @@ -2010,7 +2044,7 @@ export const REACTIVE_RUNTIME = String.raw` function appendControlContent(markup, locals) { var template = document.createElement("template"); - template.innerHTML = markup || ""; + template.innerHTML = trustedHTML(markup || ""); var fragment = template.content; var elements = toArray(fragment.childNodes).filter(function (node) { return node.nodeType === 1; @@ -2399,7 +2433,7 @@ export const REACTIVE_RUNTIME = String.raw` var markup = value == null ? "" : String(value); if (node.innerHTML !== markup) { - node.innerHTML = markup; + node.innerHTML = trustedHTML(markup); } }); }); diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 5bce4043..12212f3c 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -62,6 +62,13 @@ test("split runtime hydrates a controller only from the controller asset", () => expect(win.document.activeElement).toBe(buttons[1]); }); +test("production hydration uses Trusted Types for HTML and controller URLs", () => { + const runtime = getReactiveRuntime(); + expect(runtime).toContain('createPolicy("wrnexus"'); + expect(runtime).toContain('template.innerHTML = trustedHTML(markup || "")'); + expect(runtime).toContain("script.src = trustedScriptURL(componentControllerUrl)"); +}); + test("a page without controller markers does not request the controller asset", () => { const win = mount( `
{count}
`, diff --git a/packages/ui/COMPONENTS.md b/packages/ui/COMPONENTS.md index 4f9d3b98..d2e32dac 100644 --- a/packages/ui/COMPONENTS.md +++ b/packages/ui/COMPONENTS.md @@ -808,7 +808,7 @@ 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`, `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`, `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 })` diff --git a/packages/ui/component-reference.json b/packages/ui/component-reference.json index f887e27c..e6d14bbc 100644 --- a/packages/ui/component-reference.json +++ b/packages/ui/component-reference.json @@ -8401,6 +8401,13 @@ "default": "false", "options": [] }, + { + "name": "openOnHover", + "type": "boolean", + "required": false, + "default": "true", + "options": [] + }, { "name": "actionLabel", "type": "string", diff --git a/packages/ui/components/MegaMenu.wrn b/packages/ui/components/MegaMenu.wrn index cad3a993..b969b2d1 100644 --- a/packages/ui/components/MegaMenu.wrn +++ b/packages/ui/components/MegaMenu.wrn @@ -34,6 +34,7 @@ component MegaMenu { panelAlign: string = "start" columnCount: number = 0 fullWidth: boolean = false + openOnHover: boolean = true actionLabel: string = "" actionHref: string = "" actionIcon: string = "icon-[lucide--arrow-right]" @@ -75,6 +76,20 @@ component MegaMenu { } } + 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() @@ -113,9 +128,8 @@ component MegaMenu { class="wrn-mega__trigger" aria-haspopup="true" aria-expanded='{visible}' - @click='togglePanel(event)' - @mouseenter='showPanel(event)' - @focus='showPanel(event)' + @click='activatePanel(event)' + @mouseenter='hoverPanel(event)' > {label} @@ -274,7 +288,9 @@ component MegaMenu { 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); @@ -284,16 +300,19 @@ component MegaMenu { .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 { position: fixed; top: auto; right: 1rem; left: 1rem; width: auto; max-width: none; } + .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; grid-template-columns: auto minmax(0, 1fr) auto; gap: 1.25rem; } + .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)); } @@ -301,7 +320,7 @@ component MegaMenu { .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; padding: 0.75rem; align-content: start; gap: 0.25rem; border-radius: var(--wrn-radius-lg); background: var(--wrn-color-surface-soft); } + .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; } @@ -421,13 +440,16 @@ component MegaMenu { 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; } + .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; } } diff --git a/packages/ui/components/Navbar.wrn b/packages/ui/components/Navbar.wrn index e6009fda..39bf4c49 100644 --- a/packages/ui/components/Navbar.wrn +++ b/packages/ui/components/Navbar.wrn @@ -196,6 +196,7 @@ size: string = "default" panelAlign="{megaData(item).panelAlign || 'start'}" columnCount="{megaData(item).columnCount || 0}" fullWidth="{megaData(item).fullWidth || false}" + openOnHover="{openOnHover}" 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 7cb0c7c3..cd9143d3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.8.29", + "version": "0.8.30", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index fb3f9bb9..476cb9bd 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -3124,6 +3124,15 @@ test("mega menu bridges the gap between its trigger and panel", async () => { expect(source).toMatch(/\.wrn-mega__panel::before \{[^}]*bottom: 100%/s); }); +test("mega menu keeps hover activation and click activation from cancelling each other", () => { + 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).not.toContain("@focus='showPanel(event)'"); + expect(source).toContain("max-height: calc(100dvh - 5.5rem)"); +}); + test("stepper shows only the active step content and offers back, next and skip", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const html = await renderComponent(source, {