fix(ui): make mega menus work under strict CSP
This commit is contained in:
@@ -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:*",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.33",
|
||||
"version": "0.8.34",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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 })`
|
||||
|
||||
|
||||
@@ -8401,6 +8401,13 @@
|
||||
"default": "false",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "openOnHover",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"default": "true",
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"name": "actionLabel",
|
||||
"type": "string",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.29",
|
||||
"version": "0.8.30",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.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, {
|
||||
|
||||
Reference in New Issue
Block a user