fix(runtime): stabilize navigation and custom errors
This commit is contained in:
@@ -317,7 +317,7 @@
|
||||
},
|
||||
"packages/csr": {
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.19",
|
||||
"version": "0.8.20",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
@@ -332,7 +332,7 @@
|
||||
},
|
||||
"packages/dev-server": {
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.29",
|
||||
"version": "0.8.30",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/cache": "workspace:*",
|
||||
@@ -617,7 +617,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.16",
|
||||
"version": "0.8.17",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.19",
|
||||
"version": "0.8.20",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -367,6 +367,27 @@ export const NAV_RUNTIME = String.raw`
|
||||
});
|
||||
}
|
||||
|
||||
function syncI18n(nextDocument) {
|
||||
var script = Array.prototype.find.call(
|
||||
nextDocument.querySelectorAll("script:not([src])"),
|
||||
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
||||
);
|
||||
if (!script) return;
|
||||
var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
|
||||
if (!match) return;
|
||||
try {
|
||||
var incoming = JSON.parse(match[1]);
|
||||
var current = window.__wrnI18n || {};
|
||||
var translator = current.t;
|
||||
var setter = current.set;
|
||||
window.__wrnI18n = incoming;
|
||||
if (translator) window.__wrnI18n.t = translator;
|
||||
if (setter) window.__wrnI18n.set = setter;
|
||||
} catch (error) {
|
||||
console.error("[wrnexus] failed to synchronize i18n data", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run explicit component cleanup before removing the existing page.
|
||||
*
|
||||
@@ -516,6 +537,8 @@ export const NAV_RUNTIME = String.raw`
|
||||
|
||||
syncPreservationPolicy(doc);
|
||||
|
||||
syncI18n(doc);
|
||||
|
||||
syncWrnStyles(doc);
|
||||
|
||||
var importedNodes = [];
|
||||
@@ -568,6 +591,10 @@ export const NAV_RUNTIME = String.raw`
|
||||
*/
|
||||
rehydrate(currentApp);
|
||||
|
||||
if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
|
||||
window.__wrnLang.bind(currentApp);
|
||||
}
|
||||
|
||||
if (!isPop) {
|
||||
history.pushState(
|
||||
{
|
||||
|
||||
@@ -139,6 +139,28 @@ test("rebinds theme controls after swapping the page", async () => {
|
||||
expect(win.document.querySelector("[data-wrn-theme-toggle]")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("synchronizes and rebinds i18n data during client navigation", async () => {
|
||||
install(`<div id="app"><a href="/about" id="lnk">About</a></div>`);
|
||||
let boundRoot: unknown;
|
||||
const translate = () => "translated";
|
||||
const setLanguage = () => true;
|
||||
win.__wrnI18n = { lang: "en", messages: { old: "Old" }, t: translate, set: setLanguage };
|
||||
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
||||
nextHtml =
|
||||
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
||||
`<script>window.__wrnI18n={"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}};</script>` +
|
||||
`</body></html>`;
|
||||
|
||||
win.document.getElementById("lnk").click();
|
||||
await flush();
|
||||
|
||||
expect(win.__wrnI18n.lang).toBe("mr");
|
||||
expect(win.__wrnI18n.messages.home.title).toBe("नवीन");
|
||||
expect(win.__wrnI18n.t).toBe(translate);
|
||||
expect(win.__wrnI18n.set).toBe(setLanguage);
|
||||
expect(boundRoot).toBe(win.document.getElementById("app"));
|
||||
});
|
||||
|
||||
test("unmounts and remounts package runtimes during client navigation", async () => {
|
||||
install(
|
||||
`<div id="app"><div data-wrnexus-runtime="captcha">Old</div><a href="/next" id="lnk">Next</a></div>`,
|
||||
|
||||
@@ -66,6 +66,14 @@ interface RunningServer {
|
||||
|
||||
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
|
||||
|
||||
### Custom not-found handlers
|
||||
|
||||
Add `app/pages/404.wrn` to customize unmatched frontend routes. The rendered
|
||||
page keeps the requested response's HTTP `404` status. Add `app/api/404.ts`
|
||||
with normal HTTP method exports to customize unmatched backend/API responses;
|
||||
its response body and headers are preserved and its status is normalized to
|
||||
`404`.
|
||||
|
||||
`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache
|
||||
`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and
|
||||
`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.29",
|
||||
"version": "0.8.30",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -672,6 +672,25 @@ export const HMR_CLIENT_JS = `
|
||||
pendingSync = false;
|
||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
var i18nScript = Array.prototype.find.call(
|
||||
doc.querySelectorAll("script:not([src])"),
|
||||
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
||||
);
|
||||
if (i18nScript) {
|
||||
var i18nMatch = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(i18nScript.textContent || "").trim());
|
||||
if (i18nMatch) {
|
||||
try {
|
||||
var incomingI18n = JSON.parse(i18nMatch[1]);
|
||||
var existingI18n = window.__wrnI18n || {};
|
||||
incomingI18n.t = existingI18n.t;
|
||||
incomingI18n.set = existingI18n.set;
|
||||
window.__wrnI18n = incomingI18n;
|
||||
} catch (error) {
|
||||
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.title) {
|
||||
document.title = doc.title;
|
||||
}
|
||||
@@ -704,6 +723,10 @@ export const HMR_CLIENT_JS = `
|
||||
window.__wrnexusHydrateCsrFetches(document);
|
||||
}
|
||||
|
||||
if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
|
||||
window.__wrnLang.bind(document);
|
||||
}
|
||||
|
||||
if (
|
||||
window.wrnTheme &&
|
||||
typeof window.wrnTheme.bind === "function"
|
||||
@@ -1449,7 +1472,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
|
||||
async function handleApi(ctx: Context): Promise<Response> {
|
||||
const matched = router.matchApi(ctx.url.pathname);
|
||||
if (!matched) return Response.json({ error: "Not Found" }, { status: 404 });
|
||||
if (!matched) {
|
||||
const fallback = router.matchApi("/api/404");
|
||||
if (fallback && ctx.url.pathname !== "/api/404") {
|
||||
const originalPath = ctx.url.pathname;
|
||||
ctx.url.pathname = "/api/404";
|
||||
try {
|
||||
const response = await handleApi(ctx);
|
||||
return new Response(response.body, {
|
||||
status: 404,
|
||||
headers: response.headers,
|
||||
});
|
||||
} finally {
|
||||
ctx.url.pathname = originalPath;
|
||||
}
|
||||
}
|
||||
return Response.json({ error: "Not Found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Expose the canonical matched route to package dispatchers. A package may
|
||||
// contribute several URL paths from one module, and request URLs can be
|
||||
@@ -1606,7 +1645,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
}),
|
||||
);
|
||||
}
|
||||
const response = renderNotFound();
|
||||
let response: Response;
|
||||
const fallback = router.matchPage("/404");
|
||||
if (fallback && ctx.url.pathname !== "/404") {
|
||||
const originalPath = ctx.url.pathname;
|
||||
ctx.url.pathname = "/404";
|
||||
try {
|
||||
const rendered = await handlePage(ctx);
|
||||
response = new Response(rendered.body, {
|
||||
status: 404,
|
||||
headers: rendered.headers,
|
||||
});
|
||||
} finally {
|
||||
ctx.url.pathname = originalPath;
|
||||
}
|
||||
} else {
|
||||
response = renderNotFound();
|
||||
}
|
||||
if (!isMobileRequest) return response;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("x-wrnexus-original-status", "404");
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { buildRouter } from "@wrnexus/router";
|
||||
import { createHandlers, type RuntimeDeps } from "../src/runtime.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
function customNotFoundRuntime() {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-not-found-"));
|
||||
roots.push(root);
|
||||
const app = join(root, "app");
|
||||
mkdirSync(join(app, "pages"), { recursive: true });
|
||||
mkdirSync(join(app, "api"), { recursive: true });
|
||||
writeFileSync(join(app, "pages/404.ts"), "export default () => '';");
|
||||
writeFileSync(join(app, "api/404.ts"), "export const GET = () => null;");
|
||||
return createHandlers({
|
||||
mode: "production",
|
||||
hmr: false,
|
||||
router: buildRouter(app),
|
||||
loadModule: async (file) =>
|
||||
file.includes(`${join("api", "404")}.ts`)
|
||||
? { GET: () => Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }) }
|
||||
: { default: () => "<main><h1>That page is gone</h1></main>" },
|
||||
getMiddleware: async () => [],
|
||||
assets: { serve: async () => null },
|
||||
} satisfies RuntimeDeps);
|
||||
}
|
||||
|
||||
const server = { upgrade: () => false };
|
||||
|
||||
test("renders app/pages/404 with an HTTP 404 status", async () => {
|
||||
const response = await customNotFoundRuntime().fetch(
|
||||
new Request("https://example.test/missing"),
|
||||
server,
|
||||
);
|
||||
expect(response?.status).toBe(404);
|
||||
expect(await response?.text()).toContain("That page is gone");
|
||||
});
|
||||
|
||||
test("uses app/api/404 for unmatched API routes and preserves headers", async () => {
|
||||
const response = await customNotFoundRuntime().fetch(
|
||||
new Request("https://example.test/api/missing"),
|
||||
server,
|
||||
);
|
||||
expect(response?.status).toBe(404);
|
||||
expect(response?.headers.get("x-custom")).toBe("yes");
|
||||
expect(await response?.json()).toEqual({ code: "CUSTOM_NOT_FOUND" });
|
||||
});
|
||||
@@ -10,14 +10,18 @@ component Card {
|
||||
|
||||
props {
|
||||
title: string = "Card title"
|
||||
titleKey: string = ""
|
||||
subtitle: string = ""
|
||||
subtitleKey: string = ""
|
||||
description: string = ""
|
||||
descriptionKey: string = ""
|
||||
header: string = ""
|
||||
footer: string = ""
|
||||
imageSrc: string = ""
|
||||
imageAlt: string = ""
|
||||
imagePosition: string = "top"
|
||||
actionLabel: string = ""
|
||||
actionLabelKey: string = ""
|
||||
actionHref: string = ""
|
||||
headerActions: unknown[] = []
|
||||
navigation: unknown[] = []
|
||||
@@ -105,13 +109,13 @@ component Card {
|
||||
|
||||
<div class="wrn-next__card-content">
|
||||
{#if item.title || item.label}
|
||||
<h3>{item.title || item.label}</h3>
|
||||
<h3 data-t='{item.titleKey || item.labelKey || ""}'>{item.title || item.label}</h3>
|
||||
{/if}
|
||||
{#if item.subtitle}
|
||||
<p class="wrn-next__card-subtitle">{item.subtitle}</p>
|
||||
<p class="wrn-next__card-subtitle" data-t='{item.subtitleKey || ""}'>{item.subtitle}</p>
|
||||
{/if}
|
||||
{#if item.description}
|
||||
<p class="wrn-next__card-description">{item.description}</p>
|
||||
<p class="wrn-next__card-description" data-t='{item.descriptionKey || ""}'>{item.description}</p>
|
||||
{/if}
|
||||
{#if item.actionLabel || item.href}
|
||||
<a
|
||||
@@ -119,7 +123,7 @@ component Card {
|
||||
class="wrn-next__card-action"
|
||||
@click='output.action({ item: item, index: itemIndex })'
|
||||
>
|
||||
<span>{item.actionLabel || "Learn more"}</span>
|
||||
<span data-t='{item.actionLabelKey || ""}'>{item.actionLabel || "Learn more"}</span>
|
||||
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
|
||||
</a>
|
||||
{/if}
|
||||
@@ -168,10 +172,10 @@ component Card {
|
||||
<p class="wrn-next__card-eyebrow">{header}</p>
|
||||
{/if}
|
||||
{#if title}
|
||||
<h3>{title}</h3>
|
||||
<h3 data-t='{titleKey}'>{title}</h3>
|
||||
{/if}
|
||||
{#if subtitle}
|
||||
<p class="wrn-next__card-subtitle">{subtitle}</p>
|
||||
<p class="wrn-next__card-subtitle" data-t='{subtitleKey}'>{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -268,7 +272,7 @@ component Card {
|
||||
</div>
|
||||
{:else}
|
||||
{#if description}
|
||||
<p class="wrn-next__card-description">{description}</p>
|
||||
<p class="wrn-next__card-description" data-t='{descriptionKey}'>{description}</p>
|
||||
{/if}
|
||||
<slot></slot>
|
||||
{/if}
|
||||
@@ -285,7 +289,7 @@ component Card {
|
||||
class="wrn-next__card-action"
|
||||
@click='output.action({ href: actionHref, label: actionLabel })'
|
||||
>
|
||||
<span>{actionLabel}</span>
|
||||
<span data-t='{actionLabelKey}'>{actionLabel}</span>
|
||||
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -388,11 +388,11 @@ component Carousel {
|
||||
<img src="{item.imageSrc}" alt="{item.imageAlt || item.title || ''}" />
|
||||
{/if}
|
||||
<div class="wrn-next__carousel-slide-content">
|
||||
{#if item.eyebrow}<span>{item.eyebrow}</span>{/if}
|
||||
{#if item.title || item.label}<h4>{item.title || item.label}</h4>{/if}
|
||||
{#if item.description}<p>{item.description}</p>{/if}
|
||||
{#if item.eyebrow}<span data-t='{item.eyebrowKey || ""}'>{item.eyebrow}</span>{/if}
|
||||
{#if item.title || item.label}<h4 data-t='{item.titleKey || item.labelKey || ""}'>{item.title || item.label}</h4>{/if}
|
||||
{#if item.description}<p data-t='{item.descriptionKey || ""}'>{item.description}</p>{/if}
|
||||
{#if item.actionLabel}
|
||||
<a href="{item.actionHref || '#'}">{item.actionLabel}</a>
|
||||
<a href="{item.actionHref || '#'}" data-t='{item.actionLabelKey || ""}'>{item.actionLabel}</a>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -13,9 +13,23 @@ component Footer {
|
||||
columns: number = 3
|
||||
maxWidth: string = "compact"
|
||||
copyright: string = ""
|
||||
copyrightKey: string = ""
|
||||
translationPrefix: string = ""
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function translationKey(item, field) {
|
||||
if (!item) return ""
|
||||
const explicit = item[field + "Key"]
|
||||
if (explicit) return explicit
|
||||
if (!translationPrefix) return ""
|
||||
const source = item.value || item.href || item.label || item.title || ""
|
||||
const slug = String(source).replace(/^\/+|\/+$/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase()
|
||||
return slug ? translationPrefix + "." + slug + (field === "description" ? ".description" : "") : ""
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<footer
|
||||
{...attrs}
|
||||
@@ -58,12 +72,13 @@ component Footer {
|
||||
<h2
|
||||
id='footer-heading-{itemIndex}'
|
||||
class="wrn-footer__heading"
|
||||
data-t='{translationKey(item, "label")}'
|
||||
>
|
||||
{item.label || item.title}
|
||||
</h2>
|
||||
|
||||
{#if item.description}
|
||||
<p>
|
||||
<p data-t='{translationKey(item, "description")}'>
|
||||
{item.description}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -77,7 +92,7 @@ component Footer {
|
||||
href='{child.href || "#"}'
|
||||
target='{child.target || ""}'
|
||||
rel='{child.external ? "noopener noreferrer" : (child.rel || "")}'
|
||||
aria-current='{child.active ? "page" : ""}'
|
||||
aria-current='{child.active ? "page" : "false"}'
|
||||
class="wrn-footer__link"
|
||||
@click='output.select({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex }); child.action && output.action({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex })'
|
||||
>
|
||||
@@ -89,7 +104,7 @@ component Footer {
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span>
|
||||
<span data-t='{translationKey(child, "label")}'>
|
||||
{child.label || child.title}
|
||||
</span>
|
||||
|
||||
@@ -125,7 +140,7 @@ component Footer {
|
||||
href='{item.href || "#"}'
|
||||
target='{item.target || ""}'
|
||||
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
|
||||
aria-current='{item.active ? "page" : ""}'
|
||||
aria-current='{item.active ? "page" : "false"}'
|
||||
class="wrn-footer__link"
|
||||
@click='output.select({ item: item, itemIndex: itemIndex }); item.action && output.action({ item: item, itemIndex: itemIndex })'
|
||||
>
|
||||
@@ -137,7 +152,7 @@ component Footer {
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span>
|
||||
<span data-t='{translationKey(item, "label")}'>
|
||||
{item.label || item.title}
|
||||
</span>
|
||||
|
||||
@@ -177,6 +192,7 @@ component Footer {
|
||||
<h2
|
||||
id='footer-heading-{itemIndex}'
|
||||
class="wrn-footer__heading"
|
||||
data-t='{translationKey(item, "label")}'
|
||||
>
|
||||
{item.title || item.label}
|
||||
</h2>
|
||||
@@ -264,7 +280,7 @@ component Footer {
|
||||
<p
|
||||
class="wrn-footer__copyright"
|
||||
>
|
||||
{copyright}
|
||||
<span data-t='{copyrightKey}'>{copyright}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -20,12 +20,22 @@ size: string = "default"
|
||||
openOnHover: boolean = false
|
||||
maxWidth: string = "full"
|
||||
mobileLabel: string = "Toggle navigation"
|
||||
translationPrefix: string = ""
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state mobileOpen: boolean = false
|
||||
|
||||
functions {
|
||||
shared function translationKey(item, field) {
|
||||
if (!item) return ""
|
||||
const explicit = item[field + "Key"]
|
||||
if (explicit) return explicit
|
||||
if (!translationPrefix) return ""
|
||||
const source = item.value || item.href || item.label || item.title || ""
|
||||
const slug = String(source).replace(/^\/+|\/+$/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase()
|
||||
return slug ? translationPrefix + "." + slug + (field === "description" ? ".description" : "") : ""
|
||||
}
|
||||
client function toggleNavigation() {
|
||||
mobileOpen = !mobileOpen
|
||||
output.toggle({ open: mobileOpen })
|
||||
@@ -75,8 +85,8 @@ size: string = "default"
|
||||
{/if}
|
||||
{#if brand.label || brand.description}
|
||||
<span class="wrn-navbar__brand-copy">
|
||||
{#if brand.label}<strong>{brand.label}</strong>{/if}
|
||||
{#if brand.description}<small>{brand.description}</small>{/if}
|
||||
{#if brand.label}<strong data-t="{brand.labelKey || (translationPrefix ? translationPrefix + '.brand' : '')}">{brand.label}</strong>{/if}
|
||||
{#if brand.description}<small data-t="{brand.descriptionKey || (translationPrefix ? translationPrefix + '.brand.description' : '')}">{brand.description}</small>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
@@ -92,26 +102,26 @@ size: string = "default"
|
||||
<details class="wrn-navbar__dropdown wrn-navbar__dropdown--{item.type || 'dropdown'}" name="wrn-navbar-menu" @toggle="toggleDropdown(event, item)">
|
||||
<summary data-wrn-roving-item="true" aria-current="{isItemActive(item) ? 'page' : 'false'}">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
<span data-t="{translationKey(item, 'label')}">{item.label}</span>
|
||||
<span class="wrn-navbar__chevron" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="wrn-navbar__panel wrn-navbar__panel--columns-{item.columns || 1}">
|
||||
{#if item.description}<p class="wrn-navbar__panel-intro">{item.description}</p>{/if}
|
||||
{#if item.description}<p class="wrn-navbar__panel-intro" data-t="{translationKey(item, 'description')}">{item.description}</p>{/if}
|
||||
{#each item.children as child}
|
||||
<div class="wrn-navbar__group">
|
||||
{#if child.children && child.children.length}
|
||||
{#if child.label}<strong class="wrn-navbar__group-title">{child.label}</strong>{/if}
|
||||
{#if child.description}<small>{child.description}</small>{/if}
|
||||
{#if child.label}<strong class="wrn-navbar__group-title" data-t="{translationKey(child, 'label')}">{child.label}</strong>{/if}
|
||||
{#if child.description}<small data-t="{translationKey(child, 'description')}">{child.description}</small>{/if}
|
||||
{#each child.children as nested}
|
||||
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : 'false'}" @click="selectItem(nested, 3)">
|
||||
{#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if}
|
||||
<span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span>
|
||||
<span><strong data-t="{translationKey(nested, 'label')}">{nested.label}</strong>{#if nested.description}<small data-t="{translationKey(nested, 'description')}">{nested.description}</small>{/if}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{:else}
|
||||
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : 'false'}" @click="selectItem(child, 2)">
|
||||
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
|
||||
<span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span>
|
||||
<span><strong data-t="{translationKey(child, 'label')}">{child.label}</strong>{#if child.description}<small data-t="{translationKey(child, 'description')}">{child.description}</small>{/if}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -121,7 +131,7 @@ size: string = "default"
|
||||
{:else}
|
||||
<a class="wrn-navbar__menu-link" data-wrn-roving-item="true" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : 'false'}" @click="selectItem(item, 1)">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
<span data-t="{translationKey(item, 'label')}">{item.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -131,7 +141,7 @@ size: string = "default"
|
||||
{#each actions as item}
|
||||
<a class="wrn-navbar__action wrn-navbar__action--{item.variant || 'link'}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" @click="selectAction(item)">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
<span data-t="{translationKey(item, 'label')}">{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<slot name="actions" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.16",
|
||||
"version": "0.8.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -463,6 +463,23 @@ test("footer renders header and link entries with the requested column count", a
|
||||
);
|
||||
});
|
||||
|
||||
test("footer emits valid current-page state and translation markers for data items", async () => {
|
||||
const source = readFileSync(uiComponentPath("Footer"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
items: [
|
||||
{
|
||||
type: "header",
|
||||
label: "Services",
|
||||
labelKey: "footer.services",
|
||||
items: [{ label: "Report", labelKey: "footer.report", href: "/report" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(html).toContain('data-t="footer.services"');
|
||||
expect(html).toContain('data-t="footer.report"');
|
||||
expect(html).not.toContain('aria-current=""');
|
||||
});
|
||||
|
||||
test("component-system CSS includes responsive, theme-token, focus, and reduced-motion rules", () => {
|
||||
const css = uiStyles();
|
||||
expect(css).toContain("@media (max-width: 768px)");
|
||||
|
||||
Reference in New Issue
Block a user