Files
WRNexusJS/packages/ui/test/ui.test.ts
T
ClintchizandClaude Opus 5 5ce2771718
Quality / quality (ubuntu-latest) (push) Failing after 12m29s
Quality / quality (windows-latest) (push) Canceled after 0s
fix(ui): release the scroll lock on closed drawers, add stepper wizard controls, slide tabs
The scroll lock was mine, and it broke every page carrying a Drawer or Modal.
Making dialog visibility testable, I replaced a size check with a data-show
check -- but a Drawer animates open, so its panel cannot be hidden with
data-show at all: display:none is not transitionable. Every closed Drawer
therefore looked open, took the body scroll lock and never released it, and
the page could not be scrolled. Both components publish data-open, which is
the signal that actually means open, and that is what is read now.

Stepper gains the wizard surface: showPanel renders each step body and shows
only the active one, the same contract Tabs uses, and controls adds Back,
Skip and Next, which becomes Finish on the last step. nextDisabled lets a form
hold the step; the component never validates anything itself, since the page
owns the form.

Stepper also gets a single root. The panels and controls were siblings of the
list, so the component had several roots and anything scoped to
data-ui-component missed most of it.

Tabs panels now slide in the direction of travel rather than fading upward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:41:18 +05:30

2947 lines
117 KiB
TypeScript

import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { compileWireFile, parse } from "../../compiler/src/index.ts";
import { mountHtml, renderComponent } from "../../test/src/index.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";
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("--wire-");
});
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(108);
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"],
FileUpload: ["select", "upload", "progress", "success", "error", "cancel", "remove"],
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(0);
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",
"FileUpload",
"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("child.children");
expect(source).toContain("wire-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("wire-auth-form__submit");
expect(html).toContain('fullWidth="true"');
});
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("wire-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",
columns: 2,
children: [
{
label: "Resources",
children: [{ label: "Guidance", href: "/guidance" }],
},
],
},
],
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("wire-navbar__dropdown--mega");
expect(html).toContain("data-wrn-navbar");
expect(html).toContain('name="wire-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(".wire-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(".wire-navbar__dropdown") as HTMLDetailsElement;
expect(navbar?.classList.contains("wire-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(".wire-navbar__dropdown") as HTMLDetailsElement;
const panel = dom.querySelector(".wire-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 html = await renderComponent(
readFileSync(uiComponentPath("PreferenceSwitcher"), "utf8"),
{},
);
const dom = mountHtml(html);
const menus = Array.from(dom.querySelectorAll(".wire-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("wire-sidebar__launcher");
expect(html).toContain("wire-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("wire-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("wire-footer--columns-4");
expect(html).toContain("wire-footer__column");
expect(html).toContain("wire-footer__column-links");
expect(html).toContain("wire-footer__heading");
expect(html).toContain("Support");
expect(html).toContain('href="/contact"');
expect(html).toContain("Public service platform");
});
test("component-system CSS includes responsive, theme-token, focus, and reduced-motion rules", () => {
const css = uiCss();
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(--wire-color-surface)");
expect(css).toContain("min-height: 44px");
expect(css).toContain("--wire-motion-base");
expect(css).toContain("--wire-ease-emphasized");
expect(css).toContain("@keyframes wire-component-enter");
expect(css).toContain("@keyframes wire-dialog-enter");
expect(css).toContain("@media (hover: hover) and (pointer: fine)");
expect(css).toContain("--color-violet-600: var(--wire-color-primary)");
expect(css).toContain("--color-blue-600: var(--wire-color-info)");
expect(css).toContain("--color-red-600: var(--wire-color-danger)");
expect(css).toMatch(/:root\s*\{[^}]*--color-violet-600: var\(--wire-color-primary\)/s);
expect(css).toContain(".wire-bg-primary");
expect(css).toContain(".wire-text-muted");
expect(css).toContain(".wire-visually-hidden");
expect(css).toContain(".wire-next--field");
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(css).not.toContain(".wire-navbar");
expect(navbarSource).toMatch(
/\.wire-navbar__brand-copy strong\s*\{[^}]*font-size: 0\.9rem;[^}]*font-weight: 600;/s,
);
expect(navbarSource).toMatch(
/\.wire-navbar__menu-link,[\s\S]*?font-size: 0\.8125rem;[\s\S]*?font-weight: 500;/,
);
expect(css).toMatch(
/\.wire-footer__heading\s*\{[^}]*font-size: 0\.8125rem;[^}]*font-weight: 600;/s,
);
expect(css).toMatch(/\.wire-footer__link\s*\{[^}]*font-size: 0\.8125rem;[^}]*font-weight: 400;/s);
});
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(() => compileWireFile(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="wire-btn__tooltip"');
expect(button).toContain("{ariaLabel || label}");
expect(uiCss()).toContain(".wire-btn:hover > .wire-btn__tooltip");
expect(uiCss()).toContain(".wire-btn:focus-visible > .wire-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(".wire-next--button-group") as HTMLElement;
const buttons = [...dom.querySelectorAll(".wire-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(".wire-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 = uiCss();
expect(css).toContain('.wire-next--button-group[data-orientation="vertical"]');
expect(css).toContain('.wire-next--button-group[data-responsive="true"]');
expect(css).toContain('.wire-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 = compileWireFile(readFileSync(path, "utf8"), path);
expect(output).toContain("${__wireSpreadAttrs(__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(".wire-next__alert-body li").map((node) => node.textContent)).toEqual(
["Email is required", "Phone number is invalid"],
);
(dom.querySelector(".wire-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(".wire-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 = uiCss();
expect(css).toContain('.wire-next--alert[data-color="secondary"]');
expect(css).toContain("--wire-alert-color: #737373");
expect(css).toContain(
".wire-next--alert-compact:not(:has(.wire-next__alert-icon)):not(:has(.wire-next__alert-dismiss))",
);
expect(css).toContain(".wire-next--alert-compact .wire-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 = uiCss();
expect(root.dataset.radius).toBe("xl");
expect(root.dataset.shadow).toBe("lg");
expect(css).toContain('.wire-next--alert[data-radius="xl"]');
expect(css).toContain('.wire-next--alert[data-shadow="lg"]');
expect(css).toContain("border-radius: calc(var(--wire-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(".wire-next__avatar-initials")?.textContent).toBe("MW");
expect(initialsDom.querySelector(".wire-next__avatar-badge")?.getAttribute("aria-label")).toBe(
"Messaging account",
);
expect(initialsDom.querySelector(".wire-next__avatar-copy")?.textContent).toContain(
"mark@example.com",
);
const placeholderDom = mountHtml(await renderComponent(source, { size: "sm" }));
expect(placeholderDom.querySelector(".wire-next__avatar-placeholder")).not.toBeNull();
expect(source).not.toContain("items =");
expect(uiCss()).toContain('.wire-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(
".wire-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(".wire-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(".wire-next__avatar-group-menu")?.getAttribute("data-show")).toBe(
"false",
);
button.click();
expect(
dom.querySelector(".wire-next__avatar-group-overflow-button")?.getAttribute("aria-expanded"),
).toBe("true");
expect(dom.querySelectorAll('[role="menuitem"]')).toHaveLength(2);
expect(dom.querySelector(".wire-next__avatar-group-menu")?.getAttribute("data-show")).toBe(
"true",
);
expect(eventDetail).toEqual(expect.objectContaining({ open: true, hiddenCount: 2 }));
expect(uiCss()).toContain('.wire-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(".wire-next__badge-anchor")?.getAttribute("aria-label")).toBe(
"Open notifications",
);
expect(root.querySelector('img[alt="Christina"]')).not.toBeNull();
expect(root.querySelector(".wire-next__badge-dot")).not.toBeNull();
expect(root.querySelector(".wire-next__badge-ping")).not.toBeNull();
(root.querySelector(".wire-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(uiCss()).toContain("@keyframes wire-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(".wire-next__blockquote-mark")).toBeNull();
expect(source).toContain("<slot />");
const css = uiCss();
expect(css).toContain('.wire-next--blockquote[data-variant="bordered"]');
expect(css).toContain('.wire-next--blockquote[data-align="center"]');
expect(css).toContain("--wire-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(".wire-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(".wire-next__card-subtitle")?.textContent).toBe("Version 2.4");
expect(card.querySelector(".wire-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(".wire-next--card") as HTMLElement;
const events: string[] = [];
for (const name of ["navigate", "action", "dismiss"]) {
card.addEventListener(name, () => events.push(name));
}
(dom.querySelectorAll(".wire-next__card-tabs button")[1] as HTMLButtonElement).click();
(dom.querySelector(".wire-next__card-header-actions button") as HTMLButtonElement).click();
(dom.querySelectorAll(".wire-next__card-header-actions button")[1] as HTMLButtonElement).click();
expect(events).toEqual(["navigate", "action", "dismiss"]);
expect(dom.querySelector(".wire-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(".wire-next__card-overlay")).not.toBeNull();
expect(group.querySelectorAll(".wire-next__card-group > article")).toHaveLength(2);
expect(panel.querySelector(".wire-next__card-alert")?.getAttribute("role")).toBe("status");
expect(panel.querySelector(".wire-next__card-empty")?.textContent).toContain("No data to show");
expect(panel.querySelector(".wire-next__card-body")?.getAttribute("data-scrollable")).toBe(
"true",
);
const css = uiCss();
expect(css).toContain('.wire-next--card[data-layout="horizontal"]');
expect(css).toContain('.wire-next--card[data-hover="image"]');
expect(css).toContain(".wire-next__card-group");
expect(css).toContain(".wire-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(".wire-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(".wire-next__chat-message")).toHaveLength(2);
expect(dom.querySelectorAll(".wire-next__chat-content li")).toHaveLength(2);
expect(dom.querySelector(".wire-next__chat-meta[data-tone='danger']")).not.toBeNull();
expect(uiCss()).toContain("color: var(--wire-color-primary-contrast, #fff)");
(dom.querySelector(".wire-next__chat-content") as HTMLElement).click();
(dom.querySelector(".wire-next__chat-avatar") as HTMLButtonElement).click();
(dom.querySelector(".wire-next__chat-content a") as HTMLAnchorElement).click();
(dom.querySelector(".wire-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(".wire-next--collapse") as HTMLElement;
const trigger = dom.querySelector(".wire-next__collapse-trigger") as HTMLButtonElement;
const panel = dom.querySelector(".wire-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(uiCss()).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(
".wire-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(".wire-next__collapse-preview")).toHaveLength(2);
const multiple = mountHtml(await renderComponent(source, { multiple: true, items }));
const multipleTriggers = multiple.querySelectorAll(
".wire-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(".wire-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(".wire-next__field-hint")?.textContent).toContain("Required");
expect(dom.querySelector(".wire-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("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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-next__range-bounds")?.textContent).toContain("Step 25");
expect(dom.querySelectorAll(".wire-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(
".wire-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(".wire-next__field-help")?.textContent).toContain("local date");
const trigger = dom.querySelector('button[aria-haspopup="dialog"]') as HTMLButtonElement;
expect(dom.querySelector(".wire-next--date-picker")?.getAttribute("data-expanded")).toBe("false");
trigger.dispatchEvent(new (dom.window as any).Event("click", { bubbles: true }));
expect(dom.querySelector(".wire-next--date-picker")?.getAttribute("data-expanded")).toBe("true");
expect(dom.querySelector('button[aria-haspopup="dialog"]')?.getAttribute("aria-expanded")).toBe(
"true",
);
expect(dom.querySelectorAll(".wire-next__calendar-grid button")).toHaveLength(31);
const fifteenth = dom.querySelectorAll(
".wire-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(".wire-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(".wire-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(".wire-next--time-picker")?.getAttribute("data-expanded")).toBe("true");
expect(dom.querySelectorAll(".wire-next__time-options").length).toBe(2);
expect(dom.querySelectorAll(".wire-next__time-options button")).toHaveLength(36);
const nine = [...dom.querySelectorAll(".wire-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(".wire-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(".wire-next--file-upload-progress")?.getAttribute("data-status")).toBe(
"uploading",
);
expect(dom.querySelector(".wire-next__row")?.textContent).toContain("design-system.zip");
const buttons = dom.querySelectorAll(".wire-next__upload-actions button") as HTMLButtonElement[];
buttons[0]?.dispatchEvent(new (dom.window as any).Event("click", { bubbles: true }));
expect(dom.querySelector(".wire-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(".wire-next--carousel") as HTMLElement;
const scopeRoot = dom.querySelector("[data-wrn-scope]") as HTMLElement;
const encodedScope = scopeRoot.getAttribute("data-wrn-scope") ?? "";
const hydratedScope = JSON.parse(Buffer.from(encodedScope, "base64").toString("utf8")) as {
items: Array<{ title: string }>;
};
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(hydratedScope.items.map((item) => item.title)).toEqual([
"First story",
"Second story",
"Third story",
]);
expect(dom.querySelectorAll('[aria-roledescription="slide"]')).toHaveLength(3);
expect(dom.querySelectorAll(".wire-next__carousel-pagination button")).toHaveLength(3);
expect(dom.querySelector(".wire-next__carousel-counter")?.textContent).toContain("1 / 3");
(dom.querySelector('[aria-label="Next slide"]') as HTMLButtonElement).click();
expect(dom.querySelector(".wire-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(".wire-next--carousel") as HTMLElement;
const track = dom.querySelector(".wire-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("--wire-carousel-per-view: 3");
expect(dom.querySelectorAll(".wire-next__carousel-thumbnails button")).toHaveLength(4);
const snapHtml = await renderComponent(source, {
items,
isSnap: true,
isDraggable: true,
});
const snapDom = mountHtml(snapHtml);
const snapRoot = snapDom.querySelector(".wire-next--carousel") as HTMLElement;
expect(snapRoot.dataset.snap).toBe("true");
expect(snapRoot.dataset.draggable).toBe("false");
const css = uiCss();
expect(css).toContain('.wire-next--carousel[data-rtl="true"]');
expect(css).toContain('.wire-next--carousel[data-snap="true"]');
expect(css).toContain('.wire-next--carousel[data-thumbnails="vertical"]');
expect(css).toContain("user-select: none");
expect(css).toContain('.wire-next--carousel[data-centered="true"] .wire-next__carousel-track');
});
test("carousel autoplay timers are available in the browser reactive runtime", async () => {
const { getReactiveRuntime } = await import("../../csr/src/index.ts");
const runtime = getReactiveRuntime();
expect(runtime).toContain('name === "setInterval"');
expect(runtime).toContain('name === "clearInterval"');
});
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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(
".wire-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(".wire-next__carousel-counter")?.textContent).toContain("1 / 3");
dom
.querySelector(".wire-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 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(".wire-next__select-trigger") as HTMLButtonElement;
expect(trigger).not.toBeNull();
trigger.click();
expect(
dom.querySelector(".wire-next--advanced-select")?.classList.contains("wire-next--open"),
).toBe(true);
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("true");
expect(dom.querySelector('[role="listbox"]')).not.toBeNull();
const search = dom.querySelector(".wire-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(".wire-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(".wire-next__select-value > span")?.textContent).toBe("Engineering");
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
(dom.querySelector(".wire-next__clear-select") as HTMLButtonElement).click();
expect(dom.querySelector(".wire-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(".wire-next__select-trigger") as HTMLButtonElement;
const counter = dom.querySelector(".wire-next__row small");
expect(counter?.textContent).toBe("2 / 3 selected");
trigger.click();
const buttons = dom.querySelectorAll(".wire-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(".wire-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(".wire-next__select-trigger") as HTMLButtonElement).click();
expect(dom.querySelector(".wire-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(".wire-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(".wire-next__select-option").map((node) => node.textContent),
).toEqual(["Design"]);
(dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement).click();
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
expect(dom.querySelector(".wire-next__select-value > span")?.textContent).toBe("Design");
const search = dom.querySelector(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-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(".wire-next__select-trigger") as HTMLButtonElement).click();
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
await new Promise((resolve) => setTimeout(resolve, 0));
(dom.querySelector(".wire-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(".wire-next__strong-password-popover") as HTMLElement).dataset.open,
).toBe("true");
});
test("strong password completed requirements use a proper checkmark indicator", () => {
const css = uiCss();
expect(css).toContain(
'.wire-next__strong-password-requirements li[data-met="true"] > span::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(--wire-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(".wire-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(".wire-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(".wire-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(".wire-next__password-toggle") as HTMLButtonElement;
const showIcon = dom.querySelector(".wire-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(
".wire-next__password-control > input",
) as HTMLInputElement[];
const synchronizedToggle = synchronizedDom.querySelector(
".wire-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(
".wire-next__password-checkbox input",
) as HTMLInputElement;
const checkboxPassword = checkboxDom.querySelector(
".wire-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(".wire-pagination") as HTMLElement;
expect(root.getAttribute("role")).toBe("navigation");
const current = dom.querySelector('.wire-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(".wire-pagination__compact");
expect(current!.textContent).toContain("3");
});
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(".wire-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(".wire-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(".wire-stepper")!.tagName.toLowerCase()).toBe("div");
expect(dom.querySelector(".wire-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(".wire-stepper") as HTMLElement;
expect(root.getAttribute("data-orientation")).toBe("vertical");
const items = [...dom.querySelectorAll(".wire-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(".wire-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(".wire-nav__badge")]
.map((node) => node.textContent!.trim())
.filter(Boolean);
expect(badges).toContain("9");
expect(dom.querySelector(".wire-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(".wire-nav__arrow")).not.toBeNull();
expect(dom.querySelector(".wire-nav__submenu")).not.toBeNull();
expect(dom.querySelector(".wire-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(".wire-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(".wire-nav")).not.toBeNull();
expect(dom.querySelectorAll(".wire-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(".wire-nav__submenu[data-wrn-anchored]").length).toBeGreaterThan(0);
});
test("tabs use wire classes and declare real outputs instead of raw events", async () => {
const source = readFileSync(uiComponentPath("Tabs"), "utf8");
// The whole point of the rewrite: themeable wire-* 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(".wire-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(".wire-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(".wire-mega__trigger") as HTMLElement;
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-haspopup")).toBe("true");
const panel = dom.querySelector(".wire-mega__panel") as HTMLElement;
expect(panel.getAttribute("data-wrn-anchored")).toBe("true");
expect(dom.querySelectorAll(".wire-mega__column")).toHaveLength(2);
expect(dom.querySelectorAll(".wire-mega__link").length).toBeGreaterThanOrEqual(3);
expect(dom.querySelector(".wire-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("wire-mega__submenu");
});
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(".wire-mega")).not.toBeNull();
expect(dom.querySelectorAll(".wire-mega__link")).toHaveLength(0);
expect(dom.querySelector('[data-wrn-roving="both"]')).not.toBeNull();
});
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(".wire-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(".wire-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(".wire-sidebar__icon")).not.toBeNull();
// Third level exists and is reachable.
const deep = [...dom.querySelectorAll(".wire-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(".wire-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(".wire-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(".wire-scrollspy")).not.toBeNull();
expect(dom.querySelectorAll(".wire-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(".wire-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(".wire-breadcrumb")!.getAttribute("aria-label")).toBeTruthy();
expect(dom.querySelectorAll(".wire-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(".wire-mega__panel::before");
expect(source).toMatch(/\.wire-mega__panel::before \{[^}]*bottom: 100%/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(".wire-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(".wire-stepper__back")).not.toBeNull();
expect(dom.querySelector(".wire-stepper__next")).not.toBeNull();
expect(dom.querySelector(".wire-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(".wire-stepper__back")!.getAttribute("disabled")).not.toBeNull();
expect(first.querySelector(".wire-stepper__next")!.textContent).toContain("Next");
const last = mountHtml(await renderComponent(source, { steps, active: 1, controls: true }));
expect(last.querySelector(".wire-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(".wire-stepper__next")!.getAttribute("disabled")).not.toBeNull();
});