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