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, {