Files
WRNexusJS/packages/ui/test/ui.test.ts
T
Clintchiz 5bc391e74f
Quality / quality (windows-latest) (push) Waiting to run
Quality / quality (ubuntu-latest) (push) Failing after 22s
fix(ui): inherit catalog icon contrast
2026-08-24 23:23:43 +05:30

3637 lines
146 KiB
TypeScript

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("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(dom.querySelector(".wrn-tabs")).not.toBeNull();
expect(dom.querySelectorAll('[role="tabpanel"]')).toHaveLength(2);
});
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");
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(0);
expect(dom.querySelectorAll(".wrn-mega__link-description")).toHaveLength(0);
expect(source).toContain("border-bottom: 1px solid var(--wrn-color-border)");
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("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("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([]);
});