fix(ui): make mega menus work under strict CSP
Quality / quality (windows-latest) (push) Waiting to run
Quality / quality (ubuntu-latest) (push) Failing after 9m55s

This commit is contained in:
2026-08-24 21:52:25 +05:30
parent 9067e68f69
commit 0618782355
12 changed files with 99 additions and 19 deletions
+2 -2
View File
@@ -342,7 +342,7 @@
}, },
"packages/csr": { "packages/csr": {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.8.33", "version": "0.8.34",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
@@ -688,7 +688,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.8.29", "version": "0.8.30",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
+2 -2
View File
@@ -55,12 +55,12 @@
"packages/ui/components/Map.wrn": "3e6d5de5a21dd050c5c65950d6fb2f89af4024e1856a48f055b65b88fcd7b890", "packages/ui/components/Map.wrn": "3e6d5de5a21dd050c5c65950d6fb2f89af4024e1856a48f055b65b88fcd7b890",
"packages/ui/components/MarketingSectionHeader.wrn": "39b79499b77dc1c7d34e4e5ecb45a9ddc6182f78c2a2586671c4fa07f44679ad", "packages/ui/components/MarketingSectionHeader.wrn": "39b79499b77dc1c7d34e4e5ecb45a9ddc6182f78c2a2586671c4fa07f44679ad",
"packages/ui/components/Marquee.wrn": "81cd80732be34c8d7e4bf28b00347e1a9273166afdf66589d9f5d5774b69403b", "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/MetricCard.wrn": "513a6e99be3d9db46d96af9942b51331a0a048b184603e77e814506f0a52bdc5",
"packages/ui/components/MetricGrid.wrn": "1fcebf51dcbe8520fe16bda904c1ef206c25c3900438d53877492c64ea21a1a5", "packages/ui/components/MetricGrid.wrn": "1fcebf51dcbe8520fe16bda904c1ef206c25c3900438d53877492c64ea21a1a5",
"packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2", "packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2",
"packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a", "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/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54",
"packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39", "packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39",
"packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602", "packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.8.33", "version": "0.8.34",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -90,7 +90,7 @@ const CONTROLLER_LOADER = String.raw`
if (!componentControllerPromise) { if (!componentControllerPromise) {
componentControllerPromise = new Promise(function (resolve, reject) { componentControllerPromise = new Promise(function (resolve, reject) {
var script = document.createElement("script"); var script = document.createElement("script");
script.src = componentControllerUrl; script.src = trustedScriptURL(componentControllerUrl);
script.defer = true; script.defer = true;
script.onload = resolve; script.onload = resolve;
script.onerror = reject; script.onerror = reject;
+38 -4
View File
@@ -26,6 +26,40 @@ export const REACTIVE_RUNTIME = String.raw`
var behaviorObserver; var behaviorObserver;
var clientModuleCache = new Map(); 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 * Two builtin chains the runtime reaches for constantly. Aliasing them is
* not only shorter: hasOwn keeps prototype keys from reading as data, and * 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); var next = markup == null ? "" : String(markup);
if (htmlNode.innerHTML !== next) { if (htmlNode.innerHTML !== next) {
htmlNode.innerHTML = next; htmlNode.innerHTML = trustedHTML(next);
} }
}); });
runHtml(); runHtml();
@@ -2002,7 +2036,7 @@ export const REACTIVE_RUNTIME = String.raw`
} }
function clearControlContent() { function clearControlContent() {
if (!rangeEnd) { block.innerHTML = ""; return; } if (!rangeEnd) { block.innerHTML = trustedHTML(""); return; }
while (block.nextSibling && block.nextSibling !== rangeEnd) { while (block.nextSibling && block.nextSibling !== rangeEnd) {
block.parentNode.removeChild(block.nextSibling); block.parentNode.removeChild(block.nextSibling);
} }
@@ -2010,7 +2044,7 @@ export const REACTIVE_RUNTIME = String.raw`
function appendControlContent(markup, locals) { function appendControlContent(markup, locals) {
var template = document.createElement("template"); var template = document.createElement("template");
template.innerHTML = markup || ""; template.innerHTML = trustedHTML(markup || "");
var fragment = template.content; var fragment = template.content;
var elements = toArray(fragment.childNodes).filter(function (node) { var elements = toArray(fragment.childNodes).filter(function (node) {
return node.nodeType === 1; return node.nodeType === 1;
@@ -2399,7 +2433,7 @@ export const REACTIVE_RUNTIME = String.raw`
var markup = value == null ? "" : String(value); var markup = value == null ? "" : String(value);
if (node.innerHTML !== markup) { if (node.innerHTML !== markup) {
node.innerHTML = markup; node.innerHTML = trustedHTML(markup);
} }
}); });
}); });
+7
View File
@@ -62,6 +62,13 @@ test("split runtime hydrates a controller only from the controller asset", () =>
expect(win.document.activeElement).toBe(buttons[1]); 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", () => { test("a page without controller markers does not request the controller asset", () => {
const win = mount( const win = mount(
`<main data-scope="count: 1"><span>{count}</span></main>`, `<main data-scope="count: 1"><span>{count}</span></main>`,
+1 -1
View File
@@ -808,7 +808,7 @@ Show responsive hierarchical navigation with home support, separators, current-p
Theme-aware, responsive mega menu component. Theme-aware, responsive mega menu component.
- Mount: `data-component="MegaMenu"` - Mount: `data-component="MegaMenu"`
- 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`, `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<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 = ""`
- Slots: `default` - 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 })`
+7
View File
@@ -8401,6 +8401,13 @@
"default": "false", "default": "false",
"options": [] "options": []
}, },
{
"name": "openOnHover",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{ {
"name": "actionLabel", "name": "actionLabel",
"type": "string", "type": "string",
+29 -7
View File
@@ -34,6 +34,7 @@ component MegaMenu {
panelAlign: string = "start" panelAlign: string = "start"
columnCount: number = 0 columnCount: number = 0
fullWidth: boolean = false fullWidth: boolean = false
openOnHover: boolean = true
actionLabel: string = "" actionLabel: string = ""
actionHref: string = "" actionHref: string = ""
actionIcon: string = "icon-[lucide--arrow-right]" 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) { client function handleKeydown(sourceEvent) {
if (sourceEvent.key === "Escape" && visible) { if (sourceEvent.key === "Escape" && visible) {
sourceEvent.preventDefault() sourceEvent.preventDefault()
@@ -113,9 +128,8 @@ component MegaMenu {
class="wrn-mega__trigger" class="wrn-mega__trigger"
aria-haspopup="true" aria-haspopup="true"
aria-expanded='{visible}' aria-expanded='{visible}'
@click='togglePanel(event)' @click='activatePanel(event)'
@mouseenter='showPanel(event)' @mouseenter='hoverPanel(event)'
@focus='showPanel(event)'
> >
<span class='wrn-mega__trigger-icon {icon}' data-show="icon" aria-hidden="true"></span> <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__trigger-label">{label}</span>
@@ -274,7 +288,9 @@ component MegaMenu {
left: 0; left: 0;
width: max-content; width: max-content;
max-width: min(60rem, calc(100vw - 2rem)); max-width: min(60rem, calc(100vw - 2rem));
max-height: calc(100dvh - 5.5rem);
padding: 1rem; padding: 1rem;
overflow: hidden;
border: 1px solid var(--wrn-color-border); border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-lg); border-radius: var(--wrn-radius-lg);
background: var(--wrn-color-surface); 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="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="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-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="center"] .wrn-mega__panel { left: 50%; transform: translateX(-50%); }
.wrn-mega[data-panel-align="end"] .wrn-mega__panel { right: 0; left: auto; } .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 { .wrn-mega__columns {
display: grid; display: grid;
min-height: 0;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1.25rem; 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="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="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[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 { 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:hover { background: var(--wrn-color-surface); }
.wrn-mega__rail-link > span:last-child { display: grid; gap: 0.2rem; } .wrn-mega__rail-link > span:last-child { display: grid; gap: 0.2rem; }
@@ -421,13 +440,16 @@ component MegaMenu {
position: static; position: static;
width: auto; width: auto;
max-width: 100%; max-width: 100%;
max-height: none;
overflow: visible;
box-shadow: none; box-shadow: none;
} }
.wrn-mega__columns { .wrn-mega__columns {
grid-template-columns: 1fr; 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__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; } .wrn-mega[data-variant="icon-grid"] .wrn-mega__list { grid-template-columns: 1fr; }
} }
+1
View File
@@ -196,6 +196,7 @@ size: string = "default"
panelAlign="{megaData(item).panelAlign || 'start'}" panelAlign="{megaData(item).panelAlign || 'start'}"
columnCount="{megaData(item).columnCount || 0}" columnCount="{megaData(item).columnCount || 0}"
fullWidth="{megaData(item).fullWidth || false}" fullWidth="{megaData(item).fullWidth || false}"
openOnHover="{openOnHover}"
actionLabel="{megaData(item).actionLabel || ''}" actionLabel="{megaData(item).actionLabel || ''}"
actionHref="{megaData(item).actionHref || ''}" actionHref="{megaData(item).actionHref || ''}"
actionIcon="{megaData(item).actionIcon || 'icon-[lucide--arrow-right]'}" actionIcon="{megaData(item).actionIcon || 'icon-[lucide--arrow-right]'}"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.8.29", "version": "0.8.30",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+9
View File
@@ -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); 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 () => { test("stepper shows only the active step content and offers back, next and skip", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const html = await renderComponent(source, { const html = await renderComponent(source, {