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

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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.33",
"version": "0.8.34",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -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;
+38 -4
View File
@@ -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);
}
});
});
+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]);
});
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(
`<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.
- 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`
- Outputs: `open({ sourceEvent: Event })`, `close({ reason: string })`, `select({ item: object; value: string })`
+7
View File
@@ -8401,6 +8401,13 @@
"default": "false",
"options": []
},
{
"name": "openOnHover",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "actionLabel",
"type": "string",
+29 -7
View File
@@ -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)'
>
<span class='wrn-mega__trigger-icon {icon}' data-show="icon" aria-hidden="true"></span>
<span class="wrn-mega__trigger-label">{label}</span>
@@ -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; }
}
+1
View File
@@ -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]'}"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.8.29",
"version": "0.8.30",
"private": true,
"type": "module",
"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);
});
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, {