feat: release design system engine, studio and component contracts

This commit is contained in:
Clintchiz committed 2026-10-19 13:09:03 +05:30
1 parent ff1065fb27
commit 3943b946d6
693 files changed
+175546 -3894

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { chartConfiguration, chartTypes } from "../src/chart-config";
import { renderComponent, mountHtml } from "../../test/src/index";
import { discoverPlugins, createPluginRunner } from "../../plugin/src/index";
import { resolve } from "node:path";
const source = readFileSync(new URL("../components/Chart.wrn", import.meta.url), "utf8");
for (const type of chartTypes) {
test(`Chart.js ${type} preserves native datasets`, () => {
const data = {
labels: ["A", "B"],
datasets: [
{
label: "Example",
data: type === "scatter" || type === "bubble" ? [{ x: 1, y: -2, r: 5 }] : [-20, 40],
},
],
};
const config = chartConfiguration({ type, data });
expect(config.type).toBe(type);
expect(config.data).toEqual(data);
expect(config.data).not.toBe(data);
if (["pie", "doughnut", "radar", "polarArea"].includes(type))
expect(config.options?.scales).toBeUndefined();
});
}
test("legacy rows preserve negatives, gaps, zero, and small decimal values", () => {
const config = chartConfiguration({
items: [
{ label: "Loss", value: -20 },
{ label: "Gap", value: null },
{ label: "Invalid", value: "oops" },
{ label: "Zero", value: 0 },
{ label: "Tiny", value: 0.0002 },
],
});
expect(config.data.datasets[0].data).toEqual([-20, null, null, 0, 0.0002]);
expect(config.data.labels).toEqual(["Loss", "Gap", "Invalid", "Zero", "Tiny"]);
});
test("native dataset options, mixed types, area fills, and stacked horizontal axes survive", () => {
const data = {
labels: ["A"],
datasets: [
{ type: "bar" as const, data: [20] },
{ type: "line" as const, data: [10], borderColor: "red" },
],
};
const options = {
indexAxis: "y",
scales: { x: { stacked: true }, y: { stacked: true } },
plugins: { legend: { display: false } },
};
const config = chartConfiguration({ type: "bar", data, options });
expect(config.data).toEqual(data);
expect(config.options?.indexAxis).toBe("y");
expect((config.options?.scales?.x as any)?.stacked).toBe(true);
expect(config.options?.plugins?.legend?.display).toBe(false);
const area = chartConfiguration({ type: "area", data: { datasets: [{ data: [1, 2] }] } });
expect(area.type).toBe("line");
expect((area.data.datasets[0] as any).fill).toBe(true);
expect(() => chartConfiguration({ type: "unknown" })).toThrow("Unsupported chart type");
});
test("separate datasets and custom legacy keys remain supported", () => {
expect(chartConfiguration({ labels: ["A"], datasets: [{ data: [3] }] }).data).toEqual({
labels: ["A"],
datasets: [{ data: [3] }],
});
expect(
chartConfiguration({ items: [{ name: "A", amount: -2 }], labelKey: "name", valueKey: "amount" })
.data.datasets[0].data,
).toEqual([-2]);
});
test("SSR safely carries native chart config and typed public outputs", async () => {
const data = { labels: ["<script>bad</script>"], datasets: [{ label: "Sample", data: [-2] }] };
const dom = mountHtml(await renderComponent(source, { type: "radar", data }));
const root = dom.querySelector('[data-ui-component="Chart"]') as HTMLElement;
expect(root.dataset.wrnexusRuntime).toBe("chartjs");
expect(JSON.parse(root.dataset.chartConfig!).data).toEqual(data);
expect(root.querySelector("canvas")?.getAttribute("role")).toBe("img");
expect(root.querySelector("script")).toBeNull();
});
test("UI package discovers its bundled Chart.js runtime automatically", async () => {
const plugins = await discoverPlugins(resolve("examples/component-showcase"), [], {
strict: true,
});
const runner = createPluginRunner(plugins, {
root: resolve("examples/component-showcase"),
mode: "development",
command: "dev",
metadata: new Map(),
warn: () => {},
});
const contributions = await runner.contributions();
expect(
contributions.clientRuntimes.some((runtime) => runtime.id === "chartjs" && runtime.bundle),
).toBe(true);
});
+67 -42
View File
@@ -72,64 +72,89 @@ test("carousel exposes both unframed and framed surfaces", async () => {
}
});
test("rating commits a star selection and readonly rating stays disabled", async () => {
const dom = await mount("Rating", {value: 2});
(dom.querySelector('[aria-label="Rate 4 of 5"]') as HTMLButtonElement).click();
expect(dom.querySelector('output')?.textContent).toContain("4 / 5");
expect(dom.querySelectorAll('[data-filled="true"]').length).toBe(4);
const readonly = await mount("Rating", {readonly:true});
expect((readonly.querySelector('button') as HTMLButtonElement).disabled).toBe(true);
const dom = await mount("Rating", { value: 2 });
(dom.querySelector('[aria-label="Rate 4 of 5"]') as HTMLButtonElement).click();
expect(dom.querySelector("output")?.textContent).toContain("4 / 5");
expect(dom.querySelectorAll('[data-filled="true"]').length).toBe(4);
const readonly = await mount("Rating", { readonly: true });
expect((readonly.querySelector("button") as HTMLButtonElement).disabled).toBe(true);
});
test("fractional rating uses the requested step and commits precise values", async () => {
const dom = await mount("Rating", {value:3.5, step:0.2});
const slider = dom.querySelector('input[type="range"]') as HTMLInputElement;
expect(slider.step).toBe("0.2");
slider.value = "4.2";
slider.dispatchEvent(new (dom.window as Window & typeof globalThis).Event("input", {bubbles:true}));
expect(dom.querySelector('output')?.textContent).toContain("4.2 / 5");
const dom = await mount("Rating", { value: 3.5, step: 0.2 });
const slider = dom.querySelector('input[type="range"]') as HTMLInputElement;
expect(slider.step).toBe("0.2");
slider.value = "4.2";
slider.dispatchEvent(
new (dom.window as Window & typeof globalThis).Event("input", { bubbles: true }),
);
expect(dom.querySelector("output")?.textContent).toContain("4.2 / 5");
});
test("card header and footer can be suppressed independently", async () => {
const dom = await mount("Card", {title:"Hidden title",footer:"Hidden footer",showHeader:false,showFooter:false,description:"Body remains"});
expect(dom.querySelector('.wrn-next__card-header')).toBeNull();
expect(dom.querySelector('.wrn-next__card-footer')).toBeNull();
expect(dom.document.body.textContent).toContain("Body remains");
const dom = await mount("Card", {
title: "Hidden title",
footer: "Hidden footer",
showHeader: false,
showFooter: false,
description: "Body remains",
});
expect(dom.querySelector(".wrn-next__card-header")).toBeNull();
expect(dom.querySelector(".wrn-next__card-footer")).toBeNull();
expect(dom.document.body.textContent).toContain("Body remains");
});
test("map embeds a geographic provider and requires a Google key", async () => {
const osm = await mount("Map", {latitude:12.97,longitude:77.59});
expect(osm.querySelector('iframe')?.getAttribute('src')).toContain('openstreetmap.org/export/embed.html');
const google = await mount("Map", {provider:"google",apiKey:""});
expect(google.querySelector('iframe')).toBeNull();
expect(google.document.body.textContent).toContain('API key');
const osm = await mount("Map", { latitude: 12.97, longitude: 77.59 });
expect(osm.querySelector("iframe")?.getAttribute("src")).toContain(
"openstreetmap.org/export/embed.html",
);
const google = await mount("Map", { provider: "google", apiKey: "" });
expect(google.querySelector("iframe")).toBeNull();
expect(google.document.body.textContent).toContain("API key");
});
test("chart accepts data arrays and chooses line rendering", async () => {
const dom = await mount("Chart", {type:"line",data:[{label:"Mon",value:12},{label:"Tue",value:18}]});
expect(dom.querySelector('path.wrn-chart__line')).not.toBeNull();
expect(dom.querySelectorAll('.wrn-chart__hit').length).toBe(2);
test("chart accepts data arrays and configures Chart.js line rendering", async () => {
const dom = await mount("Chart", {
type: "line",
data: [
{ label: "Mon", value: 12 },
{ label: "Tue", value: 18 },
],
});
const root = dom.querySelector('[data-ui-component="Chart"]') as HTMLElement;
expect(root.dataset.wrnexusRuntime).toBe("chartjs");
expect(root.querySelector("canvas")?.getAttribute("role")).toBe("img");
const config = JSON.parse(root.dataset.chartConfig!);
expect(config.type).toBe("line");
expect(config.data).toEqual([
{ label: "Mon", value: 12 },
{ label: "Tue", value: 18 },
]);
});
test("calendar Done and outside dismiss both close the panel", async () => {
const dom = await mount("DatePicker", {value:"2026-09-20"});
const trigger = dom.querySelector('.wrn-next__date-trigger') as HTMLButtonElement;
trigger.click();
(dom.querySelector('.wrn-next__date-done') as HTMLButtonElement).click();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
trigger.click();
(dom.querySelector('.wrn-picker-dismiss') as HTMLButtonElement).click();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
const dom = await mount("DatePicker", { value: "2026-09-20" });
const trigger = dom.querySelector(".wrn-next__date-trigger") as HTMLButtonElement;
trigger.click();
(dom.querySelector(".wrn-next__date-done") as HTMLButtonElement).click();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
trigger.click();
(dom.querySelector(".wrn-picker-dismiss") as HTMLButtonElement).click();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
test("fractional slider increments stay precise and disabled controls do not change", async () => {
const dom = await mount("RangeSlider", {min:0,max:5,value:2.4,step:0.2});
(dom.querySelector('[aria-label="Increase Range"]') as HTMLButtonElement).click();
expect(dom.querySelector('[role="slider"]')?.getAttribute('aria-valuenow')).toBe('2.6');
const locked = await mount("RangeSlider", {value:2,disabled:true});
(locked.querySelector('[role="slider"]') as HTMLElement).dispatchEvent(new (locked.window as Window & typeof globalThis).KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}));
expect(locked.querySelector('[role="slider"]')?.getAttribute('aria-valuenow')).toBe('2');
const dom = await mount("RangeSlider", { min: 0, max: 5, value: 2.4, step: 0.2 });
(dom.querySelector('[aria-label="Increase Range"]') as HTMLButtonElement).click();
expect(dom.querySelector('[role="slider"]')?.getAttribute("aria-valuenow")).toBe("2.6");
const locked = await mount("RangeSlider", { value: 2, disabled: true });
(locked.querySelector('[role="slider"]') as HTMLElement).dispatchEvent(
new (locked.window as Window & typeof globalThis).KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
}),
);
expect(locked.querySelector('[role="slider"]')?.getAttribute("aria-valuenow")).toBe("2");
});
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { COMPONENT_CONTRACTS, type DesignSystem } from "../../design-system/src/index.ts";
import { renderComponent } from "../../test/src/index.ts";
import { resolveThemeConfig, renderThemeCss } from "../../styles/src/theme.ts";
import { renderDocument } from "../../ssr/src/index.ts";
import { uiCss } from "../src/registry.ts";
// SSR baseline gallery. Provider regions and empty collection states intentionally keep their defaults.
export async function catalogFixtureDocument(system: DesignSystem, mode: string, page: number) {
const entries = Object.entries(COMPONENT_CONTRACTS).slice(page * 16, (page + 1) * 16);
const cards = await Promise.all(
entries.map(async ([name, contract]) => {
const body = await renderComponent(readFileSync(contract.source, "utf8"), {});
return `<section class="sample" data-sample="${name}"><h2>${name}</h2>${body}</section>`;
}),
);
const theme = resolveThemeConfig({ designSystem: { system }, default: mode });
const query = `system=${system.id}&mode=${mode}`;
return renderDocument({
meta: { title: `${system.name} — Catalog ${page + 1}` },
htmlAttrs: ` data-theme="${mode}"`,
body: `<h1>Component catalog</h1><p>Default server-rendered states. Interactive core preview: <a href="/?${query}">open</a>.</p><nav>${Array.from({ length: 7 }, (_, i) => `<a href="/catalog?${query}&page=${i}">${i + 1}</a>`).join(" · ")}</nav><main>${cards.join("")}</main>`,
extraHead: `<style>${uiCss()}${renderThemeCss(theme)}*{box-sizing:border-box}body{margin:0;padding:24px;background:var(--wrn-color-bg);color:var(--wrn-color-text);font-family:var(--wrn-font-sans)}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));gap:24px;margin-top:24px}.sample{min-width:0;overflow:auto;padding:16px;border:1px solid var(--wrn-color-border)}h2{font-size:16px}a{color:inherit}</style>`,
componentRecipeCss: theme.recipeCss,
});
}
@@ -0,0 +1,58 @@
import { test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import {
COMPONENT_CONTRACTS,
CATALOG_COMPONENT_CONTRACTS,
CORE_COMPONENT_CONTRACTS,
CORE_DESIGN_SYSTEMS,
compileDesignSystem,
validateDesignSystem,
} from "../../design-system/src/index.ts";
import { renderComponent } from "../../test/src/index.ts";
test("all 104 component contracts have source hooks and preserve the core registry", () => {
expect(Object.keys(COMPONENT_CONTRACTS)).toHaveLength(104);
expect(Object.keys(CORE_COMPONENT_CONTRACTS)).toHaveLength(14);
expect(Object.keys(CATALOG_COMPONENT_CONTRACTS)).toHaveLength(90);
for (const [name, contract] of Object.entries(COMPONENT_CONTRACTS)) {
const source = readFileSync(contract.source, "utf8");
expect(source).toContain(`data-wrn-component="${name}"`);
for (const part of Object.keys(contract.parts))
expect(source).toContain(`data-wrn-part="${part}"`);
}
});
test("all three systems validate and compile recipes for the complete catalog", () => {
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
expect(validateDesignSystem(system)).toEqual([]);
expect(Object.keys(system.recipes!)).toHaveLength(104);
for (const mode of ["light", "dark"]) {
const result = compileDesignSystem(system, { mode });
expect(result.recipeCss).not.toContain("undefined");
expect(result.recipeCss).not.toContain("NaN");
for (const [name, recipe] of Object.entries(system.recipes!)) {
if (Object.keys(recipe.base ?? {}).length)
expect(result.recipeCss).toContain(`data-wrn-owner="${name}"`);
}
}
}
});
test("catalog recipes respect provider, native and content ownership", () => {
for (const [name, contract] of Object.entries(CATALOG_COMPONENT_CONTRACTS)) {
for (const [part, descriptor] of Object.entries(contract.parts)) {
if (descriptor.ownership !== "framework" || descriptor.profile === "managed") {
for (const system of Object.values(CORE_DESIGN_SYSTEMS))
expect(system.recipes![name]!.base?.[part]).toBeUndefined();
}
}
}
});
test("all remaining components retain server rendering with additive ownership hooks", async () => {
for (const [name, contract] of Object.entries(CATALOG_COMPONENT_CONTRACTS)) {
const html = await renderComponent(readFileSync(contract.source, "utf8"), {});
expect(html).toContain(`data-wrn-component="${name}"`);
expect(html).not.toContain('data-wrn-owner="undefined"');
}
});
+144
View File
@@ -0,0 +1,144 @@
import { readFileSync } from "node:fs";
import { renderComponent } from "../../test/src/index.ts";
import { CORE_COMPONENT_CONTRACTS, type DesignSystem } from "../../design-system/src/index.ts";
import { resolveThemeConfig, renderThemeCss } from "../../styles/src/theme.ts";
import { renderDocument } from "../../ssr/src/index.ts";
import { uiCss } from "../src/registry.ts";
import { getReactiveRuntime } from "../../csr/src/index.ts";
import { runWithRequestContext } from "../../core/src/index.ts";
let fixture: Promise<string> | undefined;
export function designFixtureBody(): Promise<string> {
return (fixture ??= (async () => {
const render = (
name: keyof typeof CORE_COMPONENT_CONTRACTS,
props: Record<string, unknown> = {},
) => {
let source = readFileSync(CORE_COMPONENT_CONTRACTS[name].source, "utf8");
// The isolated renderer has no parent slot distributor; provide authored trigger content.
if (name === "ContextMenu")
source = source.replace('<slot name="trigger"></slot>', "<span>More options</span>");
return renderComponent(source, props);
};
const [
nav,
heading,
input,
select,
textarea,
checkbox,
radio,
toggle,
save,
cancel,
loading,
disabled,
card,
alert,
modal,
menu,
] = await Promise.all([
render("Nav", {
items: [
{ label: "Profile", href: "#profile", value: "profile" },
{ label: "Preferences", href: "#preferences", value: "preferences" },
],
active: "profile",
collapsible: false,
}),
render("PageHeader", {
title: "Design system preview",
description: "The same components, content, and behavior in three visual systems.",
}),
render("Input", {
id: "display-name",
name: "displayName",
label: "Display name",
placeholder: "Your name",
}),
render("Select", {
id: "language",
name: "language",
label: "Language",
value: "en",
options: [
{ value: "en", label: "English" },
{ value: "hi", label: "Hindi" },
],
}),
render("Textarea", {
id: "bio",
name: "bio",
label: "About you",
placeholder: "Add a short introduction",
}),
render("Checkbox", { id: "remember", name: "remember", label: "Remember preferences" }),
render("Radio", {
name: "density",
label: "Page density",
options: [
{ value: "comfortable", label: "Comfortable" },
{ value: "compact", label: "Compact" },
],
value: "comfortable",
}),
render("Switch", { id: "updates", name: "updates", label: "Email updates" }),
render("Button", { label: "Save changes" }),
render("Button", { label: "Cancel", variant: "ghost" }),
render("Button", { label: "Save changes", loading: true }),
render("Button", { label: "Unavailable", disabled: true }),
render("Card", {
title: "Profile",
description: "Update your profile and choose how this application works for you.",
}),
render("Alert", {
title: "Preview only",
description: "Changes on this page are not sent to a server.",
color: "info",
showIcon: true,
dismissible: true,
}),
render("Modal", {
title: "Review changes",
description: "Check your preferences before continuing.",
triggerLabel: "Open dialog",
confirmLabel: "Continue",
}),
render("ContextMenu", {
trigger: "click",
label: "More options",
items: [
{ label: "View profile", value: "profile" },
{ label: "Reset preferences", value: "reset" },
],
}),
]);
return `${nav}${heading}<main id="profile" class="design-preview-grid"><section class="design-preview-form">${card}<form onsubmit="event.preventDefault()">${input}${select}${textarea}<div id="preferences">${toggle}${checkbox}${radio}</div><div class="design-preview-actions">${save}${cancel}</div></form></section><aside class="design-preview-aside">${alert}<section><h2>Interaction states</h2><div class="design-preview-actions">${loading}${disabled}</div></section><section><h2>Overlays</h2><div class="design-preview-actions">${modal}${menu}</div></section></aside></main>`;
})());
}
export const PREVIEW_CSS = `*{box-sizing:border-box}body{margin:0;padding:clamp(1rem,4vw,3rem);background:var(--wrn-color-bg);color:var(--wrn-color-text);font-family:var(--wrn-font-sans);line-height:1.5}body>#app{max-width:1120px;margin:auto}.design-preview-grid{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:2rem}.design-preview-form,.design-preview-aside,form{display:grid;gap:1.25rem;align-content:start;min-width:0}.design-preview-actions{display:flex;flex-wrap:wrap;gap:1rem;align-items:center}h2{font-size:1.125rem;margin:0 0 1rem}.preview-controls{display:flex;flex-wrap:wrap;gap:1rem;align-items:center;margin-bottom:2rem;font:0.875rem system-ui}.preview-controls a{color:inherit;text-underline-offset:4px}@media(max-width:720px){.design-preview-grid{grid-template-columns:1fr}.design-preview-actions{gap:0.75rem}}`;
export async function designFixtureDocument(
system: DesignSystem,
mode = "light",
delivery: "inline" | "linked" = "inline",
): Promise<string> {
const theme = resolveThemeConfig({ designSystem: { system }, default: mode });
const themeCss = renderThemeCss(theme);
const themeHead =
delivery === "inline"
? `<style>${themeCss}</style>`
: `<link rel="stylesheet" href="/theme.css?system=${system.id}">`;
const controls = `<nav class="preview-controls" aria-label="Preview settings"><strong>Design systems</strong>${["square", "soft", "angular"].map((name) => `<a href="/?system=${name}&mode=${mode}&delivery=${delivery}">${name}</a>`).join("")}<a href="/?system=${system.id}&mode=${mode === "dark" ? "light" : "dark"}&delivery=${delivery}">Switch mode</a><a href="/?system=${system.id}&mode=${mode}&delivery=${delivery === "inline" ? "linked" : "inline"}">${delivery === "inline" ? "Linked CSS" : "Inline CSS"}</a></nav>`;
const body = controls + (await designFixtureBody());
return runWithRequestContext({} as never, () =>
renderDocument({
meta: { title: `${system.name} — Component preview` },
body,
htmlAttrs: ` data-theme="${mode}"`,
extraHead: `${themeHead}<style>${uiCss()}${PREVIEW_CSS}</style>`,
componentRecipeCss: theme.recipeCss,
styleNonce: "design-preview",
extraBody: `<script>${getReactiveRuntime()}</script>`,
}),
);
}
+286
View File
@@ -0,0 +1,286 @@
import { test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { Window } from "happy-dom";
import {
CORE_COMPONENT_CONTRACTS,
CORE_DESIGN_SYSTEMS,
compileDesignSystem,
validateDesignSystem,
type DesignSystem,
} from "../../design-system/src/index.ts";
import { renderComponent, mountHtml } from "../../test/src/index.ts";
import { renderDocument, renderDocumentStream } from "../../ssr/src/index.ts";
import { resolveThemeConfig, renderThemeCss } from "../../styles/src/theme.ts";
import { designFixtureBody, PREVIEW_CSS } from "./design-system-fixture.ts";
const render = (name: keyof typeof CORE_COMPONENT_CONTRACTS, props: Record<string, unknown> = {}) =>
renderComponent(readFileSync(CORE_COMPONENT_CONTRACTS[name].source, "utf8"), props);
const selector = (name: string, part: string) =>
`[data-wrn-owner="${name}"][data-wrn-part="${part}"]`;
test("every advertised contract part and axis has an additive source hook", () => {
for (const [name, contract] of Object.entries(CORE_COMPONENT_CONTRACTS)) {
const source = readFileSync(contract.source, "utf8");
expect(source).toContain(`data-wrn-component="${name}"`);
for (const part of Object.keys(contract.parts))
expect(source).toContain(`data-wrn-part="${part}"`);
for (const axis of Object.keys(contract.axes))
expect(source).toContain(`data-wrn-axis-${axis}="{${axis}}"`);
}
});
test("application theme tokens and controlClass token overrides retain precedence over recipes", async () => {
const theme = resolveThemeConfig({
designSystem: { system: CORE_DESIGN_SYSTEMS.soft },
default: "light",
themes: { light: { "radius-sm": "5px" } },
});
const body =
(await render("Button", { label: "App token" })) +
(await render("Button", { label: "Local token", controlClass: "local-geometry" }));
const window = new Window();
try {
window.document.write(
renderDocument({
meta: {},
body,
extraHead: `<style>${renderThemeCss(theme)}.local-geometry{--wrn-radius-sm:11px}</style>`,
componentRecipeCss: theme.recipeCss,
}),
);
const buttons = window.document.querySelectorAll("button.wrn-btn");
expect(window.getComputedStyle(buttons[0]!).borderRadius).toBe("5px");
expect(window.getComputedStyle(buttons[1]!).borderRadius).toBe("11px");
expect(buttons[1]!.classList.contains("local-geometry")).toBe(true);
} finally {
await window.happyDOM.close();
}
});
test("the identical composed page consumes all three systems in light and dark", async () => {
const body = await designFixtureBody();
for (const [name, system] of Object.entries(CORE_DESIGN_SYSTEMS))
for (const mode of ["light", "dark"]) {
const theme = resolveThemeConfig({ designSystem: { system }, default: mode });
const html = renderDocument({
meta: {},
body,
htmlAttrs: ` data-theme="${mode}"`,
extraHead: `<style>${renderThemeCss(theme)}${PREVIEW_CSS}</style>`,
componentRecipeCss: theme.recipeCss,
styleNonce: "recipe-test",
});
const window = new Window();
try {
window.document.write(html);
const style = (component: string, part: string) =>
window.getComputedStyle(window.document.querySelector(selector(component, part))!);
expect(style("Button", "control").borderRadius).toBe(name === "soft" ? "16px" : "0px");
expect(style("Input", "control").borderRadius).toBe(name === "soft" ? "16px" : "0px");
expect(style("Card", "panel").borderRadius).toBe(name === "soft" ? "24px" : "0px");
expect(window.document.querySelector("input#display-name")).not.toBeNull();
expect(html.lastIndexOf("data-wrn-design-recipes")).toBeGreaterThan(
html.lastIndexOf('data-wrnexus-style-kind="component"'),
);
expect(html).toContain('data-wrn-design-recipes nonce="recipe-test"');
expect(style("Button", "control").backgroundImage).toBe("none");
} finally {
await window.happyDOM.close();
}
}
});
test("explicit pill, size, color and Card geometry props survive recipes", async () => {
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
const body =
(await render("Button", { label: "Pill", pill: true, size: "sm", color: "danger" })) +
(await render("Card", { title: "Card", radius: "lg", shadow: "none" }));
const theme = resolveThemeConfig({ designSystem: { system } });
const window = new Window();
try {
window.document.write(
renderDocument({
meta: {},
body,
extraHead: `<style>${renderThemeCss(theme)}</style>`,
componentRecipeCss: theme.recipeCss,
}),
);
const button = window.document.querySelector(selector("Button", "control"))!;
expect(window.getComputedStyle(button).borderRadius).toBe("999px");
expect(button.getAttribute("data-wrn-axis-size")).toBe("sm");
expect(
window.getComputedStyle(window.document.querySelector(selector("Card", "panel"))!)
.borderRadius,
).toBe("16px");
expect(
window.getComputedStyle(window.document.querySelector(selector("Card", "panel"))!)
.boxShadow,
).toBe("none");
} finally {
await window.happyDOM.close();
}
}
});
test("typography and Container recipes follow the selected system without changing layout props", async () => {
for (const [name, system] of Object.entries(CORE_DESIGN_SYSTEMS)) {
const theme = resolveThemeConfig({ designSystem: { system }, default: "light" });
const body =
(await render("Container", { columns: 1, maxWidth: "lg" })) +
(await render("PageHeader", {
title: "A long heading that must retain its typography across layout widths",
}));
const window = new Window();
try {
window.document.write(
renderDocument({
meta: {},
body,
extraHead: `<style>${renderThemeCss(theme)}</style>`,
componentRecipeCss: theme.recipeCss,
}),
);
const container = window.document.querySelector(selector("Container", "root"))!;
expect(container.getAttribute("data-columns")).toBe("1");
expect(container.getAttribute("data-max-width")).toBe("lg");
expect(window.getComputedStyle(container).getPropertyValue("padding-inline")).toBe(
name === "soft" ? "1.5rem" : "1rem",
);
const heading = window.document.querySelector(selector("PageHeader", "title"))!;
expect(window.getComputedStyle(heading).fontFamily).toContain(
name === "soft" ? "Georgia" : name === "angular" ? "Times New Roman" : "IBM Plex Mono",
);
} finally {
await window.happyDOM.close();
}
}
});
test("matching compound rules override base and native disabled state rules", async () => {
const system: DesignSystem = {
schemaVersion: 1,
id: "rules",
name: "Rules",
version: "1",
tokens: {},
recipes: {
Button: {
contractVersion: 1,
base: { control: { borderWidth: "1px" } },
variants: [
{ when: { variant: "outline", size: "sm" }, parts: { control: { borderWidth: "3px" } } },
],
states: [{ state: "disabled", parts: { control: { opacity: 0.4, borderWidth: "5px" } } }],
},
},
};
const window = new Window();
try {
const body = await render("Button", {
label: "Unavailable",
variant: "outline",
size: "sm",
disabled: true,
});
window.document.write(
renderDocument({ meta: {}, body, componentRecipeCss: compileDesignSystem(system).recipeCss }),
);
const button = window.document.querySelector(selector("Button", "control"))!;
expect(window.getComputedStyle(button).borderWidth).toBe("5px");
expect(window.getComputedStyle(button).opacity).toBe("0.4");
expect(button.hasAttribute("disabled")).toBe(true);
} finally {
await window.happyDOM.close();
}
});
test("recipes do not leak parent variant conditions into nested instances", async () => {
const system: DesignSystem = {
schemaVersion: 1,
id: "nested",
name: "Nested",
version: "1",
tokens: {},
recipes: {
Button: {
contractVersion: 1,
variants: [{ when: { variant: "outline" }, parts: { control: { borderWidth: "5px" } } }],
},
},
};
const outer = await render("Button", { label: "Outer", variant: "outline" });
const inner = await render("Button", { label: "Inner", variant: "ghost" });
const window = new Window();
try {
window.document.write(
`<style>${compileDesignSystem(system).recipeCss}</style><div data-wrn-owner="Button" data-wrn-axis-variant="outline">${outer}${inner}</div>`,
);
const controls = window.document.querySelectorAll("button.wrn-btn");
expect(window.getComputedStyle(controls[0]!).borderWidth).toBe("5px");
expect(window.getComputedStyle(controls[1]!).borderWidth).not.toBe("5px");
} finally {
await window.happyDOM.close();
}
});
test("nested design islands isolate recipes and inline modal panels regardless of stylesheet order", async () => {
const outer = await render("Button", { label: "Outer" }),
inner = await render("Button", { label: "Inner" });
const modal = await render("Modal", { title: "Nested dialog", defaultOpen: true });
const soft = compileDesignSystem(CORE_DESIGN_SYSTEMS.soft, { scope: "outer", mode: "light" }).css;
const square = compileDesignSystem(CORE_DESIGN_SYSTEMS.square, {
scope: "inner",
mode: "light",
}).css;
for (const css of [soft + square, square + soft]) {
const window = new Window();
try {
window.document.write(
`<style>${css}</style><div data-wrn-design="outer" data-theme="light">${outer}<div data-wrn-design="inner" data-theme="light">${inner}${modal}</div></div>`,
);
const buttons = window.document.querySelectorAll("button.wrn-btn");
expect(window.getComputedStyle(buttons[0]!).borderRadius).toBe("16px");
expect(window.getComputedStyle(buttons[1]!).borderRadius).toBe("0px");
expect(
window.getComputedStyle(window.document.querySelector(selector("Modal", "panel"))!)
.borderRadius,
).toBe("0px");
} finally {
await window.happyDOM.close();
}
}
});
test("all systems retain hydrated switches and modal opening/dismissal", async () => {
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
const theme = resolveThemeConfig({ designSystem: { system } });
const source =
(await render("Switch", { id: "switch", name: "switch", label: "Email updates" })) +
(await render("Modal", { title: "Review changes", triggerLabel: "Open dialog" }));
const dom = mountHtml(`<style>${renderThemeCss(theme)}${theme.recipeCss}</style>${source}`);
const toggle = dom.querySelector('input[role="switch"]') as HTMLInputElement;
toggle.click();
expect(toggle.checked).toBe(true);
const trigger = dom.querySelector(
'[data-wrn-owner="Modal"][data-wrn-part="trigger"]',
) as HTMLButtonElement;
trigger.click();
expect(trigger.getAttribute("aria-expanded")).toBe("true");
const close = dom.querySelector(
'[data-wrn-owner="Modal"][data-wrn-part="close"]',
) as HTMLButtonElement;
close.click();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
}
});
test("custom document shells and streamed documents preserve the nonce-protected late recipe block", async () => {
const css = compileDesignSystem(CORE_DESIGN_SYSTEMS.soft).recipeCss;
const body = await render("Input", { label: "Name" });
const html = renderDocument({
meta: {},
body,
componentRecipeCss: css,
styleNonce: "test-nonce",
documentTemplate: `<html><head></head><body>${body}</body></html>`,
});
expect(html.indexOf("data-wrn-design-recipes")).toBeGreaterThan(
html.indexOf("data-wrnexus-style-kind"),
);
const streamed = await new Response(
renderDocumentStream({ meta: {}, body, componentRecipeCss: css, styleNonce: "test-nonce" }),
).text();
expect(streamed).toContain('data-wrn-design-recipes nonce="test-nonce"');
expect(validateDesignSystem(CORE_DESIGN_SYSTEMS.soft)).toEqual([]);
});
+20
View File
@@ -0,0 +1,20 @@
import { test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { renderComponent, mountHtml } from "../../test/src/index.ts";
test("Drawflow canvas preserves graph data and forwards editor outputs", async () => {
const source = readFileSync(new URL("../components/DrawflowCanvas.wrn", import.meta.url), "utf8");
const nodes = [{ id: "a", label: "<script>unsafe</script>", x: 40, y: 60, ports: [] }];
const dom = mountHtml(await renderComponent(source, { nodes }));
const root = dom.querySelector("[data-drawflow-editor]") as HTMLElement;
expect(JSON.parse(root.dataset.flowNodes!)).toEqual(nodes);
expect(root.querySelector("script")).toBeNull();
let detail: any;
root.addEventListener("select", (e: any) => (detail = e.detail));
root.dispatchEvent(
new (dom.window as { CustomEvent: typeof CustomEvent }).CustomEvent("flowselect", {
detail: { id: "a" },
bubbles: true,
}),
);
expect(detail.id).toBe("a");
});
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { renderComponent, mountHtml } from "../../test/src/index.ts";
const source = readFileSync(new URL("../components/FlowCanvas.wrn", import.meta.url), "utf8");
test("canvas renders branch edges, saved positions and escaped node labels", async () => {
const nodes = [
{
id: "branch",
label: "<script>Condition</script>",
x: 40,
y: 60,
ports: [{ key: "yes", label: "Match", target: "end" }],
},
{ id: "end", label: "Finish", x: 340, y: 100, ports: [] },
];
const html = await renderComponent(source, { nodes });
const dom = mountHtml(html);
const root = dom.querySelector('[data-ui-component="FlowCanvas"]') as HTMLElement;
expect(root.querySelectorAll("path")).toHaveLength(1);
expect(root.querySelector("path")?.getAttribute("d")).toBe("M 270 122 C 340 122 270 125 340 125");
expect(root.querySelector(".wrn-flow-node")?.getAttribute("style")).toContain("left: 40px");
expect(root.querySelector(".wrn-flow-node-title")?.textContent).toContain(
"<script>Condition</script>",
);
expect(root.querySelector("script")).toBeNull();
expect(nodes[0].x).toBe(40);
const seen: any[] = [];
for (const name of ["connect", "select", "move"])
root.addEventListener(name, (e: any) => seen.push({ name, detail: e.detail }));
(root.querySelector(".wrn-flow-port") as HTMLButtonElement).click();
(root.querySelectorAll(".wrn-flow-node-title")[1] as HTMLButtonElement).click();
expect(seen.map((e) => e.name)).toEqual(["connect", "select"]);
const title = root.querySelectorAll(".wrn-flow-node-title")[1] as HTMLButtonElement;
title.dispatchEvent(
new (dom.window as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
}),
);
expect(seen.at(-1).name).toBe("move");
expect(seen.at(-1).detail.nodes).toHaveLength(2);
});
+4 -2
View File
@@ -4,8 +4,10 @@ import { basename, dirname, resolve } from "node:path";
test("component imports resolve on case-sensitive production filesystems", () => {
const root = resolve(import.meta.dir, "../components");
for (const name of readdirSync(root).filter(name => name.endsWith(".wrn"))) {
for (const match of readFileSync(resolve(root, name), "utf8").matchAll(/from\s+["'](\.[^"']+\.wrn)["']/g)) {
for (const name of readdirSync(root).filter((name) => name.endsWith(".wrn"))) {
for (const match of readFileSync(resolve(root, name), "utf8").matchAll(
/from\s+["'](\.[^"']+\.wrn)["']/g,
)) {
const target = resolve(root, match[1]);
expect(readdirSync(dirname(target)), `${name}: ${match[1]}`).toContain(basename(target));
}
+86
View File
@@ -0,0 +1,86 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { mountHtml, renderComponent } from "../../test/src/index.ts";
import { uiComponentPath } from "../src/registry.ts";
test("Select follows a changed scalar prop after the native control was edited", async () => {
const dom = mountHtml(
await renderComponent(readFileSync(uiComponentPath("Select"), "utf8"), {
name: "controlled",
value: "a",
options: [
{ value: "a", label: "First" },
{ value: "b", label: "Second" },
],
}),
);
const root = dom.querySelector("[data-wrn-hydration], [data-scope]") as any;
const control = dom.querySelector("select") as HTMLSelectElement;
control.value = "";
root.__wrnexusScopeApi.set("value", "b");
expect(control.value).toBe("b");
root.__wrnexusScopeApi.set("value", "a");
expect(control.value).toBe("a");
});
test("Select emits a snapshot of multiple values before reactive validation updates", async () => {
const dom = mountHtml(
await renderComponent(readFileSync(uiComponentPath("Select"), "utf8"), {
name: "reviewers",
label: "Reviewers",
multiple: true,
values: ["a"],
options: [
{ value: "a", label: "First" },
{ value: "b", label: "Second" },
{ value: "c", label: "Third" },
],
}),
);
const root = dom.querySelector(".wrn-next--select")!;
const control = dom.querySelector("select") as HTMLSelectElement;
expect(Array.from(control.selectedOptions).map((option) => option.value)).toEqual(["a"]);
(dom.querySelector("[data-wrn-hydration], [data-scope]") as any).__wrnexusScopeApi.set("values", [
"a",
"c",
]);
expect(Array.from(control.selectedOptions).map((option) => option.value)).toEqual(["a", "c"]);
(dom.querySelector("[data-wrn-hydration], [data-scope]") as any).__wrnexusScopeApi.set("values", [
"a",
]);
const outputs: any[] = [];
root.addEventListener("input", (event) => outputs.push((event as CustomEvent).detail));
control.options[0].selected = false;
control.options[1].selected = true;
control.options[2].selected = true;
control.dispatchEvent(new (dom.window as any).Event("input", { bubbles: true }));
expect(outputs).toHaveLength(1);
expect(outputs[0].value).toBe("b");
expect(outputs[0].values).toEqual(["b", "c"]);
for (const option of Array.from(control.options)) option.selected = false;
expect(outputs[0].values).toEqual(["b", "c"]);
control.dispatchEvent(new (dom.window as any).Event("input", { bubbles: true }));
expect(outputs[1].values).toEqual([]);
});
test("Select preserves the scalar output for single selection", async () => {
const dom = mountHtml(
await renderComponent(readFileSync(uiComponentPath("Select"), "utf8"), {
name: "single",
value: "a",
options: [
{ value: "a", label: "First" },
{ value: "b", label: "Second" },
],
}),
);
let payload: any;
dom.querySelector(".wrn-next--select")!.addEventListener("input", (event) => {
payload = (event as CustomEvent).detail;
});
const control = dom.querySelector("select") as HTMLSelectElement;
control.value = "b";
control.dispatchEvent(new (dom.window as any).Event("input", { bubbles: true }));
expect(payload.value).toBe("b");
expect(payload.values).toEqual([]);
});
+82 -48
View File
@@ -90,7 +90,7 @@ test("generated component reference documents every bundled component and its pr
readFileSync(join(uiComponentsDir(), "..", "component-catalog.json"), "utf8"),
) as { components: Array<{ name: string }> };
expect(reference.count).toBe(uiComponentNames().length);
expect(reference.count).toBe(102);
expect(reference.count).toBe(104);
expect(reference.components.map((component) => component.name)).toEqual(
expect.arrayContaining(uiComponentNames()),
);
@@ -366,23 +366,17 @@ test("navbar closes sibling menus and menus close after an outside pointer press
],
});
const dom = mountHtml(html);
const menus = Array.from(dom.querySelectorAll(".wrn-navbar__dropdown")) as HTMLDetailsElement[];
const menus = Array.from(dom.querySelectorAll(".wrn-navbar__dropdown")) as HTMLElement[];
menus[0].open = true;
menus[0].dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("toggle", { bubbles: false }),
);
menus[1].open = true;
menus[1].dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("toggle", { bubbles: false }),
);
expect(menus[0].open).toBe(false);
expect(menus[1].open).toBe(true);
(menus[0].querySelector("[data-wrn-menu-toggle]") as HTMLButtonElement).click();
(menus[1].querySelector("[data-wrn-menu-toggle]") as HTMLButtonElement).click();
expect(menus[0].dataset.open).toBe("false");
expect(menus[1].dataset.open).toBe("true");
dom.document.body.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("pointerdown", { bubbles: true }),
);
expect(menus[1].open).toBe(false);
expect(menus[1].dataset.open).toBe("false");
});
test("navbar optionally opens menus on hover and renders full-width mode", async () => {
@@ -393,16 +387,16 @@ test("navbar optionally opens menus on hover and renders full-width mode", async
});
const dom = mountHtml(html);
const navbar = dom.querySelector("[data-wrn-navbar]");
const menu = dom.querySelector(".wrn-navbar__dropdown") as HTMLDetailsElement;
const menu = dom.querySelector(".wrn-navbar__dropdown") as HTMLElement;
expect(navbar?.classList.contains("wrn-navbar--width-full")).toBe(true);
expect(navbar?.getAttribute("data-open-on-hover")).toBe("true");
menu.dispatchEvent(new (dom.window as { Event: typeof Event }).Event("pointerenter"));
expect(menu.open).toBe(true);
expect(menu.dataset.open).toBe("true");
menu.dispatchEvent(new (dom.window as { Event: typeof Event }).Event("pointerleave"));
expect(menu.open).toBe(true);
expect(menu.dataset.open).toBe("true");
await new Promise((resolve) => setTimeout(resolve, 280));
expect(menu.open).toBe(false);
expect(menu.dataset.open).toBe("false");
});
test("navbar hover menu stays open while the pointer crosses into its panel", async () => {
@@ -411,7 +405,7 @@ test("navbar hover menu stays open while the pointer crosses into its panel", as
items: [{ label: "Services", children: [{ label: "Reports", href: "/reports" }] }],
});
const dom = mountHtml(html);
const menu = dom.querySelector(".wrn-navbar__dropdown") as HTMLDetailsElement;
const menu = dom.querySelector(".wrn-navbar__dropdown") as HTMLElement;
const panel = dom.querySelector(".wrn-navbar__panel") as HTMLElement;
const DomEvent = (dom.window as { Event: typeof Event }).Event;
@@ -420,7 +414,7 @@ test("navbar hover menu stays open while the pointer crosses into its panel", as
panel.dispatchEvent(new DomEvent("pointerenter", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 280));
expect(menu.open).toBe(true);
expect(menu.dataset.open).toBe("true");
});
test("preference switcher keeps only one popover open and closes outside", async () => {
@@ -429,18 +423,17 @@ test("preference switcher keeps only one popover open and closes outside", async
expect(source).toContain("window.wrnAccent?.set(value)");
expect(source).toContain("window.wrnTheme?.set(value)");
const dom = mountHtml(html);
const menus = Array.from(dom.querySelectorAll(".wrn-preferences__menu")) as HTMLDetailsElement[];
expect(dom.document.querySelector("details,summary")).toBeNull();
const menus = Array.from(dom.querySelectorAll(".wrn-preferences__menu")) as HTMLElement[];
const DomEvent = (dom.window as { Event: typeof Event }).Event;
menus[0].open = true;
menus[0].dispatchEvent(new DomEvent("toggle"));
menus[1].open = true;
menus[1].dispatchEvent(new DomEvent("toggle"));
expect(menus[0].open).toBe(false);
expect(menus[1].open).toBe(true);
(menus[0].querySelector("[data-wrn-menu-toggle]") as HTMLButtonElement).click();
(menus[1].querySelector("[data-wrn-menu-toggle]") as HTMLButtonElement).click();
expect(menus[0].dataset.open).toBe("false");
expect(menus[1].dataset.open).toBe("true");
dom.document.body.dispatchEvent(new DomEvent("pointerdown", { bubbles: true }));
expect(menus[1].open).toBe(false);
expect(menus[1].dataset.open).toBe("false");
});
test("sidebar renders compact grouped submenu navigation and a mobile drawer trigger", async () => {
@@ -1430,7 +1423,9 @@ test("basic fields suppress native popups and render package constraint messages
const selectSource = readFileSync(uiComponentPath("Select"), "utf8");
expect(selectSource).toContain("appearance: none");
expect(selectSource).toContain("background-color: var(--wrn-color-input-background, var(--wrn-color-surface))");
expect(selectSource).toContain(
"background-color: var(--wrn-color-input-background, var(--wrn-color-surface))",
);
expect(selectSource).toContain("wrn-next__select-indicator");
});
@@ -1672,7 +1667,9 @@ test("date picker renders a custom calendar trigger with accessible field state
expect(dom.querySelector("label")?.getAttribute("for")).toBe("start-date");
expect(dom.querySelector('button[aria-haspopup="dialog"]')).not.toBeNull();
expect(source).not.toContain('type="{type}"');
expect(dom.querySelectorAll(".wrn-next__date-selectors select")[0]?.querySelectorAll("option")).toHaveLength(12);
expect(
dom.querySelectorAll(".wrn-next__date-selectors select")[0]?.querySelectorAll("option"),
).toHaveLength(12);
expect(dom.querySelector(".wrn-next__field-help")?.textContent).toContain("local date");
const trigger = dom.querySelector('button[aria-haspopup="dialog"]') as HTMLButtonElement;
expect(dom.querySelector(".wrn-next--date-picker")?.getAttribute("data-expanded")).toBe("false");
@@ -3186,7 +3183,9 @@ test("announcement full-width surface does not create viewport scrollbar overflo
});
test("announcement status uses a compatible element and preserves live announcements", async () => {
const html = await renderComponent(readFileSync(uiComponentPath("AnnouncementBar"), "utf8"), { message: "Service update" });
const html = await renderComponent(readFileSync(uiComponentPath("AnnouncementBar"), "utf8"), {
message: "Service update",
});
const dom = mountHtml(html);
const status = dom.querySelector('[role="status"]')!;
expect(status.tagName).toBe("DIV");
@@ -3196,7 +3195,9 @@ test("announcement status uses a compatible element and preserves live announcem
test("feature grid supports arbitrary slotted articles without imposing list semantics", async () => {
const grid = await renderComponent(readFileSync(uiComponentPath("FeatureGrid"), "utf8"));
const card = await renderComponent(readFileSync(uiComponentPath("FeatureCard"), "utf8"), { title: "Feature" });
const card = await renderComponent(readFileSync(uiComponentPath("FeatureCard"), "utf8"), {
title: "Feature",
});
expect(mountHtml(grid).querySelector('[role="list"]')).toBeNull();
const dom = mountHtml(card);
expect(dom.querySelector('[role="listitem"]')).toBeNull();
@@ -3218,7 +3219,9 @@ test("mega menu trigger keeps an accessible name for an empty or missing label",
const dom = mountHtml(await renderComponent(source, { label, columns: [] }));
const trigger = dom.querySelector(".wrn-mega__trigger")!;
expect(trigger.getAttribute("aria-label")).toBe(label || "Menu");
expect(dom.querySelector(".wrn-mega__trigger-label")?.getAttribute("data-label")).toBe(label || "Menu");
expect(dom.querySelector(".wrn-mega__trigger-label")?.getAttribute("data-label")).toBe(
label || "Menu",
);
}
});
@@ -3839,27 +3842,24 @@ test("list selects items without hrefs as well as navigation items", async () =>
expect(events).toEqual([{ item: { label: "Run report" }, index: 0 }]);
});
test("chart renders accessible SVG bars and emits point and legend outputs", async () => {
test("chart renders a runtime canvas and forwards point and legend outputs", async () => {
const source = readFileSync(uiComponentPath("Chart"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
items: [
{ label: "Alpha", value: 12 },
{ label: "Beta", value: 6 },
],
}),
);
const dom = mountHtml(await renderComponent(source, { items: [{ label: "Alpha", value: 12 }] }));
const root = dom.querySelector('[data-ui-component="Chart"]') as HTMLElement;
expect(root.getAttribute("data-wrnexus-runtime")).toBe("chartjs");
expect(root.querySelector("canvas")?.getAttribute("role")).toBe("img");
expect(JSON.parse(root.dataset.chartConfig!).items).toEqual([{ label: "Alpha", value: 12 }]);
const events: string[] = [];
for (const name of ["dataPointClick", "select", "legendToggle"]) {
for (const name of ["dataPointClick", "select", "legendToggle"])
root.addEventListener(name, () => events.push(name));
for (const name of ["chartpointclick", "chartselect", "chartlegendtoggle"]) {
root.dispatchEvent(
new (dom.window as { CustomEvent: typeof CustomEvent }).CustomEvent(name, {
detail: { item: 12, index: 0, datasetIndex: 0 },
bubbles: true,
}),
);
}
expect(root.querySelectorAll("svg path.wrn-chart__bar")).toHaveLength(2);
expect(root.querySelectorAll("button.wrn-chart__hit")).toHaveLength(2);
(root.querySelector("button.wrn-chart__hit") as HTMLElement).dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("click", { bubbles: true }),
);
(root.querySelector(".wrn-chart__legend button") as HTMLButtonElement).click();
expect(events).toEqual(["dataPointClick", "select", "legendToggle"]);
});
@@ -3960,3 +3960,37 @@ test("a declared output is never emitted as a hand-built CustomEvent", () => {
expect([...new Set(offenders)]).toEqual([]);
});
test("Switch follows controlled checked props after a reset and remains usable uncontrolled", async () => {
const source = readFileSync(uiComponentPath("Switch"), "utf8");
const controlled = mountHtml(
await renderComponent(source, { name: "controlled-switch", checked: true }),
);
const input = controlled.querySelector("input") as HTMLInputElement;
expect(input.checked).toBe(true);
const root = controlled.querySelector(".wrn-next--choice-field")!.parentElement as HTMLElement & {
__wrnexusScopeApi: {
set(name: string, value: unknown): void;
get(name: string): unknown;
call(name: string): unknown;
};
};
expect(root.__wrnexusScopeApi).toBeDefined();
input.click();
expect(input.checked).toBe(false);
root.__wrnexusScopeApi.set("checked", false);
root.__wrnexusScopeApi.set("checked", true);
expect(input.checked).toBe(true);
root.__wrnexusScopeApi.set("checked", false);
expect(input.checked).toBe(false);
expect(input.getAttribute("aria-checked")).toBe("false");
const uncontrolled = mountHtml(await renderComponent(source, { name: "uncontrolled-switch" }));
const control = uncontrolled.querySelector("input") as HTMLInputElement;
control.checked = true;
control.dispatchEvent(
new (uncontrolled.window as { Event: typeof Event }).Event("change", { bubbles: true }),
);
expect(control.checked).toBe(true);
expect(control.getAttribute("aria-checked")).toBe("true");
});