feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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"');
|
||||
}
|
||||
});
|
||||
@@ -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>`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user