Files
WRNexusJS/packages/ui/test/ui.test.ts
T
Clintchiz fe86570f45
Quality / quality (windows-latest) (push) Waiting to run
Quality / quality (ubuntu-latest) (push) Failing after 9m54s
fix(ui): render dynamic tab panels and ids
2026-08-25 17:59:10 +05:30

3704 lines
148 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "bun:test";
import { gzipSync } from "bun";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { compileWrnFile, parse } from "../../compiler/src/index.ts";
import { mountHtml, renderComponent } from "../../test/src/index.ts";
import { renderThemeCss, resolveThemeConfig } from "../../styles/src/theme.ts";
// The filesystem helpers moved to the server-only `registry` subpath; the
// package entry deliberately stays free of node:* imports so it can be
// bundled for the browser.
import { uiComponentNames, uiComponentsDir, uiComponentPath, uiCss } from "../src/registry.ts";
function uiStyles(): string {
return [
uiCss(),
...uiComponentNames().map((name) => readFileSync(uiComponentPath(name), "utf8")),
].join("\n");
}
test("bundled UI assets are discoverable and readable", () => {
expect(uiComponentNames()).toContain("Button");
expect(uiComponentNames()).toContain("Accordion");
expect(uiComponentNames()).toContain("DataTable");
expect(uiComponentNames()).toContain("WysiwygEditor");
expect(readFileSync(uiComponentPath("Button"), "utf8")).toContain("component Button");
expect(uiCss()).toContain("--wrn-");
});
test("Textarea binds its value without rendering hydration markup as user content", () => {
const source = readFileSync(uiComponentPath("Textarea"), "utf8");
expect(source).toContain('value="{value}"');
expect(source).not.toContain(">{value}</textarea>");
});
test("form field labels use a stable control-label size instead of inherited heading scale", () => {
const fieldComponents = uiComponentNames().filter((name) =>
readFileSync(uiComponentPath(name), "utf8").includes(".wrn-next__field-heading label"),
);
expect(fieldComponents.length).toBeGreaterThan(0);
for (const name of fieldComponents) {
const source = readFileSync(uiComponentPath(name), "utf8");
expect(source).toMatch(
/\.wrn-next__field-heading label\s*\{[^}]*font-size: 0\.8125rem;[^}]*line-height: 1\.35;/s,
);
}
});
test("global UI CSS stays below its migration ratchet", () => {
const css = uiCss();
// Component selectors belong to their owning .wrn files. Keep this asset to
// resets, tokens, accessibility behavior, motion and reusable utilities.
expect(gzipSync(new TextEncoder().encode(css)).length).toBeLessThanOrEqual(3_800);
for (const localOrRemovedFamily of [
".wrn-switch",
".wrn-alert",
".wrn-card",
".wrn-footer",
".wrn-metric-card",
".wrn-segmented-group",
".wrn-dropdown",
".wrn-btn",
]) {
expect(css).not.toContain(localOrRemovedFamily);
}
});
test("components do not carry the standalone wrn-next generation artifact", () => {
const offenders = uiComponentNames().filter((name) => {
const source = readFileSync(uiComponentPath(name), "utf8");
return [...source.matchAll(/class=["']([^"']*)["']/g)].some((match) =>
match[1]!.split(/\s+/).includes("wrn-next"),
);
});
expect(offenders).toEqual([]);
});
test("every bundled component carries local styles", () => {
const unstyled = uiComponentNames().filter(
(name) => !/^\s{2,4}style \{/m.test(readFileSync(uiComponentPath(name), "utf8")),
);
expect(unstyled).toEqual([]);
});
test("generated component reference documents every bundled component and its props", () => {
const reference = JSON.parse(
readFileSync(join(uiComponentsDir(), "..", "component-reference.json"), "utf8"),
) as { count: number; components: Array<{ name: string; props: Array<{ name: string }> }> };
const catalog = JSON.parse(
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.components.map((component) => component.name)).toEqual(
expect.arrayContaining(uiComponentNames()),
);
expect(catalog.components.map((component) => component.name).sort()).toEqual(
uiComponentNames().sort(),
);
expect(reference.components.find((component) => component.name === "Button")?.props).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "label" }),
expect.objectContaining({ name: "as" }),
]),
);
expect(
reference.components
.find((component) => component.name === "PinInput")
?.props.find((prop) => prop.name === "length"),
).toEqual(expect.objectContaining({ default: "4" }));
});
test("interactive components expose their must-have public event contracts", () => {
const reference = JSON.parse(
readFileSync(join(uiComponentsDir(), "..", "component-reference.json"), "utf8"),
) as { components: Array<{ name: string; events: string[] }> };
const eventsByComponent = new Map(
reference.components.map((component) => [component.name, component.events]),
);
const requiredEvents: Record<string, string[]> = {
Button: ["click", "focus", "blur"],
Input: ["input", "change", "focus", "blur", "invalid"],
Select: ["input", "change", "open", "close"],
Checkbox: ["input", "change", "focus", "blur", "invalid"],
ColorPicker: ["input", "change", "focus", "blur"],
FileInput: ["input", "change", "focus", "blur", "select", "clear", "invalid"],
InputGroup: ["input", "change", "focus", "blur", "submit", "action"],
Radio: ["input", "change", "focus", "blur", "invalid"],
RangeSlider: ["input", "change", "focus", "blur"],
Switch: ["input", "change", "focus", "blur"],
Textarea: ["input", "change", "focus", "blur", "invalid"],
TimePicker: ["input", "change", "focus", "blur", "open", "close", "invalid"],
Accordion: ["change", "open", "close"],
Modal: ["open", "close", "cancel", "confirm"],
DataTable: ["sort", "select", "change", "rowClick", "pageChange"],
WysiwygEditor: ["input", "change", "focus", "blur"],
};
for (const [component, events] of Object.entries(requiredEvents)) {
expect(eventsByComponent.get(component)).toEqual(expect.arrayContaining(events));
}
});
test("component reference contains only explicitly declared public events", () => {
const reference = JSON.parse(
readFileSync(join(uiComponentsDir(), "..", "component-reference.json"), "utf8"),
) as { components: Array<{ name: string; events: string[] }> };
for (const component of reference.components) {
const componentPath = uiComponentPath(component.name);
const source = readFileSync(componentPath, "utf8");
const ast = parse(source);
const declaredEvents = [
...ast.events.map((event) => event.name),
...ast.outputs.map((output) => output.name),
];
const expectedEvents = [...new Set(declaredEvents)];
if (JSON.stringify(component.events) !== JSON.stringify(expectedEvents)) {
console.error("UI EVENT REFERENCE MISMATCH", {
component: component.name,
componentPath,
referenceEvents: component.events,
declaredEvents: expectedEvents,
});
}
expect(component.events).toEqual(expectedEvents);
}
});
test("bundled components do not duplicate another component implementation", () => {
const implementations = new Map<string, string>();
for (const name of uiComponentNames()) {
const source = readFileSync(uiComponentPath(name), "utf8")
.replace(/^component\s+[A-Za-z0-9_]+/, "component __NAME__")
.replace(/\r\n/g, "\n")
.trim();
expect(implementations.get(source)).toBeUndefined();
implementations.set(source, name);
}
});
test("removed duplicate aliases have valid canonical migration targets", () => {
const migration = JSON.parse(
readFileSync(join(uiComponentsDir(), "..", "component-migrations.json"), "utf8"),
) as { removedCount: number; replacements: Record<string, string> };
const names = new Set(uiComponentNames());
expect(Object.keys(migration.replacements)).toHaveLength(migration.removedCount);
expect(migration.removedCount).toBe(6);
for (const [removed, replacement] of Object.entries(migration.replacements)) {
expect(names.has(removed)).toBe(false);
expect(names.has(replacement)).toBe(true);
}
});
test("screenshot-directed canonical components are bundled", () => {
const required = [
"Container",
"Columns",
"Grid",
"Typography",
"Button",
"Accordion",
"Alert",
"Avatar",
"Card",
"Carousel",
"DatePicker",
"Progress",
"Spinner",
"Timeline",
"Navbar",
"Tabs",
"Sidebar",
"Breadcrumb",
"Pagination",
"Stepper",
"Input",
"Textarea",
"Checkbox",
"Radio",
"Switch",
"Select",
"ComboBox",
"TimePicker",
"Modal",
"Drawer",
"Popover",
"Tooltip",
"DataTable",
"Chart",
"Map",
"WysiwygEditor",
];
expect(uiComponentNames()).toEqual(expect.arrayContaining(required));
});
test("navbar supports brands, nested menus, actions, utility slots, and public events", () => {
const source = readFileSync(uiComponentPath("Navbar"), "utf8");
expect(source).toContain('slot name="topbar"');
expect(source).toContain('slot name="actions"');
const contract = parse(source);
const defaults = new Map(contract.props.map((prop) => [prop.name, prop.default]));
expect(defaults.get("brand")).toBe("{}");
expect(defaults.get("actions")).toBe("[]");
expect(defaults.get("openOnHover")).toBe("false");
expect(defaults.get("maxWidth")).toBe('"full"');
expect(source).toContain("item.children");
expect(source).toContain("dropdownGroupItems(child)");
expect(source).toContain('itemType(item) === "mega"');
expect(source).toContain("megaData(item).featured");
expect(source).toContain("wrn-navbar__dropdown--{item.type || 'dropdown'}");
const outputs = new Set(contract.outputs.map((output) => output.name));
for (const event of ["toggle", "open", "close", "select", "action"]) {
expect(outputs.has(event)).toBe(true);
}
});
test("auth form uses package schemas, disables native validation, and renders a full-width submit control", async () => {
const source = readFileSync(uiComponentPath("AuthForm"), "utf8");
expect(source).toContain('return "auth-login"');
expect(source).toContain('data-schema="{schemaName()}"');
expect(source).toContain('data-wrnexus-runtime="auth"');
expect(source).toContain('novalidate="true"');
expect(source).toContain('fullWidth="true"');
const html = await renderComponent(source, { mode: "sign-in" });
expect(html).toContain('data-schema="auth-login"');
expect(html).toContain('data-wrnexus-runtime="auth"');
expect(html).toContain("novalidate");
expect(html).toContain("wrn-auth-form__submit");
expect(html).toContain('fullWidth="true"');
});
test("password controls provide matching lock and visibility affordances", () => {
const toggle = readFileSync(uiComponentPath("TogglePassword"), "utf8");
const strong = readFileSync(uiComponentPath("StrongPassword"), "utf8");
const button = readFileSync(uiComponentPath("Button"), "utf8");
expect(toggle).toContain("wrn-next__password-leading-icon");
expect(strong).toContain("wrn-next__strong-password-leading-icon");
expect(strong).toContain("wrn-next__strong-password-toggle");
expect(strong).toContain("type=\"{revealed ? 'text' : 'password'}\"");
expect(button).toContain("data-full-width=\"{fullWidth ? 'true' : 'false'}\"");
});
test("footer supports typed entries, responsive columns, pre/post slots, and events", () => {
const source = readFileSync(uiComponentPath("Footer"), "utf8");
expect(source).toMatch(/<slot\s+name="pre-footer"\s*>/);
expect(source).toMatch(/<slot\s+name="post-footer"\s*>/);
expect(source).toMatch(/<slot\s+name="copyright-left"\s*>/);
expect(source).toMatch(/<slot\s+name="copyright-right"\s*>/);
expect(source).toContain('item.type === "header"');
expect(source).toContain("wrn-footer--columns-{columns}");
const outputs = new Set(parse(source).outputs.map((output) => output.name));
expect(outputs.has("select")).toBe(true);
expect(outputs.has("action")).toBe(true);
});
test("navbar renders a brand, nested dropdowns, mega groups, and actions", async () => {
const html = await renderComponent(readFileSync(uiComponentPath("Navbar"), "utf8"), {
brand: {
label: "Police Management System",
description: "Public portal",
href: "/",
},
items: [
{
label: "Services",
type: "dropdown",
children: [{ label: "Reports", href: "/reports" }],
},
{
label: "Safety",
type: "mega",
mega: {
variant: "icon-grid",
columnCount: 2,
rail: [{ label: "Overview", href: "/safety" }],
columns: [
{
heading: "Resources",
items: [{ label: "Guidance", href: "/guidance", badge: "New" }],
},
],
featured: { title: "Safety report", actionLabel: "Read report" },
actionLabel: "All safety resources",
},
},
],
actions: [{ label: "Sign in", href: "/login", variant: "primary" }],
});
expect(html).toContain("Police Management System");
expect(html).toContain("Public portal");
expect(html).toContain("Reports");
expect(html).toContain("Guidance");
expect(html).toContain("wrn-navbar__mega");
expect(html).toContain("Safety report");
expect(html).toContain("All safety resources");
expect(html).toContain("data-wrn-navbar");
expect(html).toContain('name="wrn-navbar-menu"');
expect(html).toContain("Sign in");
});
test("navbar closes sibling menus and menus close after an outside pointer press", async () => {
const html = await renderComponent(readFileSync(uiComponentPath("Navbar"), "utf8"), {
items: [
{ label: "Services", children: [{ label: "Reports", href: "/reports" }] },
{ label: "Safety", children: [{ label: "Guidance", href: "/guidance" }] },
],
});
const dom = mountHtml(html);
const menus = Array.from(dom.querySelectorAll(".wrn-navbar__dropdown")) as HTMLDetailsElement[];
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);
dom.document.body.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("pointerdown", { bubbles: true }),
);
expect(menus[1].open).toBe(false);
});
test("navbar optionally opens menus on hover and renders full-width mode", async () => {
const html = await renderComponent(readFileSync(uiComponentPath("Navbar"), "utf8"), {
openOnHover: true,
maxWidth: "full",
items: [{ label: "Services", children: [{ label: "Reports", href: "/reports" }] }],
});
const dom = mountHtml(html);
const navbar = dom.querySelector("[data-wrn-navbar]");
const menu = dom.querySelector(".wrn-navbar__dropdown") as HTMLDetailsElement;
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);
menu.dispatchEvent(new (dom.window as { Event: typeof Event }).Event("pointerleave"));
expect(menu.open).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 280));
expect(menu.open).toBe(false);
});
test("navbar hover menu stays open while the pointer crosses into its panel", async () => {
const html = await renderComponent(readFileSync(uiComponentPath("Navbar"), "utf8"), {
openOnHover: true,
items: [{ label: "Services", children: [{ label: "Reports", href: "/reports" }] }],
});
const dom = mountHtml(html);
const menu = dom.querySelector(".wrn-navbar__dropdown") as HTMLDetailsElement;
const panel = dom.querySelector(".wrn-navbar__panel") as HTMLElement;
const DomEvent = (dom.window as { Event: typeof Event }).Event;
menu.dispatchEvent(new DomEvent("pointerenter"));
menu.dispatchEvent(new DomEvent("pointerleave"));
panel.dispatchEvent(new DomEvent("pointerenter", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 280));
expect(menu.open).toBe(true);
});
test("preference switcher keeps only one popover open and closes outside", async () => {
const source = readFileSync(uiComponentPath("PreferenceSwitcher"), "utf8");
const html = await renderComponent(source, {});
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[];
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);
dom.document.body.dispatchEvent(new DomEvent("pointerdown", { bubbles: true }));
expect(menus[1].open).toBe(false);
});
test("sidebar renders compact grouped submenu navigation and a mobile drawer trigger", async () => {
const html = await renderComponent(readFileSync(uiComponentPath("Sidebar"), "utf8"), {
label: "Operations",
orientation: "vertical",
items: [
{ label: "Dashboard", href: "/dashboard", value: "dashboard" },
{
label: "Case management",
children: [
{ label: "Assigned cases", href: "/cases", value: "cases" },
{ label: "Evidence", href: "/evidence", value: "evidence" },
],
},
],
});
// Classes moved to the BEM naming every other component uses, and the
// off-canvas presentation is now Drawer rather than a hand-rolled backdrop.
// Nesting via children still works: that is what shipped in 0.8.5.
expect(html).toContain("wrn-sidebar__launcher");
expect(html).toContain("wrn-sidebar__sublist");
expect(html).toContain('data-component="Drawer"');
expect(html).toContain("Assigned cases");
expect(html).toContain("Evidence");
});
test("dropdown renders slotted account triggers, menu structure, and public events", async () => {
const source = readFileSync(uiComponentPath("Dropdown"), "utf8");
const html = await renderComponent(source, {
label: "Account menu",
items: [
{ type: "header", label: "Account" },
{ label: "Profile", href: "/profile", icon: "icon-[lucide--user]" },
{ type: "divider" },
{ label: "Sign out", href: "/logout", danger: true },
],
});
expect(source).toContain('slot name="trigger"');
expect(parse(source).outputs.map((output) => output.name)).toContain("select");
expect(html).toContain("wrn-dropdown__panel");
expect(html).toContain("Profile");
expect(html).toContain('data-danger="true"');
});
test("footer renders header and link entries with the requested column count", async () => {
const html = await renderComponent(readFileSync(uiComponentPath("Footer"), "utf8"), {
columns: 4,
items: [
{ type: "header", label: "Support" },
{ type: "link", label: "Contact", href: "/contact" },
],
copyright: "Public service platform",
});
expect(html).toContain("wrn-footer--columns-4");
expect(html).toContain("wrn-footer__column");
expect(html).toContain("wrn-footer__column-links");
expect(html).toContain("wrn-footer__heading");
expect(html).toContain("Support");
expect(html).toContain('href="/contact"');
expect(html).toContain("Public service platform");
expect(html).not.toMatch(
/class="wrn-footer__bottom wrn-footer__bottom--slots"[\s\S]*class="wrn-footer__bottom"/,
);
expect(html).toMatch(
/wrn-footer__copyright-left[\s\S]*wrn-footer__copyright[\s\S]*wrn-footer__copyright-right/,
);
});
test("footer emits valid current-page state and translation markers for data items", async () => {
const source = readFileSync(uiComponentPath("Footer"), "utf8");
const html = await renderComponent(source, {
items: [
{
type: "header",
label: "Services",
labelKey: "footer.services",
items: [{ label: "Report", labelKey: "footer.report", href: "/report" }],
},
],
});
expect(html).toContain('data-t="footer.services"');
expect(html).toContain('data-t="footer.report"');
expect(html).not.toContain('aria-current=""');
});
test("component-system CSS includes responsive, theme-token, focus, and reduced-motion rules", () => {
const css = uiStyles();
expect(css).toContain("@media (max-width: 768px)");
expect(css).toContain("@media (max-width: 480px)");
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
expect(css).toContain(":focus-visible");
expect(css).toContain("var(--wrn-color-surface)");
expect(css).toContain("min-height: 44px");
expect(css).toContain("--wrn-motion-base");
expect(css).toContain("--wrn-ease-emphasized");
expect(css).toContain("@keyframes wrn-component-enter");
expect(css).toContain("@keyframes wrn-dialog-enter");
expect(css).toContain("@media (hover: hover) and (pointer: fine)");
expect(css).toContain("--color-violet-600: var(--wrn-color-primary)");
expect(css).toContain("--color-blue-600: var(--wrn-color-info)");
expect(css).toContain("--color-red-600: var(--wrn-color-danger)");
expect(css).toMatch(/:root\s*\{[^}]*--color-violet-600: var\(--wrn-color-primary\)/s);
expect(css).toContain(".wrn-bg-primary");
expect(css).toContain(".wrn-text-muted");
expect(css).toContain(".wrn-visually-hidden");
expect(css).toContain(".wrn-component--color-primary");
expect(css).toContain(".wrn-next--field");
expect(css).toContain(".wrn-next__strong-password-requirements");
expect(css).toContain('[data-show="false"]');
expect(css).toContain("display: none !important");
/*
* Navbar styles now ship with the component rather than from ui.css, so the
* whole navigation group follows one convention. The rules themselves are
* unchanged; only where they live moved.
*/
const navbarSource = readFileSync(uiComponentPath("Navbar"), "utf8");
expect(uiCss()).not.toContain(".wrn-navbar");
expect(navbarSource).toMatch(
/\.wrn-navbar__brand-copy strong\s*\{[^}]*font-size: 0\.9rem;[^}]*font-weight: 600;/s,
);
expect(navbarSource).toMatch(
/\.wrn-navbar__menu-link,[\s\S]*?font-size: 0\.8125rem;[\s\S]*?font-weight: 500;/,
);
expect(css).toMatch(
/\.wrn-footer__heading\s*\{[^}]*font-size: 0\.8125rem;[^}]*font-weight: 600;/s,
);
expect(css).toMatch(/\.wrn-footer__link\s*\{[^}]*font-size: 0\.8125rem;[^}]*font-weight: 400;/s);
});
test("every component owns its styles without pseudo-component mounts", () => {
const globalCss = uiCss();
expect(globalCss).not.toContain(".wrn-component");
expect(globalCss).not.toContain(".wrn-next");
for (const name of uiComponentNames()) {
const source = readFileSync(uiComponentPath(name), "utf8");
expect(source).toMatch(/\n\s*style\s*\{/);
expect(source).not.toContain("ComponentBaseStyles");
expect(source).not.toContain("FieldStyles");
}
expect(readFileSync(uiComponentPath("Alert"), "utf8")).toContain("Shared component foundation.");
expect(readFileSync(uiComponentPath("Input"), "utf8")).toContain("Shared field foundation.");
});
test("component boundaries have no default margins and expose the canonical class prop", () => {
for (const name of uiComponentNames()) {
const source = readFileSync(uiComponentPath(name), "utf8");
const root = source.match(/view\s*\{\s*<[A-Za-z][^>]*>/)?.[0] ?? "";
const classes = root.match(/\bclass="([^"]*)"/)?.[1]?.split(/\s+/) ?? [];
expect(classes.filter((token) => /^-?m[trblxy]?-/.test(token))).toEqual([]);
expect(source).toMatch(/^\s*class\s*(?::[^=\r\n]+)?=/m);
expect(source).not.toMatch(/^\s*className\s*(?::[^=\r\n]+)?=/m);
expect(root).toContain("{class}");
}
});
test("every component exposes universal color and size configuration", () => {
for (const name of uiComponentNames()) {
const ast = parse(readFileSync(uiComponentPath(name), "utf8"));
const props = new Set(ast.props.map((prop) => prop.name));
expect(props.has("color")).toBe(true);
expect(props.has("size")).toBe(true);
}
});
// test("component markup keeps user-facing content and data behind props", () => {
// for (const name of uiComponentNames()) {
// const source = readFileSync(uiComponentPath(name), "utf8");
// const view = source.slice(source.indexOf("view {"));
// const literalText = [...view.matchAll(/>([^<>{}]*[A-Za-z][^<>{}]*)</g)]
// .map((match) => match[1].trim())
// .filter(Boolean);
// const literalAccessibleText = [
// ...view.matchAll(/\b(?:aria-label|title|placeholder|alt)="([^"{}]*[A-Za-z][^"{}]*)"/g),
// ];
// const literalDataAttributes = [
// ...view.matchAll(/\b(?:href|src|action|value)="([^"{}]+)"/g),
// ].filter((match) => !/^(?:true|false|on)$/.test(match[1]));
// expect(literalText).toEqual([]);
// expect(literalAccessibleText).toEqual([]);
// expect(literalDataAttributes).toEqual([]);
// }
// });
test("layout boundaries do not add default padding", () => {
for (const name of ["Container", "Columns", "Grid", "LayoutSplitter", "Typography"]) {
const source = readFileSync(uiComponentPath(name), "utf8");
const root = source.match(/view\s*\{\s*<[A-Za-z][^>]*>/)?.[0] ?? "";
const classes = root.match(/\bclass="([^"]*)"/)?.[1]?.split(/\s+/) ?? [];
expect(classes.filter((token) => /^p[trblxy]?-/.test(token))).toEqual([]);
}
});
test("every bundled UI component compiles", () => {
for (const name of uiComponentNames()) {
const path = uiComponentPath(name);
expect(() => compileWrnFile(readFileSync(path, "utf8"), path)).not.toThrow();
}
});
test("icon buttons expose their accessible label as a hover and focus tooltip", () => {
const button = readFileSync(uiComponentPath("Button"), "utf8");
expect(button).toContain('class="wrn-btn__tooltip"');
expect(button).toContain("{ariaLabel || label}");
expect(uiStyles()).toContain(".wrn-btn:hover > .wrn-btn__tooltip");
expect(uiStyles()).toContain(".wrn-btn:focus-visible > .wrn-btn__tooltip");
});
test("button links navigate by default and only download when the native attribute is passed", () => {
const button = readFileSync(uiComponentPath("Button"), "utf8");
expect(button).not.toMatch(/^\s+download\s*=/m);
expect(button).not.toContain('download="{download}"');
expect(button).toContain("{...attrs}");
});
test("button group renders connected actions and emits select and change events", async () => {
const source = readFileSync(uiComponentPath("ButtonGroup"), "utf8");
const html = await renderComponent(source, {
items: [
{ label: "Profile", value: "profile" },
{ label: "Settings", value: "settings" },
{ label: "Messages", value: "messages", disabled: true },
],
value: "profile",
selectable: true,
size: "sm",
variant: "outline",
ariaLabel: "Account views",
});
const dom = mountHtml(html);
const group = dom.querySelector(".wrn-next--button-group") as HTMLElement;
const buttons = [...dom.querySelectorAll(".wrn-next--button-group > button")];
const events: Array<{ name: string; detail: unknown }> = [];
for (const name of ["select", "change"]) {
group.addEventListener(name, (event) =>
events.push({ name, detail: (event as CustomEvent).detail }),
);
}
expect(group.getAttribute("role")).toBe("group");
expect(group.getAttribute("aria-label")).toBe("Account views");
expect(group.getAttribute("data-size")).toBe("sm");
expect(buttons).toHaveLength(3);
expect(buttons[0]?.getAttribute("aria-pressed")).toBe("true");
expect(buttons[2]?.hasAttribute("disabled")).toBe(true);
(buttons[1] as HTMLButtonElement).click();
expect(buttons[0]?.getAttribute("aria-pressed")).toBe("false");
expect(buttons[1]?.getAttribute("aria-pressed")).toBe("true");
expect(events.map((event) => event.name)).toEqual(["select", "change"]);
expect((events[1]?.detail as { value: string }).value).toBe("settings");
expect((events[1]?.detail as { previousValue: string }).previousValue).toBe("profile");
});
test("button group supports vertical, responsive, detached, and toolbar layouts", async () => {
const source = readFileSync(uiComponentPath("ButtonGroup"), "utf8");
const html = await renderComponent(source, {
items: [{ label: "Bold", icon: "icon-[lucide--bold]" }],
orientation: "vertical",
responsive: true,
attached: false,
toolbar: true,
ariaLabel: "Formatting",
});
const dom = mountHtml(html);
const group = dom.querySelector(".wrn-next--button-group") as HTMLElement;
expect(group.getAttribute("role")).toBe("toolbar");
expect(group.getAttribute("aria-orientation")).toBe("vertical");
expect(group.getAttribute("data-responsive")).toBe("true");
expect(group.getAttribute("data-attached")).toBe("false");
expect(group.querySelector('[class*="icon-[lucide--bold]"]')).not.toBeNull();
const css = uiStyles();
expect(css).toContain('.wrn-next--button-group[data-orientation="vertical"]');
expect(css).toContain('.wrn-next--button-group[data-responsive="true"]');
expect(css).toContain('.wrn-next--button-group[data-attached="false"]');
});
test("every bundled UI component forwards undeclared native attributes", () => {
for (const name of uiComponentNames()) {
const path = uiComponentPath(name);
const output = compileWrnFile(readFileSync(path, "utf8"), path);
expect(output).toContain("${__wrnSpreadAttrs(__attrs)}");
}
});
test("accordion coordinates single-open sections and emits open, close, and change events", async () => {
const source = readFileSync(uiComponentPath("Accordion"), "utf8");
const html = await renderComponent(source, {
id: "faq",
defaultOpen: ["first"],
items: [
{ value: "first", label: "First", content: "First content" },
{ value: "second", label: "Second", content: "Second content" },
],
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-accordion]") as HTMLElement;
const events: string[] = [];
for (const name of ["open", "close", "change"]) {
root.addEventListener(name, () => events.push(name));
}
expect(dom.querySelector("#faq-trigger-0")?.getAttribute("aria-expanded")).toBe("true");
expect(dom.querySelector("#faq-panel-0")?.getAttribute("aria-hidden")).toBe("false");
(dom.querySelector("#faq-trigger-1") as HTMLButtonElement).click();
expect(dom.querySelector("#faq-trigger-0")?.getAttribute("aria-expanded")).toBe("false");
expect(dom.querySelector("#faq-trigger-1")?.getAttribute("aria-expanded")).toBe("true");
expect(events).toEqual(["open", "change"]);
(dom.querySelector("#faq-trigger-1") as HTMLButtonElement).click();
expect(dom.querySelector("#faq-trigger-1")?.getAttribute("aria-expanded")).toBe("false");
expect(events).toEqual(["open", "change", "close", "change"]);
});
test("accordion supports always-open, nested, and disabled sections", async () => {
const source = readFileSync(uiComponentPath("Accordion"), "utf8");
const html = await renderComponent(source, {
id: "nested-faq",
alwaysOpen: true,
defaultOpen: ["first", "first.child"],
items: [
{
value: "first",
label: "First",
content: "First content",
children: [{ value: "child", label: "Child", content: "Child content" }],
},
{ value: "second", label: "Second", content: "Second content" },
{ value: "third", label: "Third", content: "Third content", disabled: true },
],
});
const dom = mountHtml(html);
expect(dom.querySelector("#nested-faq-trigger-0")?.getAttribute("aria-expanded")).toBe("true");
expect(dom.querySelector("#nested-faq-trigger-0-0")?.getAttribute("aria-expanded")).toBe("true");
(dom.querySelector("#nested-faq-trigger-1") as HTMLButtonElement).click();
expect(dom.querySelector("#nested-faq-trigger-0")?.getAttribute("aria-expanded")).toBe("true");
expect(dom.querySelector("#nested-faq-trigger-1")?.getAttribute("aria-expanded")).toBe("true");
expect((dom.querySelector("#nested-faq-trigger-2") as HTMLButtonElement).disabled).toBe(true);
});
test("accordion chevrons use a consistent icon instead of a font glyph", () => {
const source = readFileSync(uiComponentPath("Accordion"), "utf8");
expect(source).toContain("icon-[lucide--chevron-down]");
expect(source).not.toContain('return "⌄"');
});
test("alert renders structured content, lists, actions, and accessible semantics", async () => {
const source = readFileSync(uiComponentPath("Alert"), "utf8");
const html = await renderComponent(source, {
title: "Cannot save changes",
description: "Review the following fields.",
color: "danger",
variant: "soft",
showIcon: true,
items: [{ label: "Email is required" }, { label: "Phone number is invalid" }],
actions: [{ label: "Review form", href: "#form" }],
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-alert]") as HTMLElement;
let detail: Record<string, unknown> | undefined;
root.addEventListener("action", (event) => {
detail = (event as unknown as CustomEvent<Record<string, unknown>>).detail;
});
expect(root.getAttribute("role")).toBe("alert");
expect(root.getAttribute("aria-live")).toBe("polite");
expect(root.dataset.color).toBe("danger");
expect(dom.querySelectorAll(".wrn-next__alert-body li").map((node) => node.textContent)).toEqual([
"Email is required",
"Phone number is invalid",
]);
(dom.querySelector(".wrn-next__alert-actions a") as HTMLAnchorElement).click();
expect(detail).toMatchObject({
component: "Alert",
action: { label: "Review form", href: "#form" },
});
});
test("dismissible alert hides itself and emits dismiss details", async () => {
const source = readFileSync(uiComponentPath("Alert"), "utf8");
const html = await renderComponent(source, {
title: "Update available",
description: "Install the latest version.",
dismissible: true,
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-alert]") as HTMLElement;
let dismissed = false;
root.addEventListener("dismiss", () => {
dismissed = true;
});
(dom.querySelector(".wrn-next__alert-dismiss") as HTMLButtonElement).click();
expect(dismissed).toBe(true);
expect(root.getAttribute("aria-hidden")).toBe("true");
expect(root.style.display).toBe("none");
});
test("solid alert palettes preserve readable foregrounds and compact inline layout", () => {
const css = uiStyles();
expect(css).toContain('.wrn-next--alert[data-color="secondary"]');
expect(css).toContain("--wrn-alert-color: #737373");
expect(css).toContain(
".wrn-next--alert-compact:not(:has(.wrn-next__alert-icon)):not(:has(.wrn-next__alert-dismiss))",
);
expect(css).toContain(".wrn-next--alert-compact .wrn-next__alert-body > p");
expect(css).toContain("color: currentColor");
});
test("alert exposes configurable radius and elevation treatments", async () => {
const source = readFileSync(uiComponentPath("Alert"), "utf8");
const html = await renderComponent(source, {
title: "Elevated alert",
radius: "xl",
shadow: "lg",
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-alert]") as HTMLElement;
const css = uiStyles();
expect(root.dataset.radius).toBe("xl");
expect(root.dataset.shadow).toBe("lg");
expect(css).toContain('.wrn-next--alert[data-radius="xl"]');
expect(css).toContain('.wrn-next--alert[data-shadow="lg"]');
expect(css).toContain("border-radius: calc(var(--wrn-radius, 0.75rem) * 1.75)");
expect(css).toContain("0 20px 48px color-mix(in srgb, #000 24%, transparent)");
});
test("avatar renders images, fallbacks, presence, badges, tooltips, and media copy", async () => {
const source = readFileSync(uiComponentPath("Avatar"), "utf8");
const imageHtml = await renderComponent(source, {
src: "/avatar.jpg",
alt: "Avery Chen",
shape: "rounded",
statusPosition: "top",
status: "online",
statusLabel: "Online",
tooltip: "Avery Chen · Online",
});
const imageDom = mountHtml(imageHtml);
const imageRoot = imageDom.querySelector("[data-wrn-avatar]") as HTMLElement;
expect(imageRoot.querySelector('img[alt="Avery Chen"]')).not.toBeNull();
expect(imageRoot.querySelector('[data-status="online"]')?.getAttribute("aria-label")).toBe(
"Online",
);
expect(imageRoot.querySelector('[role="tooltip"]')?.textContent).toContain("Avery Chen");
const initialsDom = mountHtml(
await renderComponent(source, {
initials: "MW",
color: "success",
badgeIcon: "icon-[lucide--message-square]",
badgeLabel: "Messaging account",
name: "Mark Wanner",
description: "mark@example.com",
}),
);
expect(initialsDom.querySelector(".wrn-next__avatar-initials")?.textContent).toBe("MW");
expect(initialsDom.querySelector(".wrn-next__avatar-badge")?.getAttribute("aria-label")).toBe(
"Messaging account",
);
expect(initialsDom.querySelector(".wrn-next__avatar-copy")?.textContent).toContain(
"mark@example.com",
);
const placeholderDom = mountHtml(await renderComponent(source, { size: "sm" }));
expect(placeholderDom.querySelector(".wrn-next__avatar-placeholder")).not.toBeNull();
expect(source).not.toContain("items =");
expect(uiStyles()).toContain('.wrn-next__avatar-wrap[data-shape="rounded"]');
});
test("avatar group limits members and opens an accessible overflow menu", async () => {
const source = readFileSync(uiComponentPath("AvatarGroup"), "utf8");
const html = await renderComponent(source, {
maxVisible: 2,
borderColor: "#2583ff",
items: [
{ src: "/one.svg", name: "One" },
{ initials: "TW", name: "Two" },
{ initials: "TH", name: "Three" },
{ initials: "FO", name: "Four" },
],
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-avatar-group]") as HTMLElement;
const button = root.querySelector(".wrn-next__avatar-group-overflow-button") as HTMLButtonElement;
let eventDetail: { open: boolean; hiddenCount: number } | undefined;
root.addEventListener("overflow", (event) => {
eventDetail = (event as CustomEvent).detail;
});
expect(root.querySelectorAll(".wrn-next__avatar-group-member")).toHaveLength(2);
expect(button.textContent?.trim()).toBe("+2");
expect(button.getAttribute("aria-expanded")).toBe("false");
expect(root.getAttribute("style")).toContain("#2583ff");
expect(root.querySelector(".wrn-next__avatar-group-menu")?.getAttribute("data-show")).toBe(
"false",
);
button.click();
expect(
dom.querySelector(".wrn-next__avatar-group-overflow-button")?.getAttribute("aria-expanded"),
).toBe("true");
expect(dom.querySelectorAll('[role="menuitem"]')).toHaveLength(2);
expect(dom.querySelector(".wrn-next__avatar-group-menu")?.getAttribute("data-show")).toBe("true");
expect(eventDetail).toEqual(expect.objectContaining({ open: true, hiddenCount: 2 }));
expect(uiStyles()).toContain('.wrn-next--avatar-group[data-layout="grid"]');
});
test("badge supports rich content, anchored placement, animation, and dismissal", async () => {
const source = readFileSync(uiComponentPath("Badge"), "utf8");
const html = await renderComponent(source, {
label: "Christina",
color: "success",
variant: "soft",
icon: "icon-[lucide--circle-check]",
dot: true,
avatarSrc: "/avatar.svg",
avatarAlt: "Christina",
dismissible: true,
animated: true,
anchorIcon: "icon-[lucide--bell]",
anchorLabelText: "Open notifications",
placement: "top-right",
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-badge]") as HTMLElement;
let dismissed = "";
root.addEventListener("dismiss", (event) => {
dismissed = (event as CustomEvent).detail.label;
});
expect(root.dataset.placement).toBe("top-right");
expect(root.querySelector(".wrn-next__badge-anchor")?.getAttribute("aria-label")).toBe(
"Open notifications",
);
expect(root.querySelector('img[alt="Christina"]')).not.toBeNull();
expect(root.querySelector(".wrn-next__badge-dot")).not.toBeNull();
expect(root.querySelector(".wrn-next__badge-ping")).not.toBeNull();
(root.querySelector(".wrn-next__badge-dismiss") as HTMLButtonElement).click();
expect(dom.querySelector("[data-wrn-badge]")?.getAttribute("data-show")).toBe("false");
expect(dismissed).toBe("Christina");
expect(source).not.toContain("items =");
expect(uiStyles()).toContain("@keyframes wrn-badge-ping");
});
test("blockquote renders semantic quote and citation content", async () => {
const source = readFileSync(uiComponentPath("Blockquote"), "utf8");
const html = await renderComponent(source, {
quote: "Design is intelligence made visible.",
citation: "Alina Wheeler",
citationTitle: "Author, Designing Brand Identity",
citationUrl: "https://example.com/source",
avatarSrc: "/authors/alina.jpg",
avatarAlt: "Alina Wheeler",
size: "lg",
color: "info",
align: "center",
});
const dom = mountHtml(html);
const figure = dom.querySelector("figure") as HTMLElement;
const quote = figure.querySelector("blockquote");
const citation = figure.querySelector("figcaption");
expect(figure.getAttribute("data-size")).toBe("lg");
expect(figure.getAttribute("data-color")).toBe("info");
expect(figure.getAttribute("data-align")).toBe("center");
expect(quote?.getAttribute("cite")).toBe("https://example.com/source");
expect(quote?.textContent).toContain("Design is intelligence made visible.");
expect(quote?.querySelector('[aria-hidden="true"]')?.textContent).toBe("“");
expect(citation?.querySelector("cite a")?.textContent).toBe("Alina Wheeler");
expect(citation?.textContent).toContain("Author, Designing Brand Identity");
expect(citation?.querySelector("img")?.getAttribute("alt")).toBe("Alina Wheeler");
});
test("blockquote supports bordered, right-aligned, non-italic, and slotted quotes", async () => {
const source = readFileSync(uiComponentPath("Blockquote"), "utf8");
const html = await renderComponent(source, {
quote: "",
variant: "bordered",
align: "right",
italic: false,
quoteMark: false,
});
const dom = mountHtml(html);
const figure = dom.querySelector("figure") as HTMLElement;
expect(figure.getAttribute("data-variant")).toBe("bordered");
expect(figure.getAttribute("data-align")).toBe("right");
expect(figure.getAttribute("data-italic")).toBe("false");
expect(figure.querySelector(".wrn-next__blockquote-mark")).toBeNull();
expect(source).toContain("<slot />");
const css = uiStyles();
expect(css).toContain('.wrn-next--blockquote[data-variant="bordered"]');
expect(css).toContain('.wrn-next--blockquote[data-align="center"]');
expect(css).toContain("--wrn-blockquote-font-size");
});
test("card renders structured media, header, body, action, and footer content", async () => {
const source = readFileSync(uiComponentPath("Card"), "utf8");
const html = await renderComponent(source, {
title: "Release notes",
subtitle: "Version 2.4",
description: "A structured card description.",
header: "Featured",
footer: "Updated today",
imageSrc: "/release.jpg",
imageAlt: "Release preview",
actionLabel: "Read more",
actionHref: "/releases/2-4",
size: "lg",
color: "info",
});
const dom = mountHtml(html);
const card = dom.querySelector(".wrn-next--card") as HTMLElement;
expect(card.getAttribute("data-size")).toBe("lg");
expect(card.getAttribute("data-color")).toBe("info");
expect(card.querySelector("article")).not.toBeNull();
expect(card.querySelector("header")?.textContent).toContain("Featured");
expect(card.querySelector("img")?.getAttribute("alt")).toBe("Release preview");
expect(card.querySelector("h3")?.textContent).toBe("Release notes");
expect(card.querySelector(".wrn-next__card-subtitle")?.textContent).toBe("Version 2.4");
expect(card.querySelector(".wrn-next__card-action")?.getAttribute("href")).toBe("/releases/2-4");
expect(card.querySelector("footer")?.textContent).toContain("Updated today");
});
test("card navigation, header actions, and dismissal emit public events", async () => {
const source = readFileSync(uiComponentPath("Card"), "utf8");
const html = await renderComponent(source, {
title: "Account",
header: "Card actions",
navigation: [
{ label: "Profile", value: "profile" },
{ label: "Security", value: "security" },
],
activeNav: "profile",
headerActions: [{ label: "Refresh", icon: "icon-[lucide--refresh-cw]" }],
dismissible: true,
});
const dom = mountHtml(html);
const card = dom.querySelector(".wrn-next--card") as HTMLElement;
const events: string[] = [];
for (const name of ["navigate", "action", "dismiss"]) {
card.addEventListener(name, () => events.push(name));
}
(dom.querySelectorAll(".wrn-next__card-tabs button")[1] as HTMLButtonElement).click();
(dom.querySelector(".wrn-next__card-header-actions button") as HTMLButtonElement).click();
(dom.querySelectorAll(".wrn-next__card-header-actions button")[1] as HTMLButtonElement).click();
expect(events).toEqual(["navigate", "action", "dismiss"]);
expect(dom.querySelector(".wrn-next__card-panel")?.hasAttribute("hidden")).toBe(true);
});
test("card supports overlays, horizontal media, groups, alerts, empty and scrolling panels", async () => {
const source = readFileSync(uiComponentPath("Card"), "utf8");
const overlay = mountHtml(
await renderComponent(source, {
title: "Overlay",
description: "Overlay copy",
imageSrc: "/overlay.jpg",
imagePosition: "overlay",
}),
);
const group = mountHtml(
await renderComponent(source, {
items: [
{ title: "First", description: "First card" },
{ title: "Second", description: "Second card" },
],
}),
);
const panel = mountHtml(
await renderComponent(source, {
title: "",
alertTitle: "Attention",
alertDescription: "Review this state.",
empty: true,
scrollable: true,
maxHeight: "12rem",
}),
);
expect(overlay.querySelector(".wrn-next__card-overlay")).not.toBeNull();
expect(group.querySelectorAll(".wrn-next__card-group > article")).toHaveLength(2);
expect(panel.querySelector(".wrn-next__card-alert")?.getAttribute("role")).toBe("status");
expect(panel.querySelector(".wrn-next__card-empty")?.textContent).toContain("No data to show");
expect(panel.querySelector(".wrn-next__card-body")?.getAttribute("data-scrollable")).toBe("true");
const css = uiStyles();
expect(css).toContain('.wrn-next--card[data-layout="horizontal"]');
expect(css).toContain('.wrn-next--card[data-hover="image"]');
expect(css).toContain(".wrn-next__card-group");
expect(css).toContain(".wrn-next__card-navigation-select");
expect(css).toContain("aspect-ratio: 16 / 9");
expect(css).toContain("rgb(2 6 23 / 0.86)");
});
test("chat bubble renders conversations, avatars, metadata, links, and public events", async () => {
const source = readFileSync(uiComponentPath("ChatBubble"), "utf8");
const events: string[] = [];
const dom = mountHtml(
await renderComponent(source, {
ariaLabel: "Support conversation",
oneSided: true,
showAvatars: true,
showMetadata: true,
items: [
{
direction: "incoming",
author: "Support",
avatarFallback: "SP",
title: "How can we help?",
text: "Choose a guide.",
bullets: ["Installation", "Components"],
links: [{ label: "Installation guide", href: "/installation" }],
status: "Sent",
timestamp: "10:32",
},
{
direction: "outgoing",
text: "Please retry.",
status: "Not sent",
statusTone: "danger",
action: "retry",
actionLabel: "Retry",
},
],
}),
);
const root = dom.querySelector(".wrn-next--chat-bubble") as HTMLElement;
for (const [name, label] of [
["messageClick", "message"],
["avatarClick", "avatar"],
["linkClick", "link"],
["action", "action"],
]) {
root.addEventListener(name, () => events.push(label));
}
expect(root.getAttribute("role")).toBe("log");
expect(root.getAttribute("aria-label")).toBe("Support conversation");
expect(root.dataset.oneSided).toBe("true");
expect(dom.querySelectorAll(".wrn-next__chat-message")).toHaveLength(2);
expect(dom.querySelectorAll(".wrn-next__chat-content li")).toHaveLength(2);
expect(dom.querySelector(".wrn-next__chat-meta[data-tone='danger']")).not.toBeNull();
expect(uiStyles()).toContain("color: var(--wrn-color-primary-contrast, #fff)");
(dom.querySelector(".wrn-next__chat-content") as HTMLElement).click();
(dom.querySelector(".wrn-next__chat-avatar") as HTMLButtonElement).click();
(dom.querySelector(".wrn-next__chat-content a") as HTMLAnchorElement).click();
(dom.querySelector(".wrn-next__chat-meta button") as HTMLButtonElement).click();
expect(events).toEqual(["message", "avatar", "link", "action"]);
});
test("collapse toggles accessible panels and emits open, close, and toggle events", async () => {
const source = readFileSync(uiComponentPath("Collapse"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
ariaLabel: "Product details",
initialOpenIndexes: [0],
items: [
{
id: "product-details",
label: "Expand",
closeLabel: "Collapse",
title: "Details",
content: "Additional product information.",
},
],
}),
);
const root = dom.querySelector(".wrn-next--collapse") as HTMLElement;
const trigger = dom.querySelector(".wrn-next__collapse-trigger") as HTMLButtonElement;
const panel = dom.querySelector(".wrn-next__collapse-panel") as HTMLElement;
const events: string[] = [];
for (const name of ["open", "close", "toggle"]) {
root.addEventListener(name, () => events.push(name));
}
expect(root.getAttribute("aria-label")).toBe("Product details");
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(trigger.getAttribute("aria-controls")).toBe("product-details");
expect(panel.dataset.open).toBe("true");
expect(panel.getAttribute("aria-hidden")).toBe("false");
expect(uiStyles()).toContain("max-height: 40rem");
expect(trigger.textContent).toContain("Collapse");
trigger.click();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(panel.dataset.open).toBe("false");
expect(panel.getAttribute("aria-hidden")).toBe("true");
expect(events).toEqual(["close", "toggle"]);
trigger.click();
expect(panel.dataset.open).toBe("true");
expect(events).toEqual(["close", "toggle", "open", "toggle"]);
});
test("collapse supports inline read-more and single or multiple open panels", async () => {
const source = readFileSync(uiComponentPath("Collapse"), "utf8");
const items = [
{ id: "first-collapse", label: "Read more", preview: "First preview", content: "First" },
{ id: "second-collapse", label: "Read more", preview: "Second preview", content: "Second" },
];
const single = mountHtml(await renderComponent(source, { mode: "inline", items }));
const singleTriggers = single.querySelectorAll(
".wrn-next__collapse-trigger",
) as HTMLButtonElement[];
singleTriggers[0]?.click();
singleTriggers[1]?.click();
expect(singleTriggers[0]?.getAttribute("aria-expanded")).toBe("false");
expect(singleTriggers[1]?.getAttribute("aria-expanded")).toBe("true");
expect(single.querySelectorAll(".wrn-next__collapse-preview")).toHaveLength(2);
const multiple = mountHtml(await renderComponent(source, { multiple: true, items }));
const multipleTriggers = multiple.querySelectorAll(
".wrn-next__collapse-trigger",
) as HTMLButtonElement[];
multipleTriggers[0]?.click();
multipleTriggers[1]?.click();
expect(multipleTriggers[0]?.getAttribute("aria-expanded")).toBe("true");
expect(multipleTriggers[1]?.getAttribute("aria-expanded")).toBe("true");
});
test("basic form fields expose shared labels, variants, states, hints, values, and validation targets", async () => {
const { checkField } = await import("../../validation/src/index.ts");
const validation = checkField(
{
type: "string",
optional: false,
trim: true,
rules: [{ kind: "email", message: "Enter a valid email address." }],
},
"not-an-email",
);
expect(validation.error).toBe("Enter a valid email address.");
const source = readFileSync(uiComponentPath("Input"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
id: "account-email",
name: "email",
label: "Email address",
placeholder: "you@example.com",
value: "not-an-email",
type: "email",
variant: "floating",
icon: "icon-[lucide--mail]",
iconPosition: "end",
helperText: "Used for account notices.",
cornerHint: "Required",
error: validation.error,
inline: true,
required: true,
}),
);
const root = dom.querySelector(".wrn-next--input") as HTMLElement;
const input = dom.querySelector("input") as HTMLInputElement;
expect(root.dataset.variant).toBe("floating");
expect(root.dataset.inline).toBe("true");
expect(root.dataset.invalid).toBe("true");
expect(input.id).toBe("account-email");
expect(input.name).toBe("email");
expect(input.value).toBe("not-an-email");
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(dom.querySelector("[data-error='email']")?.textContent).toContain(
"Enter a valid email address.",
);
expect(dom.querySelector(".wrn-next__field-hint")?.textContent).toContain("Required");
expect(dom.querySelector(".wrn-next__field-icon")).not.toBeNull();
const emitted: string[] = [];
for (const name of ["input", "change", "focus", "blur", "keydown", "keyup"]) {
root.addEventListener(name, () => emitted.push(name));
}
input.dispatchEvent(new (dom.window as any).Event("input", { bubbles: true }));
input.dispatchEvent(new (dom.window as any).Event("change", { bubbles: true }));
input.dispatchEvent(new (dom.window as any).Event("focus", { bubbles: true }));
input.dispatchEvent(new (dom.window as any).Event("blur", { bubbles: true }));
input.dispatchEvent(
new (dom.window as any).KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
input.dispatchEvent(
new (dom.window as any).KeyboardEvent("keyup", { key: "Enter", bubbles: true }),
);
expect(emitted).toEqual(["input", "change", "focus", "blur", "keydown", "keyup"]);
});
test("basic fields render native constraint messages and select matches field surfaces", async () => {
const inputSource = readFileSync(uiComponentPath("Input"), "utf8");
const dom = mountHtml(
await renderComponent(inputSource, {
id: "required-name",
name: "name",
label: "Name",
required: true,
}),
);
const input = dom.querySelector("input") as HTMLInputElement;
input.dispatchEvent(new (dom.window as any).Event("invalid", { bubbles: false }));
expect(dom.querySelector("[data-error='name']")?.textContent?.trim()).toBe(
input.validationMessage,
);
expect(dom.querySelector(".wrn-next--input")?.getAttribute("data-invalid")).toBe("true");
const selectSource = readFileSync(uiComponentPath("Select"), "utf8");
expect(selectSource).toContain("appearance: none");
expect(selectSource).toContain("background-color: var(--wrn-color-surface)");
expect(selectSource).toContain("wrn-next__select-indicator");
});
test("all basic form components render values and their must-have interaction events", async () => {
const components = [
"Checkbox",
"ColorPicker",
"FileInput",
"Input",
"InputGroup",
"Radio",
"RangeSlider",
"Select",
"Switch",
"Textarea",
"TimePicker",
];
for (const component of components) {
const source = readFileSync(uiComponentPath(component), "utf8");
const props: Record<string, unknown> = {
id: `${component}-field`,
name: `${component}Value`,
label: `${component} label`,
helperText: "Helper text",
cornerHint: "Optional",
error: "",
inline: true,
readonly: true,
disabled: false,
value: component === "RangeSlider" ? 40 : "value",
options: [{ label: "Value", value: "value" }],
};
const dom = mountHtml(await renderComponent(source, props));
const control = dom.querySelector("input, textarea, select") as HTMLElement | null;
expect(control).not.toBeNull();
expect(control?.getAttribute("name")).toBe(`${component}Value`);
expect(dom.querySelector(".wrn-next__field-help")?.textContent).toContain("Helper text");
expect(dom.querySelector(`[data-error="${component}Value"]`)).not.toBeNull();
}
});
test("radio renders passed options in group, card, list, alignment, and description layouts", async () => {
const source = readFileSync(uiComponentPath("Radio"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
id: "plan",
name: "plan",
label: "Choose a plan",
value: "professional",
orientation: "horizontal",
card: true,
list: true,
rightAligned: true,
options: [
{ label: "Starter", value: "starter", description: "For individuals." },
{ label: "Professional", value: "professional", description: "For teams." },
{ label: "Enterprise", value: "enterprise", disabled: true },
],
}),
);
const root = dom.querySelector(".wrn-next--radio-field") as HTMLElement;
const controls = [...dom.querySelectorAll('input[type="radio"]')] as HTMLInputElement[];
expect(root.dataset.orientation).toBe("horizontal");
expect(root.dataset.card).toBe("true");
expect(root.dataset.list).toBe("true");
expect(root.dataset.rightAligned).toBe("true");
expect(controls).toHaveLength(3);
expect(controls[1]?.checked).toBe(true);
expect(controls[2]?.disabled).toBe(true);
expect(dom.querySelectorAll(".wrn-next__choice-copy small")).toHaveLength(2);
});
test("checkbox renders passed values in group, card, list, alignment, description, and mixed layouts", async () => {
const source = readFileSync(uiComponentPath("Checkbox"), "utf8");
const group = mountHtml(
await renderComponent(source, {
id: "notifications",
name: "notifications",
label: "Notification preferences",
values: ["account"],
orientation: "horizontal",
card: true,
list: true,
rightAligned: true,
options: [
{ label: "Product", value: "product", description: "Product news." },
{ label: "Account", value: "account", description: "Security alerts." },
{ label: "Partners", value: "partners", disabled: true },
],
}),
);
const controls = [...group.querySelectorAll('input[type="checkbox"]')] as HTMLInputElement[];
expect(group.querySelector(".wrn-next--checkbox-field")?.getAttribute("data-orientation")).toBe(
"horizontal",
);
expect(controls).toHaveLength(3);
expect(controls[1]?.checked).toBe(true);
expect(controls[2]?.disabled).toBe(true);
expect(group.querySelectorAll(".wrn-next__choice-copy small")).toHaveLength(2);
const mixed = mountHtml(
await renderComponent(source, { id: "mixed", name: "mixed", indeterminate: true }),
);
expect(mixed.querySelector("input")?.getAttribute("aria-checked")).toBe("mixed");
});
test("range slider exposes value, minimum, maximum, step, bounds, and optional marks", async () => {
const source = readFileSync(uiComponentPath("RangeSlider"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
id: "volume",
name: "volume",
value: 50,
min: 0,
max: 100,
step: 25,
showBounds: true,
showSteps: true,
marks: [
{ value: 0, label: "Minimum" },
{ value: 50, label: "Middle" },
{ value: 100, label: "Maximum" },
],
}),
);
const control = dom.querySelector('input[type="number"]') as HTMLInputElement;
const slider = dom.querySelector('[role="slider"]') as HTMLElement;
expect(control.min).toBe("0");
expect(control.max).toBe("100");
expect(control.step).toBe("25");
expect(slider.getAttribute("aria-valuenow")).toBe("50");
expect(dom.querySelector(".wrn-next__range-bounds")?.textContent).toContain("Step 25");
expect(dom.querySelectorAll(".wrn-next__slider-marks button")).toHaveLength(3);
expect(slider.getAttribute("tabindex")).toBe("0");
expect(source).toContain('sourceEvent.key === "ArrowRight"');
const increase = dom.querySelector('[aria-label="Increase Range"]') as HTMLButtonElement;
increase.dispatchEvent(new (dom.window as any).Event("click", { bubbles: true }));
expect(dom.querySelector('[role="slider"]')?.getAttribute("aria-valuenow")).toBe("75");
dom
.querySelector('[role="slider"]')
?.dispatchEvent(
new (dom.window as any).KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
);
expect(dom.querySelector('[role="slider"]')?.getAttribute("aria-valuenow")).toBe("100");
const middleMark = dom.querySelectorAll(".wrn-next__slider-marks button")[1] as HTMLButtonElement;
middleMark.dispatchEvent(new (dom.window as any).Event("click", { bubbles: true }));
expect(dom.querySelector('[role="slider"]')?.getAttribute("aria-valuenow")).toBe("50");
expect((dom.querySelector('input[type="number"]') as HTMLInputElement).value).toBe("50");
});
test("date picker renders a custom calendar trigger with accessible field state and events", async () => {
const source = readFileSync(uiComponentPath("DatePicker"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
id: "start-date",
name: "startDate",
label: "Start date",
value: "2026-07-28",
min: "2026-01-01",
max: "2026-12-31",
helperText: "Use your local date.",
cornerHint: "Required",
required: true,
}),
);
const input = dom.querySelector("input") as HTMLInputElement;
expect(input.type).toBe("text");
expect(input.required).toBe(true);
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(source).toContain("months =");
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");
trigger.dispatchEvent(new (dom.window as any).Event("click", { bubbles: true }));
expect(dom.querySelector(".wrn-next--date-picker")?.getAttribute("data-expanded")).toBe("true");
expect(dom.querySelector('button[aria-haspopup="dialog"]')?.getAttribute("aria-expanded")).toBe(
"true",
);
expect(dom.querySelectorAll(".wrn-next__calendar-grid button")).toHaveLength(31);
const fifteenth = dom.querySelectorAll(
".wrn-next__calendar-grid button",
)[14] as HTMLButtonElement;
fifteenth.dispatchEvent(new (dom.window as any).Event("click", { bubbles: true }));
expect(input.value).toBe("2026-07-15");
expect(dom.querySelector(".wrn-next__date-trigger span")?.textContent).toBe("2026-07-15");
});
test("time picker uses a custom reactive panel instead of the browser time control", async () => {
const source = readFileSync(uiComponentPath("TimePicker"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
id: "meeting-time",
name: "meetingTime",
value: "13:30",
format: "12",
minuteStep: 15,
}),
);
expect(source).not.toContain('type="time"');
const trigger = dom.querySelector(".wrn-next__time-trigger") as HTMLButtonElement;
expect(trigger.textContent).toContain("13:30");
trigger.dispatchEvent(new (dom.window as any).Event("click", { bubbles: true }));
expect(dom.querySelector(".wrn-next--time-picker")?.getAttribute("data-expanded")).toBe("true");
expect(dom.querySelectorAll(".wrn-next__time-options").length).toBe(2);
expect(dom.querySelectorAll(".wrn-next__time-options button")).toHaveLength(36);
const nine = [...dom.querySelectorAll(".wrn-next__time-options button")].find(
(button) => button.textContent?.trim() === "09",
) as HTMLButtonElement;
nine.dispatchEvent(new (dom.window as any).Event("click", { bubbles: true }));
expect((dom.querySelector('input[name="meetingTime"]') as HTMLInputElement).value).toBe("09:30");
expect(dom.querySelector(".wrn-next__time-trigger span")?.textContent).toBe("09:30");
});
test("file upload progress reacts to cancel, retry, and complete actions", async () => {
const source = readFileSync(uiComponentPath("FileUploadProgress"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
fileName: "design-system.zip",
fileSize: "20 MB",
uploadedSize: "10 MB",
value: 50,
max: 100,
status: "uploading",
}),
);
expect(dom.querySelector(".wrn-next--file-upload-progress")?.getAttribute("data-status")).toBe(
"uploading",
);
expect(dom.querySelector(".wrn-next__row")?.textContent).toContain("design-system.zip");
const buttons = dom.querySelectorAll(".wrn-next__upload-actions button") as HTMLButtonElement[];
buttons[0]?.dispatchEvent(new (dom.window as any).Event("click", { bubbles: true }));
expect(dom.querySelector(".wrn-next--file-upload-progress")?.getAttribute("data-status")).toBe(
"cancelled",
);
});
test("carousel renders accessible slides, pagination, counter, and navigation events", async () => {
const source = readFileSync(uiComponentPath("Carousel"), "utf8");
const html = await renderComponent(source, {
ariaLabel: "Featured stories",
showPagination: true,
showCounter: true,
items: [
{ title: "First story", description: "First description" },
{ title: "Second story", description: "Second description" },
{ title: "Third story", description: "Third description" },
],
});
const dom = mountHtml(html);
const carousel = dom.querySelector(".wrn-next--carousel") as HTMLElement;
const events: string[] = [];
for (const name of ["change", "next", "previous"]) {
carousel.addEventListener(name, () => events.push(name));
}
expect(carousel.getAttribute("role")).toBe("region");
expect(carousel.getAttribute("aria-roledescription")).toBe("carousel");
expect(carousel.getAttribute("aria-label")).toBe("Featured stories");
expect(
Array.from(dom.querySelectorAll('[aria-roledescription="slide"]')).map((slide) =>
slide.textContent?.trim(),
),
).toEqual(
expect.arrayContaining([
expect.stringContaining("First story"),
expect.stringContaining("Second story"),
expect.stringContaining("Third story"),
]),
);
expect(dom.querySelectorAll('[aria-roledescription="slide"]')).toHaveLength(3);
expect(dom.querySelectorAll(".wrn-next__carousel-pagination button")).toHaveLength(3);
expect(dom.querySelector(".wrn-next__carousel-counter")?.textContent).toContain("1 / 3");
(dom.querySelector('[aria-label="Next slide"]') as HTMLButtonElement).click();
expect(dom.querySelector(".wrn-next__carousel-counter")?.textContent).toContain("2 / 3");
expect(events).toEqual(["change", "next"]);
(dom.querySelector('[aria-label="Previous slide"]') as HTMLButtonElement).click();
expect(events).toEqual(["change", "next", "change", "previous"]);
});
test("carousel supports RTL, multiple slides, dragging, snap, and thumbnail layouts", async () => {
const source = readFileSync(uiComponentPath("Carousel"), "utf8");
const items = [{ title: "First" }, { title: "Second" }, { title: "Third" }, { title: "Fourth" }];
const html = await renderComponent(source, {
items,
slidesPerView: 3,
isRTL: true,
isDraggable: true,
isAutoHeight: true,
thumbnails: "vertical",
});
const dom = mountHtml(html);
const carousel = dom.querySelector(".wrn-next--carousel") as HTMLElement;
const track = dom.querySelector(".wrn-next__carousel-track") as HTMLElement;
expect(carousel.getAttribute("dir")).toBe("rtl");
expect(carousel.dataset.draggable).toBe("true");
expect(carousel.dataset.autoHeight).toBe("true");
expect(carousel.dataset.thumbnails).toBe("vertical");
expect(track.getAttribute("style")).toContain("--wrn-carousel-per-view: 3");
expect(dom.querySelectorAll(".wrn-next__carousel-thumbnails button")).toHaveLength(4);
const snapHtml = await renderComponent(source, {
items,
isSnap: true,
isDraggable: true,
});
const snapDom = mountHtml(snapHtml);
const snapRoot = snapDom.querySelector(".wrn-next--carousel") as HTMLElement;
expect(snapRoot.dataset.snap).toBe("true");
expect(snapRoot.dataset.draggable).toBe("false");
const css = uiStyles();
expect(css).toContain('.wrn-next--carousel[data-rtl="true"]');
expect(css).toContain('.wrn-next--carousel[data-snap="true"]');
expect(css).toContain('.wrn-next--carousel[data-thumbnails="vertical"]');
expect(css).toContain("user-select: none");
expect(css).toContain('.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-track');
});
test("carousel autoplay timers are available in the browser reactive runtime", () => {
// Resolve them through the runtime rather than asserting on its source: the
// timers only have to be reachable from a client expression, and a substring
// check goes stale the moment the lookup is written differently.
const dom = mountHtml(
`<div data-scope="started: 0, stopped: 0">` +
`<button data-on-click="started = setInterval; stopped = clearInterval">go</button>` +
`<span class="started" data-text="started"></span>` +
`<span class="stopped" data-text="stopped"></span>` +
`</div>`,
);
(dom.querySelector("button") as HTMLButtonElement).click();
expect(dom.querySelector(".started")?.textContent).toContain("function");
expect(dom.querySelector(".stopped")?.textContent).toContain("function");
});
test("carousel snap controls scroll and multiple slides stop at the last full group", async () => {
const source = readFileSync(uiComponentPath("Carousel"), "utf8");
const items = [
{ title: "First" },
{ title: "Second" },
{ title: "Third" },
{ title: "Fourth" },
{ title: "Fifth" },
{ title: "Sixth" },
];
const snapDom = mountHtml(
await renderComponent(source, {
items,
isSnap: true,
showPagination: true,
}),
);
const viewport = snapDom.querySelector(".wrn-next__carousel-viewport") as HTMLElement & {
scrollTo: (options: ScrollToOptions) => void;
};
let scrollLeft: number | undefined;
viewport.scrollTo = (options: ScrollToOptions | number) => {
scrollLeft = typeof options === "number" ? options : options.left;
};
(
snapDom.querySelectorAll(".wrn-next__carousel-pagination button")[1] as HTMLButtonElement
).click();
expect(scrollLeft).toBeDefined();
const snapNext = snapDom.querySelector('[aria-label="Next slide"]') as HTMLButtonElement;
for (let index = 0; index < items.length; index++) snapNext.click();
const paginationButtons = snapDom.querySelectorAll(".wrn-next__carousel-pagination button");
expect(paginationButtons[items.length - 1]?.getAttribute("aria-current")).toBe("true");
expect(snapNext.disabled).toBe(true);
const multipleDom = mountHtml(
await renderComponent(source, {
items,
slidesPerView: 3,
showCounter: true,
}),
);
const next = multipleDom.querySelector('[aria-label="Next slide"]') as HTMLButtonElement;
for (let index = 0; index < 6; index++) next.click();
expect(multipleDom.querySelector(".wrn-next__carousel-counter")?.textContent).toContain("4 / 6");
expect(next.disabled).toBe(true);
});
test("carousel centers the last slide and auto-scrolls thumbnail rails", async () => {
const source = readFileSync(uiComponentPath("Carousel"), "utf8");
const items = [{ title: "First" }, { title: "Second" }, { title: "Third" }, { title: "Fourth" }];
const centeredDom = mountHtml(
await renderComponent(source, {
items,
isCentered: true,
showCounter: true,
}),
);
const centeredNext = centeredDom.querySelector('[aria-label="Next slide"]') as HTMLButtonElement;
for (let index = 0; index < items.length; index++) centeredNext.click();
expect(centeredDom.querySelector(".wrn-next__carousel-counter")?.textContent).toContain("4 / 4");
expect(centeredNext.disabled).toBe(true);
for (const thumbnails of ["horizontal", "vertical"]) {
const dom = mountHtml(await renderComponent(source, { items, thumbnails }));
const buttons = dom.querySelectorAll(
".wrn-next__carousel-thumbnails button",
) as HTMLButtonElement[];
let scrolled = false;
const rail = buttons[1]?.parentElement as HTMLElement & {
scrollTo: (options: ScrollToOptions) => void;
};
rail.scrollTo = () => {
scrolled = true;
};
(dom.querySelector('[aria-label="Next slide"]') as HTMLButtonElement).click();
expect(scrolled).toBe(true);
}
});
test("carousel autoplay wraps to the first slide", async () => {
const source = readFileSync(uiComponentPath("Carousel"), "utf8");
const html = await renderComponent(source, {
isAutoPlay: true,
autoplayInterval: 1000,
activeIndex: 2,
showCounter: true,
items: [{ title: "First" }, { title: "Second" }, { title: "Third" }],
});
const dom = mountHtml(html);
await Bun.sleep(1100);
expect(dom.querySelector(".wrn-next__carousel-counter")?.textContent).toContain("1 / 3");
dom
.querySelector(".wrn-next--carousel")
?.dispatchEvent(new (dom.window as any).Event("mouseenter", { bubbles: true }));
});
test("input number increments, decrements, applies steps, and respects limits", async () => {
const source = readFileSync(uiComponentPath("InputNumber"), "utf8");
const compiled = compileWrnFile(source, uiComponentPath("InputNumber"));
expect(compiled).toContain("export const __wrnexusStyles");
expect(compiled).toContain(".wrn-input-number__control");
const html = await renderComponent(source, {
name: "quantity",
value: 1,
min: 0,
max: 5,
step: 2,
});
const dom = mountHtml(html);
const input = () => dom.querySelector('input[name="quantity"]') as HTMLInputElement;
const decrement = () => dom.querySelector('[aria-label="Decrease value"]') as HTMLButtonElement;
const increment = () => dom.querySelector('[aria-label="Increase value"]') as HTMLButtonElement;
expect(input().value).toBe("1");
increment().click();
expect(input().value).toBe("3");
increment().click();
expect(input().value).toBe("5");
expect(increment().disabled).toBe(true);
decrement().click();
expect(input().value).toBe("3");
decrement().click();
decrement().click();
expect(input().value).toBe("0");
expect(decrement().disabled).toBe(true);
});
test("advanced select opens, filters, and selects without reactive handler errors", async () => {
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const html = await renderComponent(source, {
name: "team",
options: [
{ value: "design", label: "Design" },
{ value: "engineering", label: "Engineering" },
{ value: "growth", label: "Growth" },
],
});
const dom = mountHtml(html);
const trigger = dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement;
expect(trigger).not.toBeNull();
trigger.click();
expect(
dom.querySelector(".wrn-next--advanced-select")?.classList.contains("wrn-next--open"),
).toBe(true);
expect(dom.querySelector(".wrn-next__select-dropdown")?.getAttribute("data-show")).toBe("true");
expect(dom.querySelector('[role="listbox"]')).not.toBeNull();
const search = dom.querySelector(".wrn-next__select-search input") as HTMLInputElement;
search.value = "engine";
search.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
const options = dom.querySelectorAll(".wrn-next__select-option") as HTMLButtonElement[];
expect(options.filter((option) => option.style.display !== "none")).toHaveLength(1);
options.find((option) => option.style.display !== "none")?.click();
expect(dom.querySelector(".wrn-next__select-value > span")?.textContent).toBe("Engineering");
expect(dom.querySelector(".wrn-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
(dom.querySelector(".wrn-next__clear-select") as HTMLButtonElement).click();
expect(dom.querySelector(".wrn-next__select-value > span")?.textContent).toBe("Select an option");
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("");
});
test("advanced select clears, restores its placeholder, and keeps multiple counters accurate", async () => {
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const html = await renderComponent(source, {
name: "team",
multiple: true,
values: ["design", "engineering"],
showCounter: true,
maxSelections: 3,
placeholder: "Choose teams",
options: [
{ value: "design", label: "Design" },
{ value: "engineering", label: "Engineering" },
{ value: "growth", label: "Growth" },
],
});
const dom = mountHtml(html);
const trigger = dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement;
const counter = dom.querySelector(".wrn-next__row small");
expect(counter?.textContent).toBe("2 / 3 selected");
trigger.click();
const buttons = dom.querySelectorAll(".wrn-next__select-option") as HTMLButtonElement[];
const scope = dom.querySelector("[data-wrn-select]") as HTMLElement & {
__wrnexusScopeApi?: {
get(name: string): unknown;
call(name: string, value?: unknown): unknown;
};
};
expect(scope.__wrnexusScopeApi?.get("maxSelections")).toBe(3);
expect(scope.__wrnexusScopeApi?.call("selectedCount")).toBe(2);
expect(scope.__wrnexusScopeApi?.call("canSelect", { value: "growth", label: "Growth" })).toBe(
true,
);
expect(buttons.find((button) => button.textContent?.includes("Growth"))?.disabled).toBe(false);
buttons.find((button) => button.textContent?.includes("Engineering"))?.click();
expect(counter?.textContent).toBe("1 / 3 selected");
buttons.find((button) => button.textContent?.includes("Design"))?.click();
expect(counter?.textContent).toBe("0 / 3 selected");
expect(
dom.querySelector(".wrn-next__placeholder")?.parentElement?.getAttribute("data-show"),
).toBe("true");
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("");
});
test("advanced select closes when a pointer event occurs outside it", async () => {
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const html = await renderComponent(source, {
name: "team",
options: [{ value: "design", label: "Design" }],
});
const dom = mountHtml(html);
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
expect(dom.querySelector(".wrn-next__select-dropdown")?.getAttribute("data-show")).toBe("true");
dom.document.body.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("pointerdown", { bubbles: true }),
);
expect(dom.querySelector(".wrn-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
});
test("advanced select fetches remote options and appends infinite pages", async () => {
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const originalFetch = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input);
requests.push(url);
const parsedUrl = new URL(url);
const page = parsedUrl.searchParams.get("page");
const query = parsedUrl.searchParams.get("q");
return Response.json(
page === "1"
? {
items: query
? [{ value: "engineering", label: "Engineering" }]
: [{ value: "design", label: "Design" }],
hasMore: true,
}
: { items: [{ value: "support", label: "Support" }], hasMore: false },
);
}) as unknown as typeof fetch;
try {
const html = await renderComponent(source, {
name: "team",
options: [],
remote: true,
remoteUrl: "https://example.test/api/teams",
infinite: true,
hasMore: true,
remoteDebounce: 0,
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-select]") as HTMLElement & {
__wrnexusSelectController?: { attempts: number };
__wrnexusScopeApi?: { get(name: string): unknown };
};
expect(root.getAttribute("data-remote")).toBe("true");
expect(root.getAttribute("data-remote-url")).toBe("https://example.test/api/teams");
expect(root.getAttribute("data-remote-auto-load")).toBe("true");
expect(root.__wrnexusSelectController).toBeDefined();
expect(root.__wrnexusScopeApi).toBeDefined();
expect(root.__wrnexusScopeApi?.get("minSearchLength")).toBe(0);
expect(root.__wrnexusSelectController?.attempts).toBe(1);
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests).toHaveLength(1);
expect(
dom.querySelectorAll(".wrn-next__select-option").map((node) => node.textContent),
).toEqual(["Design"]);
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
(dom.querySelector(".wrn-next__select-option") as HTMLButtonElement).click();
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
expect(dom.querySelector(".wrn-next__select-value > span")?.textContent).toBe("Design");
const search = dom.querySelector(".wrn-next__select-search input") as HTMLInputElement;
search.value = "engine";
search.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests.at(-1)).toContain("q=engine");
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
expect(dom.querySelector(".wrn-next__select-value > span")?.textContent).toBe("Design");
(dom.querySelector("[data-wrn-select-load-more]") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
dom.querySelectorAll(".wrn-next__select-option").map((node) => node.textContent),
).toEqual(["Engineering", "Support"]);
expect(requests).toHaveLength(3);
expect(requests[2]).toContain("page=2");
} finally {
globalThis.fetch = originalFetch;
}
});
test("combobox filters editable input, selects suggestions, exposes methods, and clears", async () => {
const source = readFileSync(uiComponentPath("ComboBox"), "utf8");
const html = await renderComponent(source, {
name: "team",
placeholder: "Search teams",
options: [
{ value: "design", label: "Design", description: "Product design" },
{ value: "engineering", label: "Engineering", description: "Platform engineering" },
{ value: "growth", label: "Growth", description: "Customer growth" },
],
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-combobox]") as HTMLElement & {
wrnexusCombobox?: {
open(): void;
close(): void;
clear(): void;
setValue(value: string): void;
};
};
const input = dom.querySelector(".wrn-next__combobox-input") as HTMLInputElement;
const hidden = dom.querySelector('input[type="hidden"]') as HTMLInputElement;
expect(root.wrnexusCombobox).toBeDefined();
input.focus();
input.value = "engi";
input.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
expect(
dom
.querySelectorAll(".wrn-next__select-option")
.filter((option) => (option as HTMLElement).style.display !== "none")
.map((option) => option.textContent?.trim()),
).toEqual(["EngineeringPlatform engineering"]);
input.value = "No matching team";
input.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
expect(dom.querySelector(".wrn-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
expect(input.value).toBe("No matching team");
input.value = "engi";
input.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
input.dispatchEvent(
new (dom.window as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(input.value).toBe("Engineering");
expect(hidden.value).toBe("engineering");
expect(dom.querySelector(".wrn-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
root.wrnexusCombobox?.clear();
expect(input.value).toBe("");
expect(hidden.value).toBe("");
root.wrnexusCombobox?.setValue("growth");
expect(input.value).toBe("Growth");
expect(hidden.value).toBe("growth");
});
test("combobox auto-loads remote suggestions and retains its selected value while searching", async () => {
const source = readFileSync(uiComponentPath("ComboBox"), "utf8");
const originalFetch = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
const url = new URL(String(input));
requests.push(url.toString());
const query = url.searchParams.get("q");
return Response.json({
items: query
? [{ value: "engineering", label: "Engineering" }]
: [{ value: "design", label: "Design" }],
hasMore: false,
});
}) as typeof fetch;
try {
const html = await renderComponent(source, {
name: "remote-team",
remote: true,
remoteUrl: "https://example.test/api/teams",
remoteDebounce: 0,
});
const dom = mountHtml(html);
const input = dom.querySelector(".wrn-next__combobox-input") as HTMLInputElement;
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests).toHaveLength(1);
input.focus();
(dom.querySelector(".wrn-next__select-option") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(input.value).toBe("Design");
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
input.value = "engine";
input.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests.at(-1)).toContain("q=engine");
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("");
(dom.querySelector(".wrn-next__select-option") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(input.value).toBe("Engineering");
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe(
"engineering",
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("combobox emits search, select, change, clear, open, and close events", async () => {
const source = readFileSync(uiComponentPath("ComboBox"), "utf8");
const html = await renderComponent(source, {
name: "events-team",
options: [
{ value: "design", label: "Design" },
{ value: "engineering", label: "Engineering" },
],
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-combobox]") as HTMLElement;
const input = dom.querySelector(".wrn-next__combobox-input") as HTMLInputElement;
const received: Array<{ name: string; detail: Record<string, unknown> }> = [];
for (const name of ["search", "select", "change", "clear", "open", "close"]) {
root.addEventListener(`wrnexus:${name}`, ((event: CustomEvent) => {
received.push({ name, detail: event.detail });
}) as EventListener);
}
input.focus();
await new Promise((resolve) => setTimeout(resolve, 0));
input.value = "engi";
input.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
input.dispatchEvent(
new (dom.window as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
(dom.querySelector(".wrn-next__clear-select") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(received.map((event) => event.name)).toEqual(
expect.arrayContaining(["search", "select", "change", "clear", "open", "close"]),
);
expect(received.find((event) => event.name === "search")?.detail).toEqual(
expect.objectContaining({ component: "ComboBox", query: "engi" }),
);
expect(received.find((event) => event.name === "select")?.detail).toEqual(
expect.objectContaining({ value: "engineering" }),
);
});
test("advanced select emits selection events and remote load and error events", async () => {
const source = readFileSync(uiComponentPath("AdvancedSelect"), "utf8");
const originalFetch = globalThis.fetch;
const originalConsoleError = console.error;
let rejectRequest = false;
globalThis.fetch = (async () => {
if (rejectRequest) throw new Error("Remote unavailable");
return Response.json({
items: [{ value: "design", label: "Design" }],
hasMore: false,
});
}) as unknown as typeof fetch;
console.error = () => {};
try {
const html = await renderComponent(source, {
name: "advanced-events",
options: [],
remote: true,
remoteUrl: "https://example.test/api/teams",
remoteAutoLoad: true,
remoteDebounce: 0,
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-select]") as HTMLElement & {
__wrnexusSelectController?: { load(append: boolean, allowEmptyQuery?: boolean): void };
};
const received: Array<{ name: string; detail: Record<string, unknown> }> = [];
for (const name of ["load", "error", "select", "change", "clear"]) {
root.addEventListener(`wrnexus:${name}`, ((event: CustomEvent) => {
received.push({ name, detail: event.detail });
}) as EventListener);
}
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
(dom.querySelector(".wrn-next__select-trigger") as HTMLButtonElement).click();
(dom.querySelector(".wrn-next__select-option") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
(dom.querySelector(".wrn-next__clear-select") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
rejectRequest = true;
root.__wrnexusSelectController?.load(false, true);
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(received.map((event) => event.name)).toEqual(
expect.arrayContaining(["load", "select", "change", "clear", "error"]),
);
expect(received.find((event) => event.name === "load")?.detail).toEqual(
expect.objectContaining({
component: "AdvancedSelect",
page: 1,
hasMore: false,
}),
);
expect(received.find((event) => event.name === "error")?.detail).toEqual(
expect.objectContaining({
component: "AdvancedSelect",
message: "Remote unavailable",
}),
);
} finally {
globalThis.fetch = originalFetch;
console.error = originalConsoleError;
}
});
test("pin input accepts matching characters, advances focus, navigates backward, and completes", async () => {
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
const html = await renderComponent(source, {
name: "verificationCode",
length: 4,
pattern: "[0-9]",
value: "",
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-pin-input]") as HTMLElement & {
wrnexusPinInput?: {
focus(index?: number): void;
clear(): void;
setValue(value: string): void;
getValue(): string;
};
};
const cells = dom.querySelectorAll("[data-pin-cell]") as HTMLInputElement[];
const hidden = dom.querySelector("[data-pin-value]") as HTMLInputElement;
const events: string[] = [];
const publicEvents: string[] = [];
for (const name of ["input", "change", "complete", "clear"]) {
root.addEventListener(`wrnexus:${name}`, () => events.push(name));
root.addEventListener(name, () => publicEvents.push(name));
}
expect(cells).toHaveLength(4);
expect(root.wrnexusPinInput).toBeDefined();
cells[0].focus();
cells[0].value = "x";
cells[0].dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
expect(cells[0].value).toBe("");
expect(hidden.value).toBe("");
for (let index = 0; index < 4; index += 1) {
cells[index].value = String(index + 1);
cells[index].dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
}
expect(hidden.value).toBe("1234");
expect(root.getAttribute("data-complete")).toBe("true");
expect(events).toContain("complete");
expect(publicEvents).toContain("complete");
cells[3].value = "";
cells[3].dispatchEvent(
new (dom.window as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent("keydown", {
key: "Backspace",
bubbles: true,
}),
);
expect(cells[2].value).toBe("");
expect(hidden.value).toBe("12");
root.wrnexusPinInput?.setValue("9876");
expect(root.wrnexusPinInput?.getValue()).toBe("9876");
root.wrnexusPinInput?.clear();
expect(hidden.value).toBe("");
expect(events).toContain("clear");
});
test("pin input renders four separate cells when length is omitted", async () => {
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
const html = await renderComponent(source, {
name: "defaultPin",
});
const dom = mountHtml(html);
expect(dom.querySelectorAll("[data-pin-cell]")).toHaveLength(4);
});
test("pin input distributes filtered clipboard content and emits paste details", async () => {
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
const html = await renderComponent(source, {
name: "emailCode",
length: 6,
pattern: "[A-Z0-9]",
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-pin-input]") as HTMLElement;
const firstCell = dom.querySelector("[data-pin-cell]") as HTMLInputElement;
let pasteDetail: Record<string, unknown> | undefined;
root.addEventListener("wrnexus:paste", ((event: CustomEvent) => {
pasteDetail = event.detail;
}) as EventListener);
const pasteEvent = new (dom.window as { Event: typeof Event }).Event("paste", {
bubbles: true,
cancelable: true,
});
Object.defineProperty(pasteEvent, "clipboardData", {
value: { getData: () => "A-1b2 C3" },
});
firstCell.dispatchEvent(pasteEvent);
expect((dom.querySelector("[data-pin-value]") as HTMLInputElement).value).toBe("A1B2C3");
expect(pasteDetail).toEqual(
expect.objectContaining({
component: "PinInput",
pasted: "A1B2C3",
value: "A1B2C3",
}),
);
});
test("pin input accepts lowercase alphanumeric entry and normalizes it to the configured pattern", async () => {
const source = readFileSync(uiComponentPath("PinInput"), "utf8");
const html = await renderComponent(source, {
name: "securityCode",
length: 4,
pattern: "[A-Z0-9]",
inputMode: "text",
});
const dom = mountHtml(html);
const cells = dom.querySelectorAll("[data-pin-cell]") as HTMLInputElement[];
cells[0].value = "a";
cells[0].dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
expect(cells[0].value).toBe("A");
expect((dom.querySelector("[data-pin-value]") as HTMLInputElement).value).toBe("A");
});
test("strong password scores input, supports custom special characters, and opens its popover", async () => {
const source = readFileSync(uiComponentPath("StrongPassword"), "utf8");
const html = await renderComponent(source, {
name: "newPassword",
presentation: "popover",
specialCharactersSet: "@#",
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-strong-password]") as HTMLElement;
const input = dom.querySelector('input[name="newPassword"]') as HTMLInputElement;
const strengthEvents: Array<{ level: string; percent: number }> = [];
root.addEventListener("strength", (event) => {
strengthEvents.push(
(event as unknown as CustomEvent<{ level: string; percent: number }>).detail,
);
});
expect(input.minLength).toBe(8);
expect(root.dataset.strength).toBe("empty");
input.value = "Secure#9";
input.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
expect((dom.querySelector("[data-wrn-strong-password]") as HTMLElement).dataset.strength).toBe(
"strong",
);
expect((dom.querySelector("[data-wrn-strong-password]") as HTMLElement).dataset.score).toBe("5");
expect(strengthEvents.at(-1)).toMatchObject({ level: "strong", percent: 100 });
const updatedInput = dom.querySelector('input[name="newPassword"]') as HTMLInputElement;
updatedInput.value = "Secure!9";
updatedInput.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
);
expect((dom.querySelector("[data-wrn-strong-password]") as HTMLElement).dataset.strength).toBe(
"good",
);
expect(strengthEvents.at(-1)?.percent).toBe(80);
const currentInput = dom.querySelector('input[name="newPassword"]') as HTMLInputElement;
currentInput.dispatchEvent(
new (dom.window as { Event: typeof Event }).Event("focus", { bubbles: true }),
);
expect(
(dom.querySelector(".wrn-next__strong-password-popover") as HTMLElement).dataset.open,
).toBe("true");
});
test("strong password completed requirements use a proper checkmark indicator", () => {
const css = uiStyles();
expect(css).toContain(
'.wrn-next__strong-password-requirements li[data-met="true"] > span[aria-hidden="true"]::after',
);
expect(css).toContain("transform: rotate(45deg)");
expect(css).toContain("border-width: 0 0.1rem 0.1rem 0");
expect(css).not.toContain(
"linear-gradient(135deg, transparent 42%, var(--wrn-color-surface) 42% 52%",
);
});
test("toggle count switches pricing values and emits accessible change details", async () => {
const source = readFileSync(uiComponentPath("ToggleCount"), "utf8");
const html = await renderComponent(source, {
name: "billing",
value: "monthly",
firstValue: "monthly",
firstLabel: "Monthly",
secondValue: "annual",
secondLabel: "Annual",
animate: false,
items: [
{ label: "Startup", monthly: 19, annual: 190 },
{ label: "Team", monthly: 89, annual: 890 },
],
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-toggle-count]") as HTMLElement;
let detail: Record<string, unknown> | undefined;
root.addEventListener("toggle", (event) => {
detail = (event as unknown as CustomEvent<Record<string, unknown>>).detail;
});
expect(root.dataset.value).toBe("monthly");
expect(
dom
.querySelectorAll(".wrn-next__toggle-count-items strong > span")
.map((node) => node.textContent),
).toEqual(["19", "89"]);
(dom.querySelector('button[aria-pressed="false"]') as HTMLButtonElement).click();
const updatedRoot = dom.querySelector("[data-wrn-toggle-count]") as HTMLElement;
expect(updatedRoot.dataset.value).toBe("annual");
expect(
dom
.querySelectorAll(".wrn-next__toggle-count-items strong > span")
.map((node) => node.textContent),
).toEqual(["190", "890"]);
expect((dom.querySelector('input[name="billing"]') as HTMLInputElement).value).toBe("annual");
expect(dom.querySelector('button[aria-pressed="true"]')?.textContent).toBe("Annual");
expect(detail).toMatchObject({
component: "ToggleCount",
name: "billing",
value: "annual",
previousValue: "monthly",
});
});
test("toggle count animates every price upward and downward", async () => {
const source = readFileSync(uiComponentPath("ToggleCount"), "utf8");
const html = await renderComponent(source, {
value: "monthly",
animationDuration: 40,
animationSteps: 4,
items: [
{ label: "Startup", monthly: 2, annual: 10 },
{ label: "Team", monthly: 4, annual: 20 },
],
});
const dom = mountHtml(html);
const values = () =>
dom.querySelectorAll("[data-toggle-count-value]").map((node) => Number(node.textContent));
const button = (label: string) =>
dom
.querySelectorAll(".wrn-next__toggle-count-segmented button")
.find((node) => node.textContent === label) as HTMLButtonElement;
button("Annual").click();
expect(values()).toEqual([2, 4]);
await new Promise((resolve) => setTimeout(resolve, 15));
expect(values().every((value) => Number.isInteger(value))).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 80));
expect(values()).toEqual([10, 20]);
button("Monthly").click();
expect(values()).toEqual([10, 20]);
await new Promise((resolve) => setTimeout(resolve, 15));
expect(values().every((value) => Number.isInteger(value))).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 80));
expect(values()).toEqual([2, 4]);
});
test("toggle count switch exposes switch semantics and respects disabled state", async () => {
const source = readFileSync(uiComponentPath("ToggleCount"), "utf8");
const html = await renderComponent(source, {
variant: "switch",
value: "monthly",
disabled: true,
items: [{ label: "Startup", monthly: 19, annual: 190 }],
});
const dom = mountHtml(html);
const control = dom.querySelector('[role="switch"]') as HTMLButtonElement;
expect(control.getAttribute("aria-checked")).toBe("false");
expect(control.disabled).toBe(true);
control.click();
expect((dom.querySelector("[data-wrn-toggle-count]") as HTMLElement).dataset.value).toBe(
"monthly",
);
});
test("toggle password reveals and conceals its value with accessible declared events", async () => {
const source = readFileSync(uiComponentPath("TogglePassword"), "utf8");
const html = await renderComponent(source, {
name: "accountPassword",
value: "correct-horse",
});
const dom = mountHtml(html);
const root = dom.querySelector("[data-wrn-toggle-password]") as HTMLElement;
const input = dom.querySelector('input[name="accountPassword"]') as HTMLInputElement;
const toggle = dom.querySelector(".wrn-next__password-toggle") as HTMLButtonElement;
const showIcon = dom.querySelector(".wrn-next__password-icon--show") as HTMLElement;
const visibility: boolean[] = [];
root.addEventListener("toggle", (event) => {
visibility.push((event as unknown as CustomEvent).detail.visible);
});
expect(input.type).toBe("password");
expect(input.pattern).toBe("(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}");
expect(showIcon).not.toBeNull();
expect(toggle.getAttribute("aria-pressed")).toBe("false");
toggle.click();
expect(input.type).toBe("text");
expect(input.value).toBe("correct-horse");
expect(toggle.getAttribute("aria-pressed")).toBe("true");
expect(visibility).toEqual([true]);
toggle.click();
expect(input.type).toBe("password");
expect(visibility).toEqual([true, false]);
});
test("toggle password supports checkbox control and synchronized password fields", async () => {
const source = readFileSync(uiComponentPath("TogglePassword"), "utf8");
const synchronizedHtml = await renderComponent(source, {
name: "credentials",
fields: [
{
label: "New password",
name: "newPassword",
placeholder: "Enter new password",
autocomplete: "new-password",
},
{
label: "Current password",
name: "currentPassword",
value: "existing-secret",
autocomplete: "current-password",
pattern: ".{12,}",
},
],
});
const synchronizedDom = mountHtml(synchronizedHtml);
const synchronizedInputs = synchronizedDom.querySelectorAll(
".wrn-next__password-control > input",
) as HTMLInputElement[];
const synchronizedToggle = synchronizedDom.querySelector(
".wrn-next__password-toggle",
) as HTMLButtonElement;
expect(synchronizedInputs).toHaveLength(2);
expect(synchronizedInputs[0].pattern).toBe(
"(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}",
);
expect(synchronizedInputs[1].pattern).toBe(".{12,}");
expect(synchronizedInputs.map((input) => input.type)).toEqual(["password", "password"]);
synchronizedToggle.click();
expect(synchronizedInputs.map((input) => input.type)).toEqual(["text", "text"]);
const checkboxHtml = await renderComponent(source, {
name: "checkboxPassword",
toggleMode: "checkbox",
});
const checkboxDom = mountHtml(checkboxHtml);
const checkbox = checkboxDom.querySelector(
".wrn-next__password-checkbox input",
) as HTMLInputElement;
const checkboxPassword = checkboxDom.querySelector(
".wrn-next__password-control > input",
) as HTMLInputElement;
checkbox.checked = true;
checkbox.dispatchEvent(
new (checkboxDom.window as { Event: typeof Event }).Event("change", { bubbles: true }),
);
expect(checkboxPassword.type).toBe("text");
});
test("pagination renders windowed page numbers and emits change", async () => {
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
const html = await renderComponent(source, {
page: 5,
pageSize: 10,
total: 200,
variant: "numbered",
siblingCount: 1,
});
const dom = mountHtml(html);
const root = dom.querySelector(".wrn-pagination") as HTMLElement;
expect(root.getAttribute("role")).toBe("navigation");
const current = dom.querySelector('.wrn-pagination__page[aria-current="page"]');
expect(current!.textContent!.trim()).toBe("5");
expect(root.textContent).toContain("41");
expect(root.textContent).toContain("50");
expect(root.textContent).toContain("200");
});
test("pagination clamps an out-of-range page instead of rendering nothing", async () => {
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
const html = await renderComponent(source, { page: 99, pageSize: 10, total: 30 });
const dom = mountHtml(html);
const current = dom.querySelector(".wrn-pagination__compact");
expect(current!.textContent).toContain("3");
});
test("pagination renders links when given an href template", async () => {
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
const html = await renderComponent(source, {
page: 2,
pageSize: 10,
total: 50,
variant: "numbered",
hrefTemplate: "/news?page={page}",
});
const dom = mountHtml(html);
// Server-rendered navigation must be anchors, so the controls work before
// hydration and without JavaScript.
expect(dom.querySelector(".wrn-pagination__step button")).toBeNull();
const steps = [...dom.querySelectorAll("a.wrn-pagination__step")] as HTMLElement[];
expect(steps.length).toBe(2);
expect(steps[0]!.getAttribute("href")).toBe("/news?page=1");
expect(steps[1]!.getAttribute("href")).toBe("/news?page=3");
const pages = [...dom.querySelectorAll("a.wrn-pagination__page")] as HTMLElement[];
expect(pages.some((page) => page.getAttribute("href") === "/news?page=5")).toBe(true);
});
test("pagination href steps are disabled and clamped at both ends", async () => {
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
const first = mountHtml(
await renderComponent(source, {
page: 1,
pageSize: 10,
total: 30,
hrefTemplate: "/news?page={page}",
}),
);
const previous = first.querySelector("a.wrn-pagination__step") as HTMLElement;
expect(previous.getAttribute("data-disabled")).toBe("true");
// Clamped rather than linking to page 0.
expect(previous.getAttribute("href")).toBe("/news?page=1");
const last = mountHtml(
await renderComponent(source, {
page: 3,
pageSize: 10,
total: 30,
hrefTemplate: "/news?page={page}",
}),
);
const steps = [...last.querySelectorAll("a.wrn-pagination__step")] as HTMLElement[];
expect(steps[1]!.getAttribute("data-disabled")).toBe("true");
expect(steps[1]!.getAttribute("href")).toBe("/news?page=3");
});
test("pagination still renders buttons without an href template", async () => {
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
const dom = mountHtml(await renderComponent(source, { page: 2, pageSize: 10, total: 50 }));
expect(dom.querySelectorAll("button.wrn-pagination__step").length).toBe(2);
expect(dom.querySelector("a.wrn-pagination__step")).toBeNull();
});
test("pagination survives an empty dataset", async () => {
const source = readFileSync(uiComponentPath("Pagination"), "utf8");
const html = await renderComponent(source, { page: 1, pageSize: 10, total: 0 });
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-pagination")).not.toBeNull();
});
test("stepper marks complete, current and upcoming steps", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const html = await renderComponent(source, {
steps: [
{ label: "Account", description: "Your details" },
{ label: "Billing", description: "Payment method" },
{ label: "Confirm", description: "Review and submit" },
],
active: 1,
});
const dom = mountHtml(html);
const items = [...dom.querySelectorAll(".wrn-stepper__step")];
expect(items).toHaveLength(3);
expect(items[0]!.getAttribute("data-status")).toBe("complete");
expect(items[1]!.getAttribute("data-status")).toBe("current");
expect(items[2]!.getAttribute("data-status")).toBe("upcoming");
expect(items[1]!.getAttribute("aria-current")).toBe("step");
// One root wraps the list, the panels and the controls; the steps
// themselves are still an ordered list, which is what carries the semantics.
expect(dom.querySelector(".wrn-stepper")!.tagName.toLowerCase()).toBe("div");
expect(dom.querySelector(".wrn-stepper__list")!.tagName.toLowerCase()).toBe("ol");
});
test("stepper renders vertically and clamps an out-of-range active index", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const html = await renderComponent(source, {
steps: [{ label: "One" }, { label: "Two" }],
active: 99,
orientation: "vertical",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wrn-stepper") as HTMLElement;
expect(root.getAttribute("data-orientation")).toBe("vertical");
const items = [...dom.querySelectorAll(".wrn-stepper__step")];
expect(items[1]!.getAttribute("data-status")).toBe("current");
});
test("clickable stepper opts into roving focus", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const html = await renderComponent(source, {
steps: [{ label: "One" }, { label: "Two" }],
active: 0,
clickable: true,
});
const dom = mountHtml(html);
expect(dom.querySelector('[data-wrn-roving="horizontal"]')).not.toBeNull();
expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBe(2);
});
test("nav renders links, marks the active one, and shows icons and badges", async () => {
const source = readFileSync(uiComponentPath("Nav"), "utf8");
const html = await renderComponent(source, {
items: [
{ label: "Home", href: "/", value: "home", icon: "icon-[lucide--house]" },
{ label: "Inbox", href: "/inbox", value: "inbox", badge: "9" },
{ label: "Archive", href: "/archive", value: "archive", disabled: true },
],
active: "inbox",
});
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-nav")!.getAttribute("role")).toBe("navigation");
const current = dom.querySelector('[aria-current="page"]');
expect(current!.textContent).toContain("Inbox");
// Every item renders a badge span; the ones without a badge are empty and
// carry data-show="false", so match on content rather than first-in-document.
const badges = [...dom.querySelectorAll(".wrn-nav__badge")]
.map((node) => node.textContent!.trim())
.filter(Boolean);
expect(badges).toContain("9");
expect(dom.querySelector(".wrn-nav__icon")).not.toBeNull();
expect(dom.querySelector('[aria-disabled="true"]')).not.toBeNull();
});
test("nav renders a submenu with a disclosure arrow and opts into roving focus", async () => {
const source = readFileSync(uiComponentPath("Nav"), "utf8");
const html = await renderComponent(source, {
items: [
{
label: "Products",
value: "products",
items: [
{ label: "Overview", href: "/p", value: "p-overview" },
{
label: "More",
value: "p-more",
items: [{ label: "Deep", href: "/d", value: "deep" }],
},
],
},
],
active: "p-overview",
});
const dom = mountHtml(html);
expect(dom.querySelector('[data-wrn-roving="horizontal"]')).not.toBeNull();
expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBeGreaterThan(0);
expect(dom.querySelector(".wrn-nav__arrow")).not.toBeNull();
expect(dom.querySelector(".wrn-nav__submenu")).not.toBeNull();
expect(dom.querySelector(".wrn-nav__submenu--level3")).not.toBeNull();
// Every second-level item renders a third-level list; only the one whose
// item actually has children is populated, so search across them all.
const thirdLevel = [...dom.querySelectorAll(".wrn-nav__submenu--level3")]
.map((node) => node.textContent!.trim())
.filter(Boolean);
expect(thirdLevel.join(" ")).toContain("Deep");
});
test("nav renders its shell with no items and rejects a non-array", async () => {
const source = readFileSync(uiComponentPath("Nav"), "utf8");
const html = await renderComponent(source, { items: [], active: "" });
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-nav")).not.toBeNull();
expect(dom.querySelectorAll(".wrn-nav__link").length).toBe(0);
// A declared unknown[] prop is coerced by the framework, which rejects a
// non-array before the component runs. The itemList() guard is the second
// line of defence for a missing prop, not a way to render junk.
await expect(renderComponent(source, { items: "not-an-array", active: "" })).rejects.toThrow(
"Expected an array prop",
);
});
test("nav submenus are anchored so the viewport clamp can pull them back", async () => {
const source = readFileSync(uiComponentPath("Nav"), "utf8");
const html = await renderComponent(source, {
items: [
{
label: "Products",
value: "products",
items: [{ label: "Overview", href: "/p", value: "p" }],
},
],
active: "p",
});
const dom = mountHtml(html);
// visibility:hidden keeps layout, so the clamp can place a submenu correctly
// before it is ever shown -- which is why CSS-driven hover still composes
// with the runtime clamp.
expect(dom.querySelectorAll(".wrn-nav__submenu[data-wrn-anchored]").length).toBeGreaterThan(0);
});
test("tabs use wrn classes and declare real outputs instead of raw events", async () => {
const source = readFileSync(uiComponentPath("Tabs"), "utf8");
// The whole point of the rewrite: themeable wrn-* classes, not Tailwind.
expect(source).toContain("outputs {");
expect(source).toContain("output.change(");
expect(source).not.toContain("new CustomEvent(");
const html = await renderComponent(source, {
items: [
{ label: "Overview", value: "overview", content: "First panel" },
{ label: "Pricing", value: "pricing", content: "Second panel" },
],
active: "overview",
});
const dom = mountHtml(html);
const list = dom.querySelector('[role="tablist"]') as HTMLElement;
expect(list.getAttribute("data-wrn-roving")).toBe("horizontal");
const tabs = dom.querySelectorAll('[role="tab"]');
expect(tabs).toHaveLength(2);
expect(tabs[0]!.getAttribute("aria-selected")).toBe("true");
expect(tabs[1]!.getAttribute("aria-selected")).toBe("false");
expect(tabs[0]!.id).toBe("wrn-tab-overview");
expect(tabs[0]!.getAttribute("aria-controls")).toBe("wrn-panel-overview");
expect(dom.querySelector(".wrn-tabs")).not.toBeNull();
const panels = dom.querySelectorAll('[role="tabpanel"]');
expect(panels).toHaveLength(2);
expect(panels[0]!.id).toBe("wrn-panel-overview");
expect(panels[0]!.getAttribute("aria-labelledby")).toBe("wrn-tab-overview");
});
test("tabs in url mode declare the query parameter they sync to", async () => {
const source = readFileSync(uiComponentPath("Tabs"), "utf8");
const html = await renderComponent(source, {
items: [
{ label: "One", value: "one" },
{ label: "Two", value: "two" },
],
active: "one",
mode: "url",
param: "tab",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wrn-tabs") as HTMLElement;
expect(root.getAttribute("data-mode")).toBe("url");
expect(root.getAttribute("data-param")).toBe("tab");
/*
* In url mode the query parameter is the source of truth rather than
* component state. The client router owns popstate and swaps the whole page
* on back and forward, discarding component state, so the selection has to
* fall out of the URL for history to work at all.
*/
const source2 = readFileSync(uiComponentPath("Tabs"), "utf8");
expect(source2).toContain("URLSearchParams(window.location.search)");
expect(source2).toContain("history.pushState");
});
test("tabs vertical orientation switches the roving axis", async () => {
const source = readFileSync(uiComponentPath("Tabs"), "utf8");
const html = await renderComponent(source, {
items: [{ label: "One", value: "one" }],
active: "one",
orientation: "vertical",
});
const dom = mountHtml(html);
expect(dom.querySelector('[role="tablist"]')!.getAttribute("data-wrn-roving")).toBe("vertical");
});
test("mega menu renders column groups in an anchored panel", async () => {
const source = readFileSync(uiComponentPath("MegaMenu"), "utf8");
const html = await renderComponent(source, {
label: "Products",
columns: [
{
heading: "Platform",
items: [
{ label: "Runtime", href: "/runtime", description: "The browser half" },
{ label: "Compiler", href: "/compiler" },
],
},
{ heading: "Company", items: [{ label: "About", href: "/about" }] },
],
});
const dom = mountHtml(html);
const trigger = dom.querySelector(".wrn-mega__trigger") as HTMLElement;
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-haspopup")).toBe("true");
expect(trigger.getAttribute("aria-label")).toBe("Products");
expect(dom.querySelector(".wrn-mega__trigger-label")?.getAttribute("data-label")).toBe(
"Products",
);
const panel = dom.querySelector(".wrn-mega__panel") as HTMLElement;
expect(panel.getAttribute("data-wrn-anchored")).toBe("true");
expect(dom.querySelectorAll(".wrn-mega__column")).toHaveLength(2);
expect(dom.querySelectorAll(".wrn-mega__link").length).toBeGreaterThanOrEqual(3);
expect(dom.querySelector(".wrn-mega__heading")!.textContent).toContain("Platform");
// Single level by design: a mega panel shows breadth flat, so there is no
// nested submenu markup to find.
expect(source).not.toContain("wrn-mega__submenu");
expect(source).toContain('.wrn-mega[data-variant="default"] .wrn-mega__panel');
expect(source).toContain("box-sizing: border-box");
});
test("announcement full-width surface does not create viewport scrollbar overflow", () => {
const source = readFileSync(uiComponentPath("AnnouncementBar"), "utf8");
expect(source).not.toContain("width: 100vw");
expect(source).not.toContain("calc(50% - 50vw)");
});
test("mega menu columns take roving focus and survive an empty column list", async () => {
const source = readFileSync(uiComponentPath("MegaMenu"), "utf8");
const html = await renderComponent(source, { label: "Empty", columns: [] });
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-mega")).not.toBeNull();
expect(dom.querySelectorAll(".wrn-mega__link")).toHaveLength(0);
expect(dom.querySelector('[data-wrn-roving="both"]')).not.toBeNull();
});
test("catalog mega menu renders only the active rail category", async () => {
const source = readFileSync(uiComponentPath("MegaMenu"), "utf8");
const html = await renderComponent(source, {
label: "Products",
variant: "catalog",
activeCategory: "sales",
rail: [
{ label: "Platform", value: "platform" },
{ label: "Sales", value: "sales" },
],
columns: [
{ heading: "Platform", items: [{ label: "Home", href: "/home" }] },
{ heading: "Sales", items: [{ label: "CRM", href: "/crm" }] },
],
});
const dom = mountHtml(html);
expect(dom.querySelectorAll(".wrn-mega__rail-link")).toHaveLength(2);
expect(dom.querySelectorAll(".wrn-mega__column")).toHaveLength(1);
expect(dom.querySelector(".wrn-mega__heading")?.textContent).toContain("Sales");
expect(dom.querySelector('[aria-pressed="true"]')?.textContent).toContain("Sales");
expect(source).toContain("overflow-y: auto");
expect(source).not.toContain("@mouseenter='chooseCategory(item, index)'");
expect(dom.querySelector(".wrn-mega__column-header")?.textContent).toContain("Sales");
expect(dom.querySelectorAll(".wrn-mega__link-icon")).toHaveLength(1);
expect(dom.querySelector(".wrn-mega__link-icon--fallback svg")).not.toBeNull();
expect(dom.querySelectorAll(".wrn-mega__link-description")).toHaveLength(0);
expect(source).toContain("border-bottom: 1px solid var(--wrn-color-border)");
expect(source).toContain(".wrn-mega__rail-icon > span");
expect(source).toContain("color: currentColor !important");
});
test("mega menu supports rails, icons, badges, media, featured content and footer actions", async () => {
const source = readFileSync(uiComponentPath("MegaMenu"), "utf8");
const html = await renderComponent(source, {
label: "Explore",
variant: "icon-grid",
panelWidth: "2xl",
columnCount: 3,
rail: [{ label: "Enterprise", description: "For larger teams", icon: "icon-enterprise" }],
columns: [
{
heading: "Products",
description: "Choose a workspace",
image: "/products.png",
items: [{ label: "CRM", description: "Customer records", icon: "icon-crm", badge: "New" }],
actionLabel: "All products",
},
],
featured: {
eyebrow: "What's new",
title: "Release notes",
image: "/release.png",
actionLabel: "See more",
},
footer: "One connected platform",
actionLabel: "View everything",
});
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-mega")?.getAttribute("data-variant")).toBe("icon-grid");
expect(dom.querySelector(".wrn-mega__rail-link")?.textContent).toContain("Enterprise");
expect(dom.querySelector(".wrn-mega__badge")?.textContent).toContain("New");
expect(dom.querySelector(".wrn-mega__column-image")).not.toBeNull();
expect(dom.querySelector(".wrn-mega__featured")?.textContent).toContain("Release notes");
expect(dom.querySelector(".wrn-mega__footer")?.textContent).toContain("View everything");
});
test("sidebar renders single items, labelled groups and nested levels", async () => {
const source = readFileSync(uiComponentPath("Sidebar"), "utf8");
const html = await renderComponent(source, {
label: "Workspace",
items: [
{ label: "Dashboard", href: "/", value: "dash", icon: "icon-[lucide--gauge]" },
{
heading: "Projects",
items: [
{ label: "Active", href: "/active", value: "active" },
{
label: "Archive",
value: "archive",
items: [{ label: "2025", href: "/a/2025", value: "a2025" }],
},
],
},
],
active: "active",
});
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-sidebar")).not.toBeNull();
// Every entry renders a heading node; only group entries fill it in, so
// match on content rather than first-in-document.
const headings = [...dom.querySelectorAll(".wrn-sidebar__heading")]
.map((node) => node.textContent!.trim())
.filter(Boolean);
expect(headings).toContain("Projects");
expect(dom.querySelector('[aria-current="page"]')!.textContent).toContain("Active");
expect(dom.querySelector(".wrn-sidebar__icon")).not.toBeNull();
// Third level exists and is reachable.
const deep = [...dom.querySelectorAll(".wrn-sidebar__sublist--level3")]
.map((n) => n.textContent!.trim())
.filter(Boolean);
expect(deep.join(" ")).toContain("2025");
expect(dom.querySelector('[data-wrn-roving="vertical"]')).not.toBeNull();
});
test("sidebar composes Drawer for its off-canvas presentation", async () => {
const source = readFileSync(uiComponentPath("Sidebar"), "utf8");
// Reusing Drawer means the focus trap and scroll lock come for free rather
// than being reimplemented here.
expect(source).toContain('import Drawer from "./Drawer.wrn"');
expect(source).toContain("<Drawer");
});
test("scrollspy renders section links and marks the runtime observation target", async () => {
const source = readFileSync(uiComponentPath("Scrollspy"), "utf8");
const html = await renderComponent(source, {
label: "On this page",
items: [
{ label: "Overview", href: "#overview" },
{ label: "Install", href: "#install" },
{ label: "Usage", href: "#usage" },
],
active: "#install",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wrn-scrollspy") as HTMLElement;
expect(root.getAttribute("role")).toBe("navigation");
// The runtime owns which link is current; it needs a marker to find the nav.
expect(root.getAttribute("data-wrn-scrollspy")).toBe("true");
const links = [...dom.querySelectorAll(".wrn-scrollspy__link")];
expect(links).toHaveLength(3);
expect(links[1]!.getAttribute("aria-current")).toBe("location");
expect(links[0]!.getAttribute("aria-current")).toBe("false");
expect(links[1]!.getAttribute("href")).toBe("#install");
});
test("scrollspy survives an empty item list", async () => {
const source = readFileSync(uiComponentPath("Scrollspy"), "utf8");
const html = await renderComponent(source, { items: [] });
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-scrollspy")).not.toBeNull();
expect(dom.querySelectorAll(".wrn-scrollspy__link")).toHaveLength(0);
});
test("navbar uses a valid aria-current and takes roving focus on its menu", async () => {
const source = readFileSync(uiComponentPath("Navbar"), "utf8");
/*
* aria-current="" is not a valid value -- the attribute has to be absent or
* carry a token. Emitting an empty string made every link look like it
* declared a state it did not have.
*/
expect(source).not.toContain("? 'page' : ''");
expect(source).toContain("data-wrn-roving");
const html = await renderComponent(source, {
label: "Main",
items: [
{ label: "Home", href: "/", value: "home" },
{ label: "Docs", href: "/docs", value: "docs" },
],
active: "docs",
});
const dom = mountHtml(html);
const menus = dom.querySelector(".wrn-navbar__menus") as HTMLElement;
expect(menus.getAttribute("data-wrn-roving")).toBe("horizontal");
/*
* Asserted against the rendered markup rather than the parsed DOM: the test
* DOM drops aria-current from these anchors, while the served HTML carries
* it correctly.
*/
expect(html).toContain('href="/docs"');
expect(html).toMatch(/aria-current="page"/);
expect(html).not.toMatch(/aria-current=""/);
});
test("breadcrumb marks the trail end without emitting an empty aria-current", async () => {
const source = readFileSync(uiComponentPath("Breadcrumb"), "utf8");
const html = await renderComponent(source, {
items: [
{ label: "Docs", href: "/docs" },
{ label: "Components", href: "/docs/components" },
{ label: "Breadcrumb" },
],
});
// aria-current="" is not a valid value; the attribute takes a token.
expect(html).not.toMatch(/aria-current=""/);
expect(html).toMatch(/aria-current="page"/);
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-breadcrumb")!.getAttribute("aria-label")).toBeTruthy();
expect(dom.querySelectorAll(".wrn-breadcrumb__item").length).toBeGreaterThanOrEqual(3);
});
test("breadcrumb renders a standalone active page with its icon", async () => {
const source = readFileSync(uiComponentPath("Breadcrumb"), "utf8");
const html = await renderComponent(source, {
showHome: true,
items: [{ label: "Products", href: "/products", icon: "icon-[lucide--blocks]" }],
active: "CRM",
activeIcon: "icon-[lucide--contact-round]",
});
expect(html).toContain("CRM");
expect(html).toContain("icon-[lucide--contact-round]");
expect(html).toMatch(/aria-current="page"/);
const dom = mountHtml(html);
expect(dom.querySelectorAll(".wrn-breadcrumb__item").length).toBe(3);
});
test("breadcrumb default separators remain visible without generated icon CSS", async () => {
const source = readFileSync(uiComponentPath("Breadcrumb"), "utf8");
const html = await renderComponent(source, {
showHome: true,
items: [{ label: "Legal" }, { label: "Terms", current: true }],
});
expect(html).toContain("");
expect(html).not.toContain("icon-[lucide--chevron-right]");
});
test("stats bar desktop gutters align its surface with container content", () => {
const source = readFileSync(uiComponentPath("StatsBar"), "utf8");
expect(source).toContain("--stats-max-width: 80rem");
expect(source).toContain("--stats-gutter-width: 4rem");
expect(source).toContain("calc(var(--stats-max-width) - var(--stats-gutter-width))");
});
test("mega menu bridges the gap between its trigger and panel", async () => {
const source = readFileSync(uiComponentPath("MegaMenu"), "utf8");
/*
* The panel is offset below the trigger, and that offset is dead space
* belonging to neither element: travelling to the panel fired mouseleave on
* the root and closed the menu before the pointer arrived. A descendant
* covering the gap keeps the pointer inside the component.
*/
expect(source).toContain(".wrn-mega__panel::before");
expect(source).toMatch(/\.wrn-mega__panel::before \{[^}]*bottom: 100%/s);
});
test("mega menu keeps hover activation and click activation from cancelling each other", () => {
const source = readFileSync(uiComponentPath("MegaMenu"), "utf8");
expect(source).toContain("openOnHover: boolean = true");
expect(source).toContain("@click='activatePanel(event)'");
expect(source).toContain("@mouseenter='hoverPanel(event)'");
expect(source).toContain("showPanel(sourceEvent)");
expect(source).not.toContain("function scheduleOpen");
expect(source).toContain('.wrn-mega__panel[data-show="false"] { display: none; }');
expect(source).toContain('style=\'{visible ? "" : "display:none"}\'');
expect(source).not.toContain("@focus='showPanel(event)'");
expect(source).toContain("max-height: calc(100dvh - 5.5rem)");
});
test("mega menu gives the pointer time to cross from its trigger into a floating panel", () => {
const source = readFileSync(uiComponentPath("MegaMenu"), "utf8");
expect(source).toContain("state closeTimer = null");
expect(source).toContain("@mouseleave='scheduleHide()'");
expect(source).toContain("@mouseenter='cancelScheduledHide()'");
expect(source).toContain("}, 220)");
expect(source).not.toContain("@mouseleave='hidePanel(\"pointer-leave\")'");
});
test("navbar pins full-width mega panels to desktop viewport gutters", () => {
const source = readFileSync(uiComponentPath("Navbar"), "utf8");
expect(source).toMatch(
/\.wrn-navbar__mega\[data-full-width="true"\] \.wrn-mega__panel \{[^}]*position: fixed/s,
);
expect(source).toMatch(/right: 1rem;\s+left: 1rem;/s);
expect(source).toMatch(
/data-collapse-at="1100px"[^}]+\.wrn-navbar__mega\[data-full-width="true"\][^}]+position: static/s,
);
});
test("stepper shows only the active step content and offers back, next and skip", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const html = await renderComponent(source, {
steps: [
{ label: "Account", content: "ACCOUNT BODY" },
{ label: "Billing", content: "BILLING BODY" },
{ label: "Confirm", content: "CONFIRM BODY" },
],
active: 1,
showPanel: true,
controls: true,
allowSkip: true,
});
const dom = mountHtml(html);
const panels = [...dom.querySelectorAll(".wrn-stepper__panel")];
expect(panels).toHaveLength(3);
/*
* Asserted through aria-hidden rather than data-show. data-show is emitted
* verbatim for the client runtime to evaluate, so its server-rendered value
* is not a reliable statement about which step is showing; aria-hidden is
* interpolated at render and says exactly that.
*/
expect(panels.map((p) => p.getAttribute("aria-hidden"))).toEqual(["true", "false", "true"]);
expect(dom.querySelector(".wrn-stepper__back")).not.toBeNull();
expect(dom.querySelector(".wrn-stepper__next")).not.toBeNull();
expect(dom.querySelector(".wrn-stepper__skip")).not.toBeNull();
});
test("stepper back is disabled on the first step and next becomes finish on the last", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const steps = [{ label: "One" }, { label: "Two" }];
const first = mountHtml(await renderComponent(source, { steps, active: 0, controls: true }));
expect(first.querySelector(".wrn-stepper__back")!.getAttribute("disabled")).not.toBeNull();
expect(first.querySelector(".wrn-stepper__next")!.textContent).toContain("Next");
const last = mountHtml(await renderComponent(source, { steps, active: 1, controls: true }));
expect(last.querySelector(".wrn-stepper__next")!.textContent).toContain("Finish");
});
test("stepper next can be gated so a form can hold it until the step validates", async () => {
const source = readFileSync(uiComponentPath("Stepper"), "utf8");
const html = await renderComponent(source, {
steps: [{ label: "One" }, { label: "Two" }],
active: 0,
controls: true,
nextDisabled: true,
});
const dom = mountHtml(html);
expect(dom.querySelector(".wrn-stepper__next")!.getAttribute("disabled")).not.toBeNull();
});
test("layout splitter renders two panes and an operable separator", async () => {
const source = readFileSync(uiComponentPath("LayoutSplitter"), "utf8");
/*
* It used to declare resizeStart, resize and resizeEnd with no pointer
* handling at all, so a caller connected up @resize and received nothing for
* ever. The behaviour now lives in the runtime behind these markers.
*/
expect(source).toContain("data-wrn-splitter");
expect(source).toContain("data-wrn-splitter-handle");
/*
* The output is sizeChange, not resize. An output named after a native DOM
* event is emitted by the component but never reaches the parent binding.
*/
expect(source).toContain("sizeChange(payload:");
expect(source).not.toMatch(/^\s*resize\(payload:/m);
const html = await renderComponent(source, {
orientation: "horizontal",
size: 40,
minSize: 20,
label: "Resize panels",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wrn-splitter") as HTMLElement;
expect(root.getAttribute("data-wrn-splitter")).toBe("horizontal");
expect(root.getAttribute("style")).toContain("--wrn-split");
const handle = dom.querySelector("[data-wrn-splitter-handle]") as HTMLElement;
expect(handle.getAttribute("role")).toBe("separator");
expect(handle.getAttribute("tabindex")).toBe("0");
expect(handle.getAttribute("aria-valuenow")).toBe("40");
expect(handle.getAttribute("aria-valuemin")).toBe("20");
expect(handle.getAttribute("aria-valuemax")).toBe("80");
expect(handle.getAttribute("aria-orientation")).toBe("vertical");
expect(dom.querySelectorAll(".wrn-splitter__pane")).toHaveLength(2);
});
test("layout splitter clamps an out-of-range size and flips orientation", async () => {
const source = readFileSync(uiComponentPath("LayoutSplitter"), "utf8");
const dom = mountHtml(
await renderComponent(source, { orientation: "vertical", size: 95, minSize: 25 }),
);
const handle = dom.querySelector("[data-wrn-splitter-handle]") as HTMLElement;
// 95 is past the 75 bound implied by minSize, so it clamps rather than
// leaving one pane unrecoverable.
expect(handle.getAttribute("aria-valuenow")).toBe("75");
// A vertical splitter stacks, so its separator is a horizontal bar.
expect(handle.getAttribute("aria-orientation")).toBe("horizontal");
});
test("custom scrollbar styles the scrollbar and drops its fake output", async () => {
const source = readFileSync(uiComponentPath("CustomScrollbar"), "utf8");
/*
* It declared a scroll output it never emitted, and its props were columns,
* gap and maxWidth copied from a grid scaffold. A caller can listen for a
* plain scroll event on the element, so the output is gone rather than left
* unimplemented.
*/
expect(source).not.toContain("outputs {");
// Matched as a declaration: the word still appears in the comment recording
// why those props were wrong.
expect(source).not.toMatch(/^\s*columns:/m);
expect(source).not.toMatch(/^\s*gap:/m);
expect(source).toContain("scrollbar-color");
expect(source).toContain("::-webkit-scrollbar");
const html = await renderComponent(source, {
axis: "vertical",
thickness: 10,
maxHeight: "18rem",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wrn-scrollbar") as HTMLElement;
expect(root.getAttribute("data-axis")).toBe("vertical");
const style = root.getAttribute("style") || "";
expect(style).toContain("--scrollbar-thickness");
expect(style).toContain("18rem");
});
test("page header collapses compiled empty slot markers", () => {
const source = readFileSync(uiComponentPath("PageHeader"), "utf8");
expect(source).toContain(".wrn-page-header__meta:not(:has(> :not(template)))");
expect(source).toContain(".wrn-page-header__body:not(:has(> :not(template)))");
expect(source).toContain(".wrn-page-header__actions--slot-only:not(:has(> :not(template)))");
});
test("layout components ship their own styles instead of Tailwind utilities", () => {
/*
* These were built from utility classes and class: conditionals. That works
* only where Tailwind is present, and a variant cost a dozen lines of
* conditionals. They now carry wrn-* classes, a local style block, and
* data attributes the style block selects on.
*/
const migrated = [
"Container",
"Columns",
"Grid",
"Divider",
"Image",
"Link",
"Typography",
"Kbd",
"LayoutSplitter",
"CustomScrollbar",
"Section",
"SectionHeader",
"PublicPageShell",
"PageHeader",
];
const utility =
/class:(grid-cols-|sm:|lg:|md:|max-w-|gap-[0-9]|px-[0-9]|py-[0-9]|border-l|bg-\[|h-0|text-xs|items-)/;
for (const name of migrated) {
const source = readFileSync(uiComponentPath(name), "utf8");
expect({ name, hasStyleBlock: /^\s{2}style \{/m.test(source) }).toEqual({
name,
hasStyleBlock: true,
});
expect({ name, usesUtilityConditionals: utility.test(source) }).toEqual({
name,
usesUtilityConditionals: false,
});
expect({ name, hasWrnClass: /class='wrn-[a-z-]+/.test(source) }).toEqual({
name,
hasWrnClass: true,
});
}
});
test("remaining utility-styled components use local BEM styles", () => {
const migrated = ["List", "InputNumber", "Marquee", "TextLink", "Map", "SearchBox", "Timeline"];
const utilityMarkup =
/class(?::[^=\s]+)?=['"][^'"]*\b(?:flex|grid|w-full|items-center|justify-between|gap-[0-9]|p[xytrbl]?-[0-9]|m[xytrbl]?-[0-9]|text-(?:xs|sm|lg|xl)|rounded-(?:md|lg|xl|2xl|full)|border-[lrtbxy]?|bg-\[|shadow-(?:sm|lg)|size-[0-9])\b/;
for (const name of migrated) {
const source = readFileSync(uiComponentPath(name), "utf8");
const markup = source.split(/^\s{2,4}style \{/m)[0] ?? source;
expect({ name, hasStyleBlock: /^\s{2,4}style \{/m.test(source) }).toEqual({
name,
hasStyleBlock: true,
});
expect({ name, utilityMarkup: utilityMarkup.test(markup) }).toEqual({
name,
utilityMarkup: false,
});
}
});
test("every wrn color token a component references is defined by the theme", () => {
/*
* Ten tokens were referenced by components and defined by nothing:
* --wrn-color-focus, --wrn-color-surface-soft, --wrn-color-on-danger and
* the input-* family. An undefined custom property does not warn, it simply
* resolves to nothing, so focus rings drew with no colour and soft surfaces
* rendered transparent.
*
* Checked against the rendered theme CSS rather than the source: most of
* these tokens are derived per palette, so they never appear as literals.
*/
const css = renderThemeCss(resolveThemeConfig());
const defined = new Set(
[...css.matchAll(/(--wrn-color-[a-z0-9-]+)\s*:/g)].map((match) => match[1]!),
);
const missing = new Map<string, string[]>();
for (const name of uiComponentNames()) {
const source = readFileSync(uiComponentPath(name), "utf8");
for (const match of source.matchAll(/var\((--wrn-color-[a-z0-9-]+)/g)) {
const token = match[1]!;
if (defined.has(token)) continue;
if (!missing.has(token)) missing.set(token, []);
const users = missing.get(token)!;
if (users.length < 4 && !users.includes(name)) users.push(name);
}
}
expect([...missing].map(([token, users]) => `${token} <- ${users.join(", ")}`)).toEqual([]);
});
test("no component gains an output that nothing ever emits", () => {
/*
* An output only reaches a parent @binding when the component calls
* output.<name>(). The runtime keeps parent handlers in a registry that only
* invokeComponentOutput reads, so a component that instead dispatches its own
* CustomEvent -- even a bubbling one, on its own root -- is emitting into
* nothing: the parent binding is never invoked and no error is raised.
* Eighteen components did exactly that and were converted; this pins what is
* left, which are components with no emitter of any kind.
*
* Native event names are excluded, and that exclusion is real rather than
* assumed: invokeComponentOutput falls back to dispatchComponentEvent when no
* handler is registered, and a parent @click on a component tag is also bound
* as an ordinary DOM listener, so a natively-named output does arrive.
*
* That reasoning held only for events that bubble. focus and blur do not, so
* the bubble-phase fallback never heard a descendant take focus and every
* focus/blur output here was undeliverable -- silently, since a missing
* output raises nothing. The runtime now binds those two in the capture
* phase, which is what makes their exclusion honest rather than convenient.
* See "a @focus binding on a component tag fires for a focusable element
* inside it" in packages/csr/test/reactive.test.ts.
*/
const native = new Set([
"click",
"focus",
"blur",
"input",
"change",
"submit",
"copy",
"paste",
"load",
"error",
"scroll",
"toggle",
"drop",
"dragstart",
"dragend",
"dragenter",
"dragleave",
"dragover",
"keydown",
"keyup",
"select",
]);
const runtime = readFileSync(
join(uiComponentsDir(), "..", "..", "csr", "src", "reactive-runtime.ts"),
"utf8",
);
const offenders: string[] = [];
for (const name of uiComponentNames()) {
const source = readFileSync(uiComponentPath(name), "utf8");
const block = /^ {2}outputs \{([\s\S]*?)^ {2}\}/m.exec(source);
if (!block) continue;
for (const match of block[1]!.matchAll(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*\(/gm)) {
const output = match[1]!;
if (source.slice(block.index).includes(`output.${output}(`)) continue;
if (source.includes("output[")) continue;
if (native.has(output.toLowerCase())) continue;
// Emitted by a runtime controller written for this component.
if (runtime.includes(`"${output}"`) && new RegExp(name, "i").test(runtime)) continue;
offenders.push(`${name}.${output}`);
}
}
/*
* A ceiling, not a target. It only ever moves down: rebuilding one of these
* components should tighten it.
*/
expect(offenders.length).toBe(0);
expect(offenders).not.toContain("LayoutSplitter.sizeChange");
expect(offenders).not.toContain("CustomScrollbar.scroll");
});
test("list selects items without hrefs as well as navigation items", async () => {
const source = readFileSync(uiComponentPath("List"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
title: "Actions",
items: [{ label: "Run report" }],
}),
);
const root = dom.querySelector('[data-ui-component="List"]') as HTMLElement;
const events: Array<{ item: { label: string }; index: number }> = [];
root.addEventListener("select", (event) => {
events.push((event as CustomEvent).detail);
});
(root.querySelector("li") as HTMLElement).click();
expect(events).toEqual([{ item: { label: "Run report" }, index: 0 }]);
});
test("chart renders accessible SVG bars and emits 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 root = dom.querySelector('[data-ui-component="Chart"]') as HTMLElement;
const events: string[] = [];
for (const name of ["dataPointClick", "select", "legendToggle"]) {
root.addEventListener(name, () => events.push(name));
}
expect(root.querySelectorAll("svg rect")).toHaveLength(2);
(root.querySelector("svg rect") as SVGElement).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"]);
});
test("tree view expands branches, selects nodes, and emits public outputs", async () => {
const source = readFileSync(uiComponentPath("TreeView"), "utf8");
const dom = mountHtml(
await renderComponent(source, {
items: [{ value: "docs", label: "Docs", children: [{ value: "guide", label: "Guide" }] }],
}),
);
const root = dom.querySelector('[data-ui-component="TreeView"]') as HTMLElement;
const events: string[] = [];
for (const name of ["toggle", "expand", "select"])
root.addEventListener(name, () => events.push(name));
(root.querySelector(".wrn-tree-view__toggle") as HTMLButtonElement).click();
expect(root.querySelector(".wrn-tree-view__group")?.getAttribute("data-show")).toBe("true");
(root.querySelector('[aria-level="2"] .wrn-tree-view__node') as HTMLButtonElement).click();
expect(events).toEqual(["toggle", "expand", "select"]);
});
test("confetti completes deferred bursts and clipboard reports successful copies", async () => {
const confetti = mountHtml(
await renderComponent(readFileSync(uiComponentPath("Confetti"), "utf8"), {
duration: 5,
count: 4,
}),
);
const confettiRoot = confetti.querySelector('[data-ui-component="Confetti"]') as HTMLElement;
const confettiEvents: string[] = [];
confettiRoot.addEventListener("start", () => confettiEvents.push("start"));
confettiRoot.addEventListener("complete", () => confettiEvents.push("complete"));
(confettiRoot.querySelector("button") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 15));
expect(confettiEvents).toEqual(["start", "complete"]);
const clipboard = mountHtml(
await renderComponent(readFileSync(uiComponentPath("Clipboard"), "utf8"), {
value: "npm add wrnexus",
}),
);
Object.defineProperty((clipboard.window as { navigator: object }).navigator, "clipboard", {
configurable: true,
value: { writeText: () => Promise.resolve() },
});
const clipboardRoot = clipboard.querySelector('[data-ui-component="Clipboard"]') as HTMLElement;
const clipboardEvents: string[] = [];
for (const name of ["copy", "success"])
clipboardRoot.addEventListener(name, () => clipboardEvents.push(name));
(clipboardRoot.querySelector("button") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(clipboardEvents).toEqual(["copy", "success"]);
expect(clipboardRoot.dataset.status).toBe("success");
});
test("a declared output is never emitted as a hand-built CustomEvent", () => {
/*
* The failure this prevents is silent in both directions: the component
* looks like it emits, the caller looks like it listens, and the event
* bubbles right past the binding because the runtime resolves parent
* handlers from a registry rather than from the DOM. Verified in a browser
* before this test was written -- an AnnouncementBar dispatching its own
* bubbling "dismiss" never reached a page-level @dismiss, and the same
* component reached it immediately once it called output.dismiss().
*
* Dispatching on window is a different thing and stays allowed: that is how
* Toaster, Modal and DataTable signal across component boundaries, where
* there is no parent binding to reach.
*/
const offenders: string[] = [];
for (const name of uiComponentNames()) {
const source = readFileSync(uiComponentPath(name), "utf8");
const block = /^ {2}outputs \{([\s\S]*?)^ {2}\}/m.exec(source);
if (!block) continue;
const declared = new Set(
[...block[1]!.matchAll(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*\(/gm)].map((m) => m[1]!),
);
for (const match of source.matchAll(
/(\w+(?:\.\w+)*)\.dispatchEvent\(|initCustomEvent\(\s*"([A-Za-z][A-Za-z0-9_]*)"/g,
)) {
const target = match[1];
if (target && /^window\b/.test(target)) continue;
// Which event name is being built here?
const around = source.slice(Math.max(0, match.index - 400), match.index + 200);
for (const declaredName of declared) {
const quoted = `"${declaredName}"`;
if (
around.includes(`CustomEvent(${quoted}`) ||
around.includes(`initCustomEvent(${quoted}`)
) {
offenders.push(`${name}.${declaredName}`);
}
}
}
}
expect([...new Set(offenders)]).toEqual([]);
});