Compare commits

..
Author SHA1 Message Date
Clintchiz d78707be9f fix(forms): surface validation and recover schema drift
Quality / quality (ubuntu-latest) (push) Failing after 10m23s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 13:01:10 +05:30
Clintchiz 1d16ef1e82 chore(release): refresh ui consumers
Quality / quality (ubuntu-latest) (push) Failing after 9m50s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 12:33:40 +05:30
Clintchiz 12a1014db2 fix(ui): bind textarea values without markup
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 12:30:46 +05:30
Clintchiz 52b3e6d378 fix(csr): preserve translations across navigation
Quality / quality (ubuntu-latest) (push) Failing after 9m51s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 12:26:01 +05:30
Clintchiz f88dd47408 fix(runtime): stabilize navigation and custom errors
Quality / quality (ubuntu-latest) (push) Failing after 23s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-15 10:44:06 +05:30
Clintchiz f0447fddb0 fix(db): share registry across bundled copies
Quality / quality (ubuntu-latest) (push) Failing after 9m51s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-13 19:10:51 +05:30
23 changed files with 461 additions and 60 deletions
+4 -4
View File
@@ -268,7 +268,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.8.31", "version": "0.8.33",
"bin": { "bin": {
"wrnexus": "src/index.ts", "wrnexus": "src/index.ts",
}, },
@@ -317,7 +317,7 @@
}, },
"packages/csr": { "packages/csr": {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.8.19", "version": "0.8.21",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
@@ -332,7 +332,7 @@
}, },
"packages/dev-server": { "packages/dev-server": {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.8.29", "version": "0.8.31",
"dependencies": { "dependencies": {
"@wrnexus/authz": "workspace:*", "@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*", "@wrnexus/cache": "workspace:*",
@@ -617,7 +617,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.8.16", "version": "0.8.18",
"dependencies": { "dependencies": {
"@wrnexus/core": "workspace:*", "@wrnexus/core": "workspace:*",
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.8.32", "version": "0.8.34",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.8.19", "version": "0.8.21",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+48
View File
@@ -367,6 +367,48 @@ 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;
function mergeCatalog(base, update) {
var output = {};
Object.keys(base && typeof base === "object" ? base : {}).forEach(function (key) {
var value = base[key];
output[key] = value && typeof value === "object" && !Array.isArray(value)
? mergeCatalog(value, {})
: value;
});
Object.keys(update && typeof update === "object" ? update : {}).forEach(function (key) {
var left = output[key];
var right = update[key];
output[key] = left && right && typeof left === "object" && typeof right === "object" && !Array.isArray(left) && !Array.isArray(right)
? mergeCatalog(left, right)
: right;
});
return output;
}
if (current.lang && current.lang === incoming.lang) {
incoming.messages = mergeCatalog(current.messages, incoming.messages);
incoming.fallbackMessages = mergeCatalog(current.fallbackMessages, incoming.fallbackMessages);
}
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. * Run explicit component cleanup before removing the existing page.
* *
@@ -516,6 +558,8 @@ export const NAV_RUNTIME = String.raw`
syncPreservationPolicy(doc); syncPreservationPolicy(doc);
syncI18n(doc);
syncWrnStyles(doc); syncWrnStyles(doc);
var importedNodes = []; var importedNodes = [];
@@ -568,6 +612,10 @@ export const NAV_RUNTIME = String.raw`
*/ */
rehydrate(currentApp); rehydrate(currentApp);
if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
window.__wrnLang.bind(currentApp);
}
if (!isPop) { if (!isPop) {
history.pushState( history.pushState(
{ {
+52
View File
@@ -139,6 +139,58 @@ test("rebinds theme controls after swapping the page", async () => {
expect(win.document.querySelector("[data-wrn-theme-toggle]")).not.toBeNull(); 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("preserves same-language translations when an incoming navigation catalog is partial", async () => {
install(`<div id="app"><a href="/about" id="lnk">About</a></div>`);
win.__wrnI18n = {
lang: "en",
messages: { navigation: { home: "Home" }, footer: { contact: "Contact" } },
fallbackMessages: {},
};
win.__wrnLang = {
bind: (root: ParentNode) => {
root.querySelectorAll("[data-t]").forEach((node) => {
const parts = String(node.getAttribute("data-t") || "").split(".");
let value: any = win.__wrnI18n.messages;
for (const part of parts) value = value?.[part];
node.textContent = typeof value === "string" ? value : node.getAttribute("data-t");
});
},
};
nextHtml =
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
`<script>window.__wrnI18n={"lang":"en","messages":{},"fallbackMessages":{}};</script>` +
`</body></html>`;
win.document.getElementById("lnk").click();
await flush();
expect(win.__wrnI18n.messages.navigation.home).toBe("Home");
expect(win.__wrnI18n.messages.footer.contact).toBe("Contact");
expect(win.document.querySelector("[data-t]")?.textContent).toBe("Home");
});
test("unmounts and remounts package runtimes during client navigation", async () => { test("unmounts and remounts package runtimes during client navigation", async () => {
install( install(
`<div id="app"><div data-wrnexus-runtime="captcha">Old</div><a href="/next" id="lnk">Next</a></div>`, `<div id="app"><div data-wrnexus-runtime="captcha">Old</div><a href="/next" id="lnk">Next</a></div>`,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.8.12", "version": "0.8.14",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
+18 -6
View File
@@ -14,7 +14,18 @@ import type { Db } from "./driver.ts";
const DEFAULT = "default"; const DEFAULT = "default";
type DbFactory = () => Db; type DbFactory = () => Db;
type RegistryEntry = { db?: Db; factory?: DbFactory }; type RegistryEntry = { db?: Db; factory?: DbFactory };
const registry = new Map<string, RegistryEntry>(); const REGISTRY_KEY = Symbol.for("@wrnexus/db:registry:v1");
type RegistryGlobal = typeof globalThis & { [REGISTRY_KEY]?: Map<string, RegistryEntry> };
// Production bundlers can include @wrnexus/db more than once when an app and
// the server runtime resolve compatible but distinct package installations.
// A module-local Map splits configuration from consumers in that case. Store
// the registry on globalThis under a stable symbol so every bundled copy in
// the process observes the same default and named connections.
function databaseRegistry(): Map<string, RegistryEntry> {
const scope = globalThis as RegistryGlobal;
return (scope[REGISTRY_KEY] ??= new Map<string, RegistryEntry>());
}
/** Set the default database (called by the runtime at startup). */ /** Set the default database (called by the runtime at startup). */
export function setDb(db: Db): Db; export function setDb(db: Db): Db;
@@ -23,7 +34,7 @@ export function setDb(name: string, db: Db): Db;
export function setDb(a: string | Db, b?: Db): Db { export function setDb(a: string | Db, b?: Db): Db {
const name = typeof a === "string" ? a : DEFAULT; const name = typeof a === "string" ? a : DEFAULT;
const db = typeof a === "string" ? b! : a; const db = typeof a === "string" ? b! : a;
registry.set(name, { db }); databaseRegistry().set(name, { db });
return db; return db;
} }
@@ -37,12 +48,12 @@ export function registerDb(name: string, db: Db): Db {
* `getDb(name)` call creates and caches the connection. * `getDb(name)` call creates and caches the connection.
*/ */
export function registerLazyDb(name: string, factory: DbFactory): void { export function registerLazyDb(name: string, factory: DbFactory): void {
registry.set(name, { factory }); databaseRegistry().set(name, { factory });
} }
/** The default database, or a named one. Throws if it isn't configured. */ /** The default database, or a named one. Throws if it isn't configured. */
export function getDb(name = DEFAULT): Db { export function getDb(name = DEFAULT): Db {
const entry = registry.get(name); const entry = databaseRegistry().get(name);
if (!entry) { if (!entry) {
throw new Error( throw new Error(
name === DEFAULT name === DEFAULT
@@ -60,16 +71,17 @@ export function getDb(name = DEFAULT): Db {
/** Whether the default (or a named) database has been configured. */ /** Whether the default (or a named) database has been configured. */
export function hasDb(name = DEFAULT): boolean { export function hasDb(name = DEFAULT): boolean {
return registry.has(name); return databaseRegistry().has(name);
} }
/** Names of all configured databases (the default appears as "default"). */ /** Names of all configured databases (the default appears as "default"). */
export function databaseNames(): string[] { export function databaseNames(): string[] {
return [...registry.keys()]; return [...databaseRegistry().keys()];
} }
/** Close every configured database and clear the registry. */ /** Close every configured database and clear the registry. */
export async function closeDatabases(): Promise<void> { export async function closeDatabases(): Promise<void> {
const registry = databaseRegistry();
const databases = [...registry.values()].flatMap((entry) => (entry.db ? [entry.db] : [])); const databases = [...registry.values()].flatMap((entry) => (entry.db ? [entry.db] : []));
registry.clear(); registry.clear();
const results = await Promise.allSettled(databases.map((db) => db.close())); const results = await Promise.allSettled(databases.map((db) => db.close()));
+40 -1
View File
@@ -86,6 +86,40 @@ function hasExecutableSql(sql: string): boolean {
return false; return false;
} }
function additiveColumnTarget(sql: string): { table: string; column: string } | undefined {
const executable = sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--[^\r\n]*/g, " ")
.trim();
const match = /^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec(
executable,
);
return match ? { table: match[1]!, column: match[2]! } : undefined;
}
async function additiveColumnAlreadyExists(db: Db, sql: string): Promise<boolean> {
const target = additiveColumnTarget(sql);
if (!target) return false;
if (db.driver.dialect === "sqlite") {
const columns = await db.all<{ name: string }>(`PRAGMA table_info(${target.table})`);
return columns.some(({ name }) => name.toLowerCase() === target.column.toLowerCase());
}
if (db.driver.dialect === "postgres") {
return Boolean(
await db.one(
"SELECT 1 AS present FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?",
[target.table, target.column],
),
);
}
return Boolean(
await db.one(
"SELECT 1 AS present FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?",
[target.table, target.column],
),
);
}
/** Load and parse all migration files in a directory, sorted by filename. */ /** Load and parse all migration files in a directory, sorted by filename. */
export function loadMigrations(dir: string): Migration[] { export function loadMigrations(dir: string): Migration[] {
if (!existsSync(dir)) return []; if (!existsSync(dir)) return [];
@@ -171,7 +205,12 @@ export async function applyMigrations(
for (const migration of pending.filter(({ name }) => !current.has(name))) { for (const migration of pending.filter(({ name }) => !current.has(name))) {
throwIfAborted(options.signal); throwIfAborted(options.signal);
await db.tx(async (tx) => { await db.tx(async (tx) => {
if (hasExecutableSql(migration.up)) await tx.exec(migration.up); if (
hasExecutableSql(migration.up) &&
!(await additiveColumnAlreadyExists(tx, migration.up))
) {
await tx.exec(migration.up);
}
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]); await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
}); });
done.push(migration.name); done.push(migration.name);
+18
View File
@@ -161,6 +161,24 @@ test("comment-only migrations are recorded without executing empty SQL", async (
await db.close(); await db.close();
}); });
test("add-column migrations recover when the column exists but the migration record does not", async () => {
const db = createDb(sqlite());
await db.exec("CREATE TABLE otp_challenges (id TEXT PRIMARY KEY, purpose TEXT NOT NULL)");
const migrations = [
{
name: "0002_otp_purpose",
up: "ALTER TABLE otp_challenges ADD COLUMN purpose TEXT NOT NULL DEFAULT 'verification';",
down: "ALTER TABLE otp_challenges DROP COLUMN purpose;",
},
];
expect(await applyMigrations(db, migrations)).toEqual(["0002_otp_purpose"]);
expect(await appliedMigrations(db)).toContain("0002_otp_purpose");
const columns = await db.all<{ name: string }>("PRAGMA table_info(otp_challenges)");
expect(columns.filter(({ name }) => name === "purpose")).toHaveLength(1);
await db.close();
});
test("migration dry-run plans changes without applying schema and honors cancellation", async () => { test("migration dry-run plans changes without applying schema and honors cancellation", async () => {
const db = createDb(sqlite()); const db = createDb(sqlite());
const migrations = [ const migrations = [
+13
View File
@@ -92,3 +92,16 @@ test("registry closes every database and clears itself when one close fails", as
expect(secondClosed).toBe(true); expect(secondClosed).toBe(true);
expect(databaseNames()).toEqual([]); expect(databaseNames()).toEqual([]);
}); });
test("separately evaluated package copies share the process-wide registry", async () => {
await closeDatabases();
const secondCopy = await import(`../src/client.ts?copy=${crypto.randomUUID()}`);
const main = createDb(sqlite(":memory:"));
setDb(main);
expect(secondCopy.hasDb()).toBe(true);
expect(secondCopy.getDb()).toBe(main);
await secondCopy.closeDatabases();
expect(hasDb()).toBe(false);
});
+8
View File
@@ -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. 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 `getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache
`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and `hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and
`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded `lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.8.29", "version": "0.8.32",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"exports": { "exports": {
+57 -2
View File
@@ -672,6 +672,25 @@ export const HMR_CLIENT_JS = `
pendingSync = false; pendingSync = false;
var doc = new DOMParser().parseFromString(html, "text/html"); 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) { if (doc.title) {
document.title = doc.title; document.title = doc.title;
} }
@@ -704,6 +723,10 @@ export const HMR_CLIENT_JS = `
window.__wrnexusHydrateCsrFetches(document); window.__wrnexusHydrateCsrFetches(document);
} }
if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
window.__wrnLang.bind(document);
}
if ( if (
window.wrnTheme && window.wrnTheme &&
typeof window.wrnTheme.bind === "function" typeof window.wrnTheme.bind === "function"
@@ -1449,7 +1472,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
async function handleApi(ctx: Context): Promise<Response> { async function handleApi(ctx: Context): Promise<Response> {
const matched = router.matchApi(ctx.url.pathname); 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 // Expose the canonical matched route to package dispatchers. A package may
// contribute several URL paths from one module, and request URLs can be // 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; if (!isMobileRequest) return response;
const headers = new Headers(response.headers); const headers = new Headers(response.headers);
headers.set("x-wrnexus-original-status", "404"); 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" });
});
+12 -8
View File
@@ -10,14 +10,18 @@ component Card {
props { props {
title: string = "Card title" title: string = "Card title"
titleKey: string = ""
subtitle: string = "" subtitle: string = ""
subtitleKey: string = ""
description: string = "" description: string = ""
descriptionKey: string = ""
header: string = "" header: string = ""
footer: string = "" footer: string = ""
imageSrc: string = "" imageSrc: string = ""
imageAlt: string = "" imageAlt: string = ""
imagePosition: string = "top" imagePosition: string = "top"
actionLabel: string = "" actionLabel: string = ""
actionLabelKey: string = ""
actionHref: string = "" actionHref: string = ""
headerActions: unknown[] = [] headerActions: unknown[] = []
navigation: unknown[] = [] navigation: unknown[] = []
@@ -105,13 +109,13 @@ component Card {
<div class="wrn-next__card-content"> <div class="wrn-next__card-content">
{#if item.title || item.label} {#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}
{#if item.subtitle} {#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}
{#if item.description} {#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}
{#if item.actionLabel || item.href} {#if item.actionLabel || item.href}
<a <a
@@ -119,7 +123,7 @@ component Card {
class="wrn-next__card-action" class="wrn-next__card-action"
@click='output.action({ item: item, index: itemIndex })' @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> <span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
</a> </a>
{/if} {/if}
@@ -168,10 +172,10 @@ component Card {
<p class="wrn-next__card-eyebrow">{header}</p> <p class="wrn-next__card-eyebrow">{header}</p>
{/if} {/if}
{#if title} {#if title}
<h3>{title}</h3> <h3 data-t='{titleKey}'>{title}</h3>
{/if} {/if}
{#if subtitle} {#if subtitle}
<p class="wrn-next__card-subtitle">{subtitle}</p> <p class="wrn-next__card-subtitle" data-t='{subtitleKey}'>{subtitle}</p>
{/if} {/if}
</div> </div>
@@ -268,7 +272,7 @@ component Card {
</div> </div>
{:else} {:else}
{#if description} {#if description}
<p class="wrn-next__card-description">{description}</p> <p class="wrn-next__card-description" data-t='{descriptionKey}'>{description}</p>
{/if} {/if}
<slot></slot> <slot></slot>
{/if} {/if}
@@ -285,7 +289,7 @@ component Card {
class="wrn-next__card-action" class="wrn-next__card-action"
@click='output.action({ href: actionHref, label: actionLabel })' @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> <span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
</a> </a>
{/if} {/if}
+4 -4
View File
@@ -388,11 +388,11 @@ component Carousel {
<img src="{item.imageSrc}" alt="{item.imageAlt || item.title || ''}" /> <img src="{item.imageSrc}" alt="{item.imageAlt || item.title || ''}" />
{/if} {/if}
<div class="wrn-next__carousel-slide-content"> <div class="wrn-next__carousel-slide-content">
{#if item.eyebrow}<span>{item.eyebrow}</span>{/if} {#if item.eyebrow}<span data-t='{item.eyebrowKey || ""}'>{item.eyebrow}</span>{/if}
{#if item.title || item.label}<h4>{item.title || item.label}</h4>{/if} {#if item.title || item.label}<h4 data-t='{item.titleKey || item.labelKey || ""}'>{item.title || item.label}</h4>{/if}
{#if item.description}<p>{item.description}</p>{/if} {#if item.description}<p data-t='{item.descriptionKey || ""}'>{item.description}</p>{/if}
{#if item.actionLabel} {#if item.actionLabel}
<a href="{item.actionHref || '#'}">{item.actionLabel}</a> <a href="{item.actionHref || '#'}" data-t='{item.actionLabelKey || ""}'>{item.actionLabel}</a>
{/if} {/if}
</div> </div>
</article> </article>
+22 -6
View File
@@ -13,9 +13,23 @@ component Footer {
columns: number = 3 columns: number = 3
maxWidth: string = "compact" maxWidth: string = "compact"
copyright: string = "" copyright: string = ""
copyrightKey: string = ""
translationPrefix: string = ""
class: 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 { view {
<footer <footer
{...attrs} {...attrs}
@@ -58,12 +72,13 @@ component Footer {
<h2 <h2
id='footer-heading-{itemIndex}' id='footer-heading-{itemIndex}'
class="wrn-footer__heading" class="wrn-footer__heading"
data-t='{translationKey(item, "label")}'
> >
{item.label || item.title} {item.label || item.title}
</h2> </h2>
{#if item.description} {#if item.description}
<p> <p data-t='{translationKey(item, "description")}'>
{item.description} {item.description}
</p> </p>
{/if} {/if}
@@ -77,7 +92,7 @@ component Footer {
href='{child.href || "#"}' href='{child.href || "#"}'
target='{child.target || ""}' target='{child.target || ""}'
rel='{child.external ? "noopener noreferrer" : (child.rel || "")}' rel='{child.external ? "noopener noreferrer" : (child.rel || "")}'
aria-current='{child.active ? "page" : ""}' aria-current='{child.active ? "page" : "false"}'
class="wrn-footer__link" 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 })' @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> </span>
{/if} {/if}
<span> <span data-t='{translationKey(child, "label")}'>
{child.label || child.title} {child.label || child.title}
</span> </span>
@@ -125,7 +140,7 @@ component Footer {
href='{item.href || "#"}' href='{item.href || "#"}'
target='{item.target || ""}' target='{item.target || ""}'
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}' rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
aria-current='{item.active ? "page" : ""}' aria-current='{item.active ? "page" : "false"}'
class="wrn-footer__link" class="wrn-footer__link"
@click='output.select({ item: item, itemIndex: itemIndex }); item.action && output.action({ item: item, itemIndex: itemIndex })' @click='output.select({ item: item, itemIndex: itemIndex }); item.action && output.action({ item: item, itemIndex: itemIndex })'
> >
@@ -137,7 +152,7 @@ component Footer {
</span> </span>
{/if} {/if}
<span> <span data-t='{translationKey(item, "label")}'>
{item.label || item.title} {item.label || item.title}
</span> </span>
@@ -177,6 +192,7 @@ component Footer {
<h2 <h2
id='footer-heading-{itemIndex}' id='footer-heading-{itemIndex}'
class="wrn-footer__heading" class="wrn-footer__heading"
data-t='{translationKey(item, "label")}'
> >
{item.title || item.label} {item.title || item.label}
</h2> </h2>
@@ -264,7 +280,7 @@ component Footer {
<p <p
class="wrn-footer__copyright" class="wrn-footer__copyright"
> >
{copyright} <span data-t='{copyrightKey}'>{copyright}</span>
</p> </p>
{/if} {/if}
+20 -10
View File
@@ -20,12 +20,22 @@ size: string = "default"
openOnHover: boolean = false openOnHover: boolean = false
maxWidth: string = "full" maxWidth: string = "full"
mobileLabel: string = "Toggle navigation" mobileLabel: string = "Toggle navigation"
translationPrefix: string = ""
class: string = "" class: string = ""
} }
state mobileOpen: boolean = false state mobileOpen: boolean = false
functions { 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() { client function toggleNavigation() {
mobileOpen = !mobileOpen mobileOpen = !mobileOpen
output.toggle({ open: mobileOpen }) output.toggle({ open: mobileOpen })
@@ -75,8 +85,8 @@ size: string = "default"
{/if} {/if}
{#if brand.label || brand.description} {#if brand.label || brand.description}
<span class="wrn-navbar__brand-copy"> <span class="wrn-navbar__brand-copy">
{#if brand.label}<strong>{brand.label}</strong>{/if} {#if brand.label}<strong data-t="{brand.labelKey || (translationPrefix ? translationPrefix + '.brand' : '')}">{brand.label}</strong>{/if}
{#if brand.description}<small>{brand.description}</small>{/if} {#if brand.description}<small data-t="{brand.descriptionKey || (translationPrefix ? translationPrefix + '.brand.description' : '')}">{brand.description}</small>{/if}
</span> </span>
{/if} {/if}
</a> </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)"> <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'}"> <summary data-wrn-roving-item="true" aria-current="{isItemActive(item) ? 'page' : 'false'}">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if} {#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> <span class="wrn-navbar__chevron" aria-hidden="true"></span>
</summary> </summary>
<div class="wrn-navbar__panel wrn-navbar__panel--columns-{item.columns || 1}"> <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} {#each item.children as child}
<div class="wrn-navbar__group"> <div class="wrn-navbar__group">
{#if child.children && child.children.length} {#if child.children && child.children.length}
{#if child.label}<strong class="wrn-navbar__group-title">{child.label}</strong>{/if} {#if child.label}<strong class="wrn-navbar__group-title" data-t="{translationKey(child, 'label')}">{child.label}</strong>{/if}
{#if child.description}<small>{child.description}</small>{/if} {#if child.description}<small data-t="{translationKey(child, 'description')}">{child.description}</small>{/if}
{#each child.children as nested} {#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)"> <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} {#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> </a>
{/each} {/each}
{:else} {:else}
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : 'false'}" @click="selectItem(child, 2)"> <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} {#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> </a>
{/if} {/if}
</div> </div>
@@ -121,7 +131,7 @@ size: string = "default"
{:else} {: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)"> <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} {#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> </a>
{/if} {/if}
{/each} {/each}
@@ -131,7 +141,7 @@ size: string = "default"
{#each actions as item} {#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)"> <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} {#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> </a>
{/each} {/each}
<slot name="actions" /> <slot name="actions" />
+16 -6
View File
@@ -39,15 +39,24 @@ component Input {
step: string = "" step: string = ""
class: string = "" class: string = ""
} }
state validationError: string = ""
functions { functions {
client function detail(sourceEvent) { client function detail(sourceEvent) {
return { value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent } return { value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }
} }
client function handleInput(sourceEvent) { sourceEvent.stopPropagation(); output.input(detail(sourceEvent)) } client function handleInput(sourceEvent) {
sourceEvent.stopPropagation()
if (sourceEvent.currentTarget.validity.valid) {
validationError = ""
sourceEvent.currentTarget.setAttribute("aria-invalid", error ? "true" : "false")
sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", error ? "true" : "false")
}
output.input(detail(sourceEvent))
}
client function handleChange(sourceEvent) { sourceEvent.stopPropagation(); output.change(detail(sourceEvent)) } client function handleChange(sourceEvent) { sourceEvent.stopPropagation(); output.change(detail(sourceEvent)) }
client function handleFocus(sourceEvent) { sourceEvent.stopPropagation(); output.focus(detail(sourceEvent)) } client function handleFocus(sourceEvent) { sourceEvent.stopPropagation(); output.focus(detail(sourceEvent)) }
client function handleBlur(sourceEvent) { sourceEvent.stopPropagation(); output.blur(detail(sourceEvent)) } client function handleBlur(sourceEvent) { sourceEvent.stopPropagation(); output.blur(detail(sourceEvent)) }
client function handleInvalid(sourceEvent) { sourceEvent.stopPropagation(); output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) } client function handleInvalid(sourceEvent) { sourceEvent.stopPropagation(); validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent }) }
client function handleKeydown(sourceEvent) { sourceEvent.stopPropagation(); output.keydown({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } client function handleKeydown(sourceEvent) { sourceEvent.stopPropagation(); output.keydown({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
client function handleKeyup(sourceEvent) { sourceEvent.stopPropagation(); output.keyup({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } client function handleKeyup(sourceEvent) { sourceEvent.stopPropagation(); output.keyup({ key: sourceEvent.key, value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
} }
@@ -58,7 +67,7 @@ component Input {
data-variant="{variant}" data-variant="{variant}"
data-inline="{inline}" data-inline="{inline}"
data-floating="{variant === 'floating'}" data-floating="{variant === 'floating'}"
data-invalid="{error ? 'true' : 'false'}" data-invalid="{error || validationError ? 'true' : 'false'}"
> >
<div <div
class="wrn-next__field-heading" class="wrn-next__field-heading"
@@ -105,8 +114,8 @@ component Input {
min="{min}" min="{min}"
max="{max}" max="{max}"
step="{step}" step="{step}"
aria-invalid="{error ? 'true' : 'false'}" aria-invalid="{error || validationError ? 'true' : 'false'}"
aria-describedby="{error ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}" aria-describedby="{error || validationError ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}"
@input="handleInput(event)" @input="handleInput(event)"
@change="handleChange(event)" @change="handleChange(event)"
@focus="handleFocus(event)" @focus="handleFocus(event)"
@@ -135,8 +144,9 @@ component Input {
id="{(id || name) + '-error'}" id="{(id || name) + '-error'}"
class="wrn-next__field-error" class="wrn-next__field-error"
data-error="{name}" data-error="{name}"
aria-live="polite"
> >
{error} {error || validationError}
</small> </small>
</div> </div>
} }
+18 -4
View File
@@ -33,6 +33,7 @@ size: string = "default"
required: boolean = false required: boolean = false
class: string = "" class: string = ""
} }
state validationError: string = ""
functions { functions {
shared function selected(option) { shared function selected(option) {
if (multiple) return values.includes(option.value) if (multiple) return values.includes(option.value)
@@ -40,34 +41,47 @@ size: string = "default"
} }
client function emitField(nameEvent, sourceEvent) { client function emitField(nameEvent, sourceEvent) {
sourceEvent.stopPropagation() sourceEvent.stopPropagation()
if (sourceEvent.currentTarget.validity.valid) { validationError = ""; sourceEvent.currentTarget.setAttribute("aria-invalid", error ? "true" : "false"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", error ? "true" : "false") }
output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent })
} }
client function handleFocus(sourceEvent) { emitField("focus", sourceEvent); output.open({ name: name, sourceEvent: sourceEvent }) } client function handleFocus(sourceEvent) { emitField("focus", sourceEvent); output.open({ name: name, sourceEvent: sourceEvent }) }
client function handleBlur(sourceEvent) { emitField("blur", sourceEvent); output.close({ name: name, sourceEvent: sourceEvent }) } client function handleBlur(sourceEvent) { emitField("blur", sourceEvent); output.close({ name: name, sourceEvent: sourceEvent }) }
client function handleInvalid(sourceEvent) { output.invalid({ name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) } client function handleInvalid(sourceEvent) { validationError = sourceEvent.currentTarget.validationMessage; sourceEvent.currentTarget.setAttribute("aria-invalid", "true"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true"); output.invalid({ name: name, message: validationError, sourceEvent: sourceEvent }) }
} }
view { view {
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--select {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error ? 'true' : 'false'}" data-readonly="{readonly}"> <div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--select {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error || validationError ? 'true' : 'false'}" data-readonly="{readonly}">
<div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div> <div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
<div class="wrn-next__field-control" data-icon-position="{iconPosition}"> <div class="wrn-next__field-control" data-icon-position="{iconPosition}">
{#if icon}<span class="{icon} wrn-next__field-icon" aria-hidden="true"></span>{/if} {#if icon}<span class="{icon} wrn-next__field-icon" aria-hidden="true"></span>{/if}
<select id="{id || name}" name="{name}" multiple="{multiple}" disabled="{disabled || readonly}" required="{required}" aria-readonly="{readonly}" aria-invalid="{error ? 'true' : 'false'}" @input="emitField('input', event)" @change="emitField('change', event)" @focus="handleFocus(event)" @blur="handleBlur(event)" @invalid="handleInvalid(event)"> <select id="{id || name}" name="{name}" multiple="{multiple}" disabled="{disabled || readonly}" required="{required}" aria-readonly="{readonly}" aria-invalid="{error || validationError ? 'true' : 'false'}" aria-describedby="{error || validationError ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}" @input="emitField('input', event)" @change="emitField('change', event)" @focus="handleFocus(event)" @blur="handleBlur(event)" @invalid="handleInvalid(event)">
{#if !multiple}<option value="" selected="{value === ''}" disabled="{required}">{placeholder}</option>{/if} {#if !multiple}<option value="" selected="{value === ''}" disabled="{required}">{placeholder}</option>{/if}
{#each options as option}<option value="{option.value}" selected="{selected(option)}" disabled="{option.disabled}">{option.label}</option>{/each} {#each options as option}<option value="{option.value}" selected="{selected(option)}" disabled="{option.disabled}">{option.label}</option>{/each}
</select> </select>
{#if !multiple}<span class="icon-[lucide--chevron-down] wrn-next__select-indicator" aria-hidden="true"></span>{/if}
{#if variant === "floating"}<span class="wrn-next__field-floating-label">{label}</span>{/if} {#if variant === "floating"}<span class="wrn-next__field-floating-label">{label}</span>{/if}
</div> </div>
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if} {#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}
<small class="wrn-next__field-error" data-error="{name}">{error}</small> <small id="{(id || name) + '-error'}" class="wrn-next__field-error" data-error="{name}" aria-live="polite">{error || validationError}</small>
</div> </div>
} }
style { style {
.wrn-next--select select { .wrn-next--select select {
appearance: none;
width: 100%; width: 100%;
min-height: 2.65rem; min-height: 2.65rem;
margin: 0; margin: 0;
padding-inline-end: 2.5rem;
line-height: 1.35; line-height: 1.35;
color: var(--wrn-color-text);
background-color: var(--wrn-color-surface);
}
.wrn-next__select-indicator {
position: absolute;
right: 0.85rem;
color: var(--wrn-color-muted);
pointer-events: none;
} }
/* Shared component foundation. */ /* Shared component foundation. */
+9 -4
View File
@@ -32,25 +32,30 @@ size: string = "default"
maxlength: string = "" maxlength: string = ""
class: string = "" class: string = ""
} }
state validationError: string = ""
functions { functions {
client function emitField(nameEvent, sourceEvent) { client function emitField(nameEvent, sourceEvent) {
sourceEvent.stopPropagation() sourceEvent.stopPropagation()
if (sourceEvent.currentTarget.validity.valid) { validationError = ""; sourceEvent.currentTarget.setAttribute("aria-invalid", error ? "true" : "false"); sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", error ? "true" : "false") }
output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent })
} }
client function handleInvalid(sourceEvent) { client function handleInvalid(sourceEvent) {
output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) validationError = sourceEvent.currentTarget.validationMessage
sourceEvent.currentTarget.setAttribute("aria-invalid", "true")
sourceEvent.currentTarget.closest(".wrn-next--field").setAttribute("data-invalid", "true")
output.invalid({ value: sourceEvent.currentTarget.value, name: name, message: validationError, sourceEvent: sourceEvent })
} }
} }
view { view {
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--textarea {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error ? 'true' : 'false'}"> <div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--textarea {class}" data-variant="{variant}" data-inline="{inline}" data-floating="{variant === 'floating'}" data-invalid="{error || validationError ? 'true' : 'false'}">
<div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div> <div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
<div class="wrn-next__field-control" data-icon-position="{iconPosition}"> <div class="wrn-next__field-control" data-icon-position="{iconPosition}">
{#if icon}<span class="{icon} wrn-next__field-icon" aria-hidden="true"></span>{/if} {#if icon}<span class="{icon} wrn-next__field-icon" aria-hidden="true"></span>{/if}
<textarea id="{id || name}" name="{name}" placeholder="{variant === 'floating' ? ' ' : placeholder}" rows="{rows}" style="resize: {resize}" readonly="{readonly}" disabled="{disabled}" required="{required}" minlength="{minlength}" maxlength="{maxlength}" aria-invalid="{error ? 'true' : 'false'}" aria-describedby="{error ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}" @input="emitField('input', event)" @change="emitField('change', event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @invalid="handleInvalid(event)">{value}</textarea> <textarea id="{id || name}" name="{name}" value="{value}" placeholder="{variant === 'floating' ? ' ' : placeholder}" rows="{rows}" style="resize: {resize}" readonly="{readonly}" disabled="{disabled}" required="{required}" minlength="{minlength}" maxlength="{maxlength}" aria-invalid="{error || validationError ? 'true' : 'false'}" aria-describedby="{error || validationError ? (id || name) + '-error' : (helperText ? (id || name) + '-help' : '')}" @input="emitField('input', event)" @change="emitField('change', event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @invalid="handleInvalid(event)"></textarea>
{#if variant === "floating"}<span class="wrn-next__field-floating-label">{label}</span>{/if} {#if variant === "floating"}<span class="wrn-next__field-floating-label">{label}</span>{/if}
</div> </div>
{#if helperText}<small id="{(id || name) + '-help'}" class="wrn-next__field-help">{helperText}</small>{/if} {#if helperText}<small id="{(id || name) + '-help'}" class="wrn-next__field-help">{helperText}</small>{/if}
<small id="{(id || name) + '-error'}" class="wrn-next__field-error" data-error="{name}">{error}</small> <small id="{(id || name) + '-error'}" class="wrn-next__field-error" data-error="{name}" aria-live="polite">{error || validationError}</small>
</div> </div>
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/ui", "name": "@wrnexus/ui",
"version": "0.8.16", "version": "0.8.19",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+46
View File
@@ -26,6 +26,12 @@ test("bundled UI assets are discoverable and readable", () => {
expect(uiCss()).toContain("--wrn-"); expect(uiCss()).toContain("--wrn-");
}); });
test("Textarea binds its value without rendering hydration markup as user content", () => {
const source = readFileSync(uiComponentPath("Textarea"), "utf8");
expect(source).toContain('value="{value}"');
expect(source).not.toContain(">{value}</textarea>");
});
test("global UI CSS stays below its migration ratchet", () => { test("global UI CSS stays below its migration ratchet", () => {
const css = uiCss(); const css = uiCss();
// Component selectors belong to their owning .wrn files. Keep this asset to // Component selectors belong to their owning .wrn files. Keep this asset to
@@ -463,6 +469,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", () => { test("component-system CSS includes responsive, theme-token, focus, and reduced-motion rules", () => {
const css = uiStyles(); const css = uiStyles();
expect(css).toContain("@media (max-width: 768px)"); expect(css).toContain("@media (max-width: 768px)");
@@ -1286,6 +1309,29 @@ test("basic form fields expose shared labels, variants, states, hints, values, a
expect(emitted).toEqual(["input", "change", "focus", "blur", "keydown", "keyup"]); expect(emitted).toEqual(["input", "change", "focus", "blur", "keydown", "keyup"]);
}); });
test("basic fields render native constraint messages and select matches field surfaces", async () => {
const inputSource = readFileSync(uiComponentPath("Input"), "utf8");
const dom = mountHtml(
await renderComponent(inputSource, {
id: "required-name",
name: "name",
label: "Name",
required: true,
}),
);
const input = dom.querySelector("input") as HTMLInputElement;
input.dispatchEvent(new (dom.window as any).Event("invalid", { bubbles: false }));
expect(dom.querySelector("[data-error='name']")?.textContent?.trim()).toBe(
input.validationMessage,
);
expect(dom.querySelector(".wrn-next--input")?.getAttribute("data-invalid")).toBe("true");
const selectSource = readFileSync(uiComponentPath("Select"), "utf8");
expect(selectSource).toContain("appearance: none");
expect(selectSource).toContain("background-color: var(--wrn-color-surface)");
expect(selectSource).toContain("wrn-next__select-indicator");
});
test("all basic form components render values and their must-have interaction events", async () => { test("all basic form components render values and their must-have interaction events", async () => {
const components = [ const components = [
"Checkbox", "Checkbox",