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}"); }); test("form field labels use a stable control-label size instead of inherited heading scale", () => { const fieldComponents = uiComponentNames().filter((name) => readFileSync(uiComponentPath(name), "utf8").includes(".wrn-next__field-heading label"), ); expect(fieldComponents.length).toBeGreaterThan(0); for (const name of fieldComponents) { const source = readFileSync(uiComponentPath(name), "utf8"); expect(source).toMatch( /\.wrn-next__field-heading label\s*\{[^}]*font-size: 0\.8125rem;[^}]*line-height: 1\.35;/s, ); } }); test("global UI CSS stays below its migration ratchet", () => { const css = uiCss(); // Component selectors belong to their owning .wrn files. Keep this asset to // resets, tokens, accessibility behavior, motion and reusable utilities. expect(gzipSync(new TextEncoder().encode(css)).length).toBeLessThanOrEqual(3_800); for (const localOrRemovedFamily of [ ".wrn-switch", ".wrn-alert", ".wrn-card", ".wrn-footer", ".wrn-metric-card", ".wrn-segmented-group", ".wrn-dropdown", ".wrn-btn", ]) { expect(css).not.toContain(localOrRemovedFamily); } }); test("components do not carry the standalone wrn-next generation artifact", () => { const offenders = uiComponentNames().filter((name) => { const source = readFileSync(uiComponentPath(name), "utf8"); return [...source.matchAll(/class=["']([^"']*)["']/g)].some((match) => match[1]!.split(/\s+/).includes("wrn-next"), ); }); expect(offenders).toEqual([]); }); test("every bundled component carries local styles", () => { const unstyled = uiComponentNames().filter( (name) => !/^\s{2,4}style \{/m.test(readFileSync(uiComponentPath(name), "utf8")), ); expect(unstyled).toEqual([]); }); test("generated component reference documents every bundled component and its props", () => { const reference = JSON.parse( readFileSync(join(uiComponentsDir(), "..", "component-reference.json"), "utf8"), ) as { count: number; components: Array<{ name: string; props: Array<{ name: string }> }> }; const catalog = JSON.parse( readFileSync(join(uiComponentsDir(), "..", "component-catalog.json"), "utf8"), ) as { components: Array<{ name: string }> }; expect(reference.count).toBe(uiComponentNames().length); expect(reference.count).toBe(102); expect(reference.components.map((component) => component.name)).toEqual( expect.arrayContaining(uiComponentNames()), ); expect(catalog.components.map((component) => component.name).sort()).toEqual( uiComponentNames().sort(), ); expect(reference.components.find((component) => component.name === "Button")?.props).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "label" }), expect.objectContaining({ name: "as" }), ]), ); expect( reference.components .find((component) => component.name === "PinInput") ?.props.find((prop) => prop.name === "length"), ).toEqual(expect.objectContaining({ default: "4" })); }); test("interactive components expose their must-have public event contracts", () => { const reference = JSON.parse( readFileSync(join(uiComponentsDir(), "..", "component-reference.json"), "utf8"), ) as { components: Array<{ name: string; events: string[] }> }; const eventsByComponent = new Map( reference.components.map((component) => [component.name, component.events]), ); const requiredEvents: Record = { 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(); 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 }; 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(//); expect(source).toMatch(//); expect(source).toMatch(//); expect(source).toMatch(//); 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][^<>{}]*) 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 | undefined; root.addEventListener("action", (event) => { detail = (event as unknown as CustomEvent>).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(""); 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 = { 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( `
` + `` + `` + `` + `
`, ); (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 }> = []; 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 }> = []; 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 | 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 | undefined; root.addEventListener("toggle", (event) => { detail = (event as unknown as CustomEvent>).detail; }); expect(root.dataset.value).toBe("monthly"); expect( dom .querySelectorAll(".wrn-next__toggle-count-items strong > span") .map((node) => node.textContent), ).toEqual(["19", "89"]); (dom.querySelector('button[aria-pressed="false"]') as HTMLButtonElement).click(); const updatedRoot = dom.querySelector("[data-wrn-toggle-count]") as HTMLElement; expect(updatedRoot.dataset.value).toBe("annual"); expect( dom .querySelectorAll(".wrn-next__toggle-count-items strong > span") .map((node) => node.textContent), ).toEqual(["190", "890"]); expect((dom.querySelector('input[name="billing"]') as HTMLInputElement).value).toBe("annual"); expect(dom.querySelector('button[aria-pressed="true"]')?.textContent).toBe("Annual"); expect(detail).toMatchObject({ component: "ToggleCount", name: "billing", value: "annual", previousValue: "monthly", }); }); test("toggle count animates every price upward and downward", async () => { const source = readFileSync(uiComponentPath("ToggleCount"), "utf8"); const html = await renderComponent(source, { value: "monthly", animationDuration: 40, animationSteps: 4, items: [ { label: "Startup", monthly: 2, annual: 10 }, { label: "Team", monthly: 4, annual: 20 }, ], }); const dom = mountHtml(html); const values = () => dom.querySelectorAll("[data-toggle-count-value]").map((node) => Number(node.textContent)); const button = (label: string) => dom .querySelectorAll(".wrn-next__toggle-count-segmented button") .find((node) => node.textContent === label) as HTMLButtonElement; button("Annual").click(); expect(values()).toEqual([2, 4]); await new Promise((resolve) => setTimeout(resolve, 15)); expect(values().every((value) => Number.isInteger(value))).toBe(true); await new Promise((resolve) => setTimeout(resolve, 80)); expect(values()).toEqual([10, 20]); button("Monthly").click(); expect(values()).toEqual([10, 20]); await new Promise((resolve) => setTimeout(resolve, 15)); expect(values().every((value) => Number.isInteger(value))).toBe(true); await new Promise((resolve) => setTimeout(resolve, 80)); expect(values()).toEqual([2, 4]); }); test("toggle count switch exposes switch semantics and respects disabled state", async () => { const source = readFileSync(uiComponentPath("ToggleCount"), "utf8"); const html = await renderComponent(source, { variant: "switch", value: "monthly", disabled: true, items: [{ label: "Startup", monthly: 19, annual: 190 }], }); const dom = mountHtml(html); const control = dom.querySelector('[role="switch"]') as HTMLButtonElement; expect(control.getAttribute("aria-checked")).toBe("false"); expect(control.disabled).toBe(true); control.click(); expect((dom.querySelector("[data-wrn-toggle-count]") as HTMLElement).dataset.value).toBe( "monthly", ); }); test("toggle password reveals and conceals its value with accessible declared events", async () => { const source = readFileSync(uiComponentPath("TogglePassword"), "utf8"); const html = await renderComponent(source, { name: "accountPassword", value: "correct-horse", }); const dom = mountHtml(html); const root = dom.querySelector("[data-wrn-toggle-password]") as HTMLElement; const input = dom.querySelector('input[name="accountPassword"]') as HTMLInputElement; const toggle = dom.querySelector(".wrn-next__password-toggle") as HTMLButtonElement; const showIcon = dom.querySelector(".wrn-next__password-icon--show") as HTMLElement; const visibility: boolean[] = []; root.addEventListener("toggle", (event) => { visibility.push((event as unknown as CustomEvent).detail.visible); }); expect(input.type).toBe("password"); expect(input.pattern).toBe("(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}"); expect(showIcon).not.toBeNull(); expect(toggle.getAttribute("aria-pressed")).toBe("false"); toggle.click(); expect(input.type).toBe("text"); expect(input.value).toBe("correct-horse"); expect(toggle.getAttribute("aria-pressed")).toBe("true"); expect(visibility).toEqual([true]); toggle.click(); expect(input.type).toBe("password"); expect(visibility).toEqual([true, false]); }); test("toggle password supports checkbox control and synchronized password fields", async () => { const source = readFileSync(uiComponentPath("TogglePassword"), "utf8"); const synchronizedHtml = await renderComponent(source, { name: "credentials", fields: [ { label: "New password", name: "newPassword", placeholder: "Enter new password", autocomplete: "new-password", }, { label: "Current password", name: "currentPassword", value: "existing-secret", autocomplete: "current-password", pattern: ".{12,}", }, ], }); const synchronizedDom = mountHtml(synchronizedHtml); const synchronizedInputs = synchronizedDom.querySelectorAll( ".wrn-next__password-control > input", ) as HTMLInputElement[]; const synchronizedToggle = synchronizedDom.querySelector( ".wrn-next__password-toggle", ) as HTMLButtonElement; expect(synchronizedInputs).toHaveLength(2); expect(synchronizedInputs[0].pattern).toBe( "(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}", ); expect(synchronizedInputs[1].pattern).toBe(".{12,}"); expect(synchronizedInputs.map((input) => input.type)).toEqual(["password", "password"]); synchronizedToggle.click(); expect(synchronizedInputs.map((input) => input.type)).toEqual(["text", "text"]); const checkboxHtml = await renderComponent(source, { name: "checkboxPassword", toggleMode: "checkbox", }); const checkboxDom = mountHtml(checkboxHtml); const checkbox = checkboxDom.querySelector( ".wrn-next__password-checkbox input", ) as HTMLInputElement; const checkboxPassword = checkboxDom.querySelector( ".wrn-next__password-control > input", ) as HTMLInputElement; checkbox.checked = true; checkbox.dispatchEvent( new (checkboxDom.window as { Event: typeof Event }).Event("change", { bubbles: true }), ); expect(checkboxPassword.type).toBe("text"); }); test("pagination renders windowed page numbers and emits change", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const html = await renderComponent(source, { page: 5, pageSize: 10, total: 200, variant: "numbered", siblingCount: 1, }); const dom = mountHtml(html); const root = dom.querySelector(".wrn-pagination") as HTMLElement; expect(root.getAttribute("role")).toBe("navigation"); const current = dom.querySelector('.wrn-pagination__page[aria-current="page"]'); expect(current!.textContent!.trim()).toBe("5"); expect(root.textContent).toContain("41"); expect(root.textContent).toContain("50"); expect(root.textContent).toContain("200"); }); test("pagination clamps an out-of-range page instead of rendering nothing", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const html = await renderComponent(source, { page: 99, pageSize: 10, total: 30 }); const dom = mountHtml(html); const current = dom.querySelector(".wrn-pagination__compact"); expect(current!.textContent).toContain("3"); }); test("pagination renders links when given an href template", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const html = await renderComponent(source, { page: 2, pageSize: 10, total: 50, variant: "numbered", hrefTemplate: "/news?page={page}", }); const dom = mountHtml(html); // Server-rendered navigation must be anchors, so the controls work before // hydration and without JavaScript. expect(dom.querySelector(".wrn-pagination__step button")).toBeNull(); const steps = [...dom.querySelectorAll("a.wrn-pagination__step")] as HTMLElement[]; expect(steps.length).toBe(2); expect(steps[0]!.getAttribute("href")).toBe("/news?page=1"); expect(steps[1]!.getAttribute("href")).toBe("/news?page=3"); const pages = [...dom.querySelectorAll("a.wrn-pagination__page")] as HTMLElement[]; expect(pages.some((page) => page.getAttribute("href") === "/news?page=5")).toBe(true); }); test("pagination href steps are disabled and clamped at both ends", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const first = mountHtml( await renderComponent(source, { page: 1, pageSize: 10, total: 30, hrefTemplate: "/news?page={page}", }), ); const previous = first.querySelector("a.wrn-pagination__step") as HTMLElement; expect(previous.getAttribute("data-disabled")).toBe("true"); // Clamped rather than linking to page 0. expect(previous.getAttribute("href")).toBe("/news?page=1"); const last = mountHtml( await renderComponent(source, { page: 3, pageSize: 10, total: 30, hrefTemplate: "/news?page={page}", }), ); const steps = [...last.querySelectorAll("a.wrn-pagination__step")] as HTMLElement[]; expect(steps[1]!.getAttribute("data-disabled")).toBe("true"); expect(steps[1]!.getAttribute("href")).toBe("/news?page=3"); }); test("pagination still renders buttons without an href template", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const dom = mountHtml(await renderComponent(source, { page: 2, pageSize: 10, total: 50 })); expect(dom.querySelectorAll("button.wrn-pagination__step").length).toBe(2); expect(dom.querySelector("a.wrn-pagination__step")).toBeNull(); }); test("pagination survives an empty dataset", async () => { const source = readFileSync(uiComponentPath("Pagination"), "utf8"); const html = await renderComponent(source, { page: 1, pageSize: 10, total: 0 }); const dom = mountHtml(html); expect(dom.querySelector(".wrn-pagination")).not.toBeNull(); }); test("stepper marks complete, current and upcoming steps", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const html = await renderComponent(source, { steps: [ { label: "Account", description: "Your details" }, { label: "Billing", description: "Payment method" }, { label: "Confirm", description: "Review and submit" }, ], active: 1, }); const dom = mountHtml(html); const items = [...dom.querySelectorAll(".wrn-stepper__step")]; expect(items).toHaveLength(3); expect(items[0]!.getAttribute("data-status")).toBe("complete"); expect(items[1]!.getAttribute("data-status")).toBe("current"); expect(items[2]!.getAttribute("data-status")).toBe("upcoming"); expect(items[1]!.getAttribute("aria-current")).toBe("step"); // One root wraps the list, the panels and the controls; the steps // themselves are still an ordered list, which is what carries the semantics. expect(dom.querySelector(".wrn-stepper")!.tagName.toLowerCase()).toBe("div"); expect(dom.querySelector(".wrn-stepper__list")!.tagName.toLowerCase()).toBe("ol"); }); test("stepper renders vertically and clamps an out-of-range active index", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const html = await renderComponent(source, { steps: [{ label: "One" }, { label: "Two" }], active: 99, orientation: "vertical", }); const dom = mountHtml(html); const root = dom.querySelector(".wrn-stepper") as HTMLElement; expect(root.getAttribute("data-orientation")).toBe("vertical"); const items = [...dom.querySelectorAll(".wrn-stepper__step")]; expect(items[1]!.getAttribute("data-status")).toBe("current"); }); test("clickable stepper opts into roving focus", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const html = await renderComponent(source, { steps: [{ label: "One" }, { label: "Two" }], active: 0, clickable: true, }); const dom = mountHtml(html); expect(dom.querySelector('[data-wrn-roving="horizontal"]')).not.toBeNull(); expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBe(2); }); test("nav renders links, marks the active one, and shows icons and badges", async () => { const source = readFileSync(uiComponentPath("Nav"), "utf8"); const html = await renderComponent(source, { items: [ { label: "Home", href: "/", value: "home", icon: "icon-[lucide--house]" }, { label: "Inbox", href: "/inbox", value: "inbox", badge: "9" }, { label: "Archive", href: "/archive", value: "archive", disabled: true }, ], active: "inbox", }); const dom = mountHtml(html); expect(dom.querySelector(".wrn-nav")!.getAttribute("role")).toBe("navigation"); const current = dom.querySelector('[aria-current="page"]'); expect(current!.textContent).toContain("Inbox"); // Every item renders a badge span; the ones without a badge are empty and // carry data-show="false", so match on content rather than first-in-document. const badges = [...dom.querySelectorAll(".wrn-nav__badge")] .map((node) => node.textContent!.trim()) .filter(Boolean); expect(badges).toContain("9"); expect(dom.querySelector(".wrn-nav__icon")).not.toBeNull(); expect(dom.querySelector('[aria-disabled="true"]')).not.toBeNull(); }); test("nav renders a submenu with a disclosure arrow and opts into roving focus", async () => { const source = readFileSync(uiComponentPath("Nav"), "utf8"); const html = await renderComponent(source, { items: [ { label: "Products", value: "products", items: [ { label: "Overview", href: "/p", value: "p-overview" }, { label: "More", value: "p-more", items: [{ label: "Deep", href: "/d", value: "deep" }], }, ], }, ], active: "p-overview", }); const dom = mountHtml(html); expect(dom.querySelector('[data-wrn-roving="horizontal"]')).not.toBeNull(); expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBeGreaterThan(0); expect(dom.querySelector(".wrn-nav__arrow")).not.toBeNull(); expect(dom.querySelector(".wrn-nav__submenu")).not.toBeNull(); expect(dom.querySelector(".wrn-nav__submenu--level3")).not.toBeNull(); // Every second-level item renders a third-level list; only the one whose // item actually has children is populated, so search across them all. const thirdLevel = [...dom.querySelectorAll(".wrn-nav__submenu--level3")] .map((node) => node.textContent!.trim()) .filter(Boolean); expect(thirdLevel.join(" ")).toContain("Deep"); }); test("nav renders its shell with no items and rejects a non-array", async () => { const source = readFileSync(uiComponentPath("Nav"), "utf8"); const html = await renderComponent(source, { items: [], active: "" }); const dom = mountHtml(html); expect(dom.querySelector(".wrn-nav")).not.toBeNull(); expect(dom.querySelectorAll(".wrn-nav__link").length).toBe(0); // A declared unknown[] prop is coerced by the framework, which rejects a // non-array before the component runs. The itemList() guard is the second // line of defence for a missing prop, not a way to render junk. await expect(renderComponent(source, { items: "not-an-array", active: "" })).rejects.toThrow( "Expected an array prop", ); }); test("nav submenus are anchored so the viewport clamp can pull them back", async () => { const source = readFileSync(uiComponentPath("Nav"), "utf8"); const html = await renderComponent(source, { items: [ { label: "Products", value: "products", items: [{ label: "Overview", href: "/p", value: "p" }], }, ], active: "p", }); const dom = mountHtml(html); // visibility:hidden keeps layout, so the clamp can place a submenu correctly // before it is ever shown -- which is why CSS-driven hover still composes // with the runtime clamp. expect(dom.querySelectorAll(".wrn-nav__submenu[data-wrn-anchored]").length).toBeGreaterThan(0); }); test("tabs use wrn classes and declare real outputs instead of raw events", async () => { const source = readFileSync(uiComponentPath("Tabs"), "utf8"); // The whole point of the rewrite: themeable wrn-* classes, not Tailwind. expect(source).toContain("outputs {"); expect(source).toContain("output.change("); expect(source).not.toContain("new CustomEvent("); const html = await renderComponent(source, { items: [ { label: "Overview", value: "overview", content: "First panel" }, { label: "Pricing", value: "pricing", content: "Second panel" }, ], active: "overview", }); const dom = mountHtml(html); const list = dom.querySelector('[role="tablist"]') as HTMLElement; expect(list.getAttribute("data-wrn-roving")).toBe("horizontal"); const tabs = dom.querySelectorAll('[role="tab"]'); expect(tabs).toHaveLength(2); expect(tabs[0]!.getAttribute("aria-selected")).toBe("true"); expect(tabs[1]!.getAttribute("aria-selected")).toBe("false"); expect(tabs[0]!.id).toBe("wrn-tab-overview"); expect(tabs[0]!.getAttribute("aria-controls")).toBe("wrn-panel-overview"); expect(dom.querySelector(".wrn-tabs")).not.toBeNull(); const panels = dom.querySelectorAll('[role="tabpanel"]'); expect(panels).toHaveLength(2); expect(panels[0]!.id).toBe("wrn-panel-overview"); expect(panels[0]!.getAttribute("aria-labelledby")).toBe("wrn-tab-overview"); }); test("tabs in url mode declare the query parameter they sync to", async () => { const source = readFileSync(uiComponentPath("Tabs"), "utf8"); const html = await renderComponent(source, { items: [ { label: "One", value: "one" }, { label: "Two", value: "two" }, ], active: "one", mode: "url", param: "tab", }); const dom = mountHtml(html); const root = dom.querySelector(".wrn-tabs") as HTMLElement; expect(root.getAttribute("data-mode")).toBe("url"); expect(root.getAttribute("data-param")).toBe("tab"); /* * In url mode the query parameter is the source of truth rather than * component state. The client router owns popstate and swaps the whole page * on back and forward, discarding component state, so the selection has to * fall out of the URL for history to work at all. */ const source2 = readFileSync(uiComponentPath("Tabs"), "utf8"); expect(source2).toContain("URLSearchParams(window.location.search)"); expect(source2).toContain("history.pushState"); }); test("tabs vertical orientation switches the roving axis", async () => { const source = readFileSync(uiComponentPath("Tabs"), "utf8"); const html = await renderComponent(source, { items: [{ label: "One", value: "one" }], active: "one", orientation: "vertical", }); const dom = mountHtml(html); expect(dom.querySelector('[role="tablist"]')!.getAttribute("data-wrn-roving")).toBe("vertical"); }); test("mega menu renders column groups in an anchored panel", async () => { const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); const html = await renderComponent(source, { label: "Products", columns: [ { heading: "Platform", items: [ { label: "Runtime", href: "/runtime", description: "The browser half" }, { label: "Compiler", href: "/compiler" }, ], }, { heading: "Company", items: [{ label: "About", href: "/about" }] }, ], }); const dom = mountHtml(html); const trigger = dom.querySelector(".wrn-mega__trigger") as HTMLElement; expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(trigger.getAttribute("aria-haspopup")).toBe("true"); expect(trigger.getAttribute("aria-label")).toBe("Products"); expect(dom.querySelector(".wrn-mega__trigger-label")?.getAttribute("data-label")).toBe( "Products", ); const panel = dom.querySelector(".wrn-mega__panel") as HTMLElement; expect(panel.getAttribute("data-wrn-anchored")).toBe("true"); expect(dom.querySelectorAll(".wrn-mega__column")).toHaveLength(2); expect(dom.querySelectorAll(".wrn-mega__link").length).toBeGreaterThanOrEqual(3); expect(dom.querySelector(".wrn-mega__heading")!.textContent).toContain("Platform"); // Single level by design: a mega panel shows breadth flat, so there is no // nested submenu markup to find. expect(source).not.toContain("wrn-mega__submenu"); expect(source).toContain('.wrn-mega[data-variant="default"] .wrn-mega__panel'); expect(source).toContain("box-sizing: border-box"); }); test("announcement full-width surface does not create viewport scrollbar overflow", () => { const source = readFileSync(uiComponentPath("AnnouncementBar"), "utf8"); expect(source).not.toContain("width: 100vw"); expect(source).not.toContain("calc(50% - 50vw)"); }); test("mega menu columns take roving focus and survive an empty column list", async () => { const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); const html = await renderComponent(source, { label: "Empty", columns: [] }); const dom = mountHtml(html); expect(dom.querySelector(".wrn-mega")).not.toBeNull(); expect(dom.querySelectorAll(".wrn-mega__link")).toHaveLength(0); expect(dom.querySelector('[data-wrn-roving="both"]')).not.toBeNull(); }); test("catalog mega menu renders only the active rail category", async () => { const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); expect(source).toContain(".wrn-mega__list > li"); expect(source).toContain("overflow-wrap: anywhere;"); const html = await renderComponent(source, { label: "Products", variant: "catalog", activeCategory: "sales", rail: [ { label: "Platform", value: "platform" }, { label: "Sales", value: "sales" }, ], columns: [ { heading: "Platform", items: [{ label: "Home", href: "/home" }] }, { heading: "Sales", items: [{ label: "CRM", href: "/crm" }] }, ], }); const dom = mountHtml(html); expect(dom.querySelectorAll(".wrn-mega__rail-link")).toHaveLength(2); expect(dom.querySelectorAll(".wrn-mega__column")).toHaveLength(1); expect(dom.querySelector(".wrn-mega__heading")?.textContent).toContain("Sales"); expect(dom.querySelector('[aria-pressed="true"]')?.textContent).toContain("Sales"); expect(source).toContain("overflow-y: auto"); expect(source).not.toContain("@mouseenter='chooseCategory(item, index)'"); expect(dom.querySelector(".wrn-mega__column-header")?.textContent).toContain("Sales"); expect(dom.querySelectorAll(".wrn-mega__link-icon")).toHaveLength(1); expect(dom.querySelector(".wrn-mega__link-icon--fallback svg")).not.toBeNull(); expect(dom.querySelectorAll(".wrn-mega__link-description")).toHaveLength(0); expect(source).toContain("border-bottom: 1px solid var(--wrn-color-border)"); expect(source).toContain(".wrn-mega__rail-icon > span"); expect(source).toContain("color: currentColor !important"); }); test("mega menu supports rails, icons, badges, media, featured content and footer actions", async () => { const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); const html = await renderComponent(source, { label: "Explore", variant: "icon-grid", panelWidth: "2xl", columnCount: 3, rail: [{ label: "Enterprise", description: "For larger teams", icon: "icon-enterprise" }], columns: [ { heading: "Products", description: "Choose a workspace", image: "/products.png", items: [{ label: "CRM", description: "Customer records", icon: "icon-crm", badge: "New" }], actionLabel: "All products", }, ], featured: { eyebrow: "What's new", title: "Release notes", image: "/release.png", actionLabel: "See more", }, footer: "One connected platform", actionLabel: "View everything", }); const dom = mountHtml(html); expect(dom.querySelector(".wrn-mega")?.getAttribute("data-variant")).toBe("icon-grid"); expect(dom.querySelector(".wrn-mega__rail-link")?.textContent).toContain("Enterprise"); expect(dom.querySelector(".wrn-mega__badge")?.textContent).toContain("New"); expect(dom.querySelector(".wrn-mega__column-image")).not.toBeNull(); expect(dom.querySelector(".wrn-mega__featured")?.textContent).toContain("Release notes"); expect(dom.querySelector(".wrn-mega__footer")?.textContent).toContain("View everything"); }); test("sidebar renders single items, labelled groups and nested levels", async () => { const source = readFileSync(uiComponentPath("Sidebar"), "utf8"); const html = await renderComponent(source, { label: "Workspace", items: [ { label: "Dashboard", href: "/", value: "dash", icon: "icon-[lucide--gauge]" }, { heading: "Projects", items: [ { label: "Active", href: "/active", value: "active" }, { label: "Archive", value: "archive", items: [{ label: "2025", href: "/a/2025", value: "a2025" }], }, ], }, ], active: "active", }); const dom = mountHtml(html); expect(dom.querySelector(".wrn-sidebar")).not.toBeNull(); // Every entry renders a heading node; only group entries fill it in, so // match on content rather than first-in-document. const headings = [...dom.querySelectorAll(".wrn-sidebar__heading")] .map((node) => node.textContent!.trim()) .filter(Boolean); expect(headings).toContain("Projects"); expect(dom.querySelector('[aria-current="page"]')!.textContent).toContain("Active"); expect(dom.querySelector(".wrn-sidebar__icon")).not.toBeNull(); // Third level exists and is reachable. const deep = [...dom.querySelectorAll(".wrn-sidebar__sublist--level3")] .map((n) => n.textContent!.trim()) .filter(Boolean); expect(deep.join(" ")).toContain("2025"); expect(dom.querySelector('[data-wrn-roving="vertical"]')).not.toBeNull(); }); test("sidebar composes Drawer for its off-canvas presentation", async () => { const source = readFileSync(uiComponentPath("Sidebar"), "utf8"); // Reusing Drawer means the focus trap and scroll lock come for free rather // than being reimplemented here. expect(source).toContain('import Drawer from "./Drawer.wrn"'); expect(source).toContain(" { 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"); expect(source).toContain("pointer-events: none;"); expect(source).toContain(".wrn-navbar__menus > *"); const html = await renderComponent(source, { label: "Main", items: [ { label: "Home", href: "/", value: "home" }, { label: "Docs", href: "/docs", value: "docs" }, ], active: "docs", }); const dom = mountHtml(html); const menus = dom.querySelector(".wrn-navbar__menus") as HTMLElement; expect(menus.getAttribute("data-wrn-roving")).toBe("horizontal"); /* * Asserted against the rendered markup rather than the parsed DOM: the test * DOM drops aria-current from these anchors, while the served HTML carries * it correctly. */ expect(html).toContain('href="/docs"'); expect(html).toMatch(/aria-current="page"/); expect(html).not.toMatch(/aria-current=""/); }); test("breadcrumb marks the trail end without emitting an empty aria-current", async () => { const source = readFileSync(uiComponentPath("Breadcrumb"), "utf8"); const html = await renderComponent(source, { items: [ { label: "Docs", href: "/docs" }, { label: "Components", href: "/docs/components" }, { label: "Breadcrumb" }, ], }); // aria-current="" is not a valid value; the attribute takes a token. expect(html).not.toMatch(/aria-current=""/); expect(html).toMatch(/aria-current="page"/); const dom = mountHtml(html); expect(dom.querySelector(".wrn-breadcrumb")!.getAttribute("aria-label")).toBeTruthy(); expect(dom.querySelectorAll(".wrn-breadcrumb__item").length).toBeGreaterThanOrEqual(3); }); test("breadcrumb renders a standalone active page with its icon", async () => { const source = readFileSync(uiComponentPath("Breadcrumb"), "utf8"); const html = await renderComponent(source, { showHome: true, items: [{ label: "Products", href: "/products", icon: "icon-[lucide--blocks]" }], active: "CRM", activeIcon: "icon-[lucide--contact-round]", }); expect(html).toContain("CRM"); expect(html).toContain("icon-[lucide--contact-round]"); expect(html).toMatch(/aria-current="page"/); const dom = mountHtml(html); expect(dom.querySelectorAll(".wrn-breadcrumb__item").length).toBe(3); }); test("breadcrumb default separators remain visible without generated icon CSS", async () => { const source = readFileSync(uiComponentPath("Breadcrumb"), "utf8"); const html = await renderComponent(source, { showHome: true, items: [{ label: "Legal" }, { label: "Terms", current: true }], }); expect(html).toContain("›"); expect(html).not.toContain("icon-[lucide--chevron-right]"); }); test("stats bar desktop gutters align its surface with container content", () => { const source = readFileSync(uiComponentPath("StatsBar"), "utf8"); expect(source).toContain("--stats-max-width: 80rem"); expect(source).toContain("--stats-gutter-width: 4rem"); expect(source).toContain("calc(var(--stats-max-width) - var(--stats-gutter-width))"); }); test("mega menu bridges the gap between its trigger and panel", async () => { const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); /* * The panel is offset below the trigger, and that offset is dead space * belonging to neither element: travelling to the panel fired mouseleave on * the root and closed the menu before the pointer arrived. A descendant * covering the gap keeps the pointer inside the component. */ expect(source).toContain(".wrn-mega__panel::before"); expect(source).toMatch(/\.wrn-mega__panel::before \{[^}]*bottom: 100%/s); }); test("mega menu keeps hover activation and click activation from cancelling each other", () => { const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); expect(source).toContain("openOnHover: boolean = true"); expect(source).toContain("@click='activatePanel(event)'"); expect(source).toContain("@mouseenter='hoverPanel(event)'"); expect(source).toContain("showPanel(sourceEvent)"); expect(source).not.toContain("function scheduleOpen"); expect(source).toContain('.wrn-mega__panel[data-show="false"] { display: none; }'); expect(source).toContain('style=\'{visible ? "" : "display:none"}\''); expect(source).not.toContain("@focus='showPanel(event)'"); expect(source).toContain("max-height: calc(100dvh - 5.5rem)"); }); test("mega menu gives the pointer time to cross from its trigger into a floating panel", () => { const source = readFileSync(uiComponentPath("MegaMenu"), "utf8"); expect(source).toContain("state closeTimer = null"); expect(source).toContain("@mouseleave='scheduleHide()'"); expect(source).toContain("@mouseenter='cancelScheduledHide()'"); expect(source).toContain("}, 220)"); expect(source).not.toContain("@mouseleave='hidePanel(\"pointer-leave\")'"); }); test("navbar pins full-width mega panels to desktop viewport gutters", () => { const source = readFileSync(uiComponentPath("Navbar"), "utf8"); expect(source).toMatch( /\.wrn-navbar__mega\[data-full-width="true"\] \.wrn-mega__panel \{[^}]*position: fixed/s, ); expect(source).toMatch(/right: 1rem;\s+left: 1rem;/s); expect(source).toMatch( /data-collapse-at="1100px"[^}]+\.wrn-navbar__mega\[data-full-width="true"\][^}]+position: static/s, ); }); test("stepper shows only the active step content and offers back, next and skip", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const html = await renderComponent(source, { steps: [ { label: "Account", content: "ACCOUNT BODY" }, { label: "Billing", content: "BILLING BODY" }, { label: "Confirm", content: "CONFIRM BODY" }, ], active: 1, showPanel: true, controls: true, allowSkip: true, }); const dom = mountHtml(html); const panels = [...dom.querySelectorAll(".wrn-stepper__panel")]; expect(panels).toHaveLength(3); /* * Asserted through aria-hidden rather than data-show. data-show is emitted * verbatim for the client runtime to evaluate, so its server-rendered value * is not a reliable statement about which step is showing; aria-hidden is * interpolated at render and says exactly that. */ expect(panels.map((p) => p.getAttribute("aria-hidden"))).toEqual(["true", "false", "true"]); expect(dom.querySelector(".wrn-stepper__back")).not.toBeNull(); expect(dom.querySelector(".wrn-stepper__next")).not.toBeNull(); expect(dom.querySelector(".wrn-stepper__skip")).not.toBeNull(); }); test("stepper back is disabled on the first step and next becomes finish on the last", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const steps = [{ label: "One" }, { label: "Two" }]; const first = mountHtml(await renderComponent(source, { steps, active: 0, controls: true })); expect(first.querySelector(".wrn-stepper__back")!.getAttribute("disabled")).not.toBeNull(); expect(first.querySelector(".wrn-stepper__next")!.textContent).toContain("Next"); const last = mountHtml(await renderComponent(source, { steps, active: 1, controls: true })); expect(last.querySelector(".wrn-stepper__next")!.textContent).toContain("Finish"); }); test("stepper next can be gated so a form can hold it until the step validates", async () => { const source = readFileSync(uiComponentPath("Stepper"), "utf8"); const html = await renderComponent(source, { steps: [{ label: "One" }, { label: "Two" }], active: 0, controls: true, nextDisabled: true, }); const dom = mountHtml(html); expect(dom.querySelector(".wrn-stepper__next")!.getAttribute("disabled")).not.toBeNull(); }); test("layout splitter renders two panes and an operable separator", async () => { const source = readFileSync(uiComponentPath("LayoutSplitter"), "utf8"); /* * It used to declare resizeStart, resize and resizeEnd with no pointer * handling at all, so a caller connected up @resize and received nothing for * ever. The behaviour now lives in the runtime behind these markers. */ expect(source).toContain("data-wrn-splitter"); expect(source).toContain("data-wrn-splitter-handle"); /* * The output is sizeChange, not resize. An output named after a native DOM * event is emitted by the component but never reaches the parent binding. */ expect(source).toContain("sizeChange(payload:"); expect(source).not.toMatch(/^\s*resize\(payload:/m); const html = await renderComponent(source, { orientation: "horizontal", size: 40, minSize: 20, label: "Resize panels", }); const dom = mountHtml(html); const root = dom.querySelector(".wrn-splitter") as HTMLElement; expect(root.getAttribute("data-wrn-splitter")).toBe("horizontal"); expect(root.getAttribute("style")).toContain("--wrn-split"); const handle = dom.querySelector("[data-wrn-splitter-handle]") as HTMLElement; expect(handle.getAttribute("role")).toBe("separator"); expect(handle.getAttribute("tabindex")).toBe("0"); expect(handle.getAttribute("aria-valuenow")).toBe("40"); expect(handle.getAttribute("aria-valuemin")).toBe("20"); expect(handle.getAttribute("aria-valuemax")).toBe("80"); expect(handle.getAttribute("aria-orientation")).toBe("vertical"); expect(dom.querySelectorAll(".wrn-splitter__pane")).toHaveLength(2); }); test("layout splitter clamps an out-of-range size and flips orientation", async () => { const source = readFileSync(uiComponentPath("LayoutSplitter"), "utf8"); const dom = mountHtml( await renderComponent(source, { orientation: "vertical", size: 95, minSize: 25 }), ); const handle = dom.querySelector("[data-wrn-splitter-handle]") as HTMLElement; // 95 is past the 75 bound implied by minSize, so it clamps rather than // leaving one pane unrecoverable. expect(handle.getAttribute("aria-valuenow")).toBe("75"); // A vertical splitter stacks, so its separator is a horizontal bar. expect(handle.getAttribute("aria-orientation")).toBe("horizontal"); }); test("custom scrollbar styles the scrollbar and drops its fake output", async () => { const source = readFileSync(uiComponentPath("CustomScrollbar"), "utf8"); /* * It declared a scroll output it never emitted, and its props were columns, * gap and maxWidth copied from a grid scaffold. A caller can listen for a * plain scroll event on the element, so the output is gone rather than left * unimplemented. */ expect(source).not.toContain("outputs {"); // Matched as a declaration: the word still appears in the comment recording // why those props were wrong. expect(source).not.toMatch(/^\s*columns:/m); expect(source).not.toMatch(/^\s*gap:/m); expect(source).toContain("scrollbar-color"); expect(source).toContain("::-webkit-scrollbar"); const html = await renderComponent(source, { axis: "vertical", thickness: 10, maxHeight: "18rem", }); const dom = mountHtml(html); const root = dom.querySelector(".wrn-scrollbar") as HTMLElement; expect(root.getAttribute("data-axis")).toBe("vertical"); const style = root.getAttribute("style") || ""; expect(style).toContain("--scrollbar-thickness"); expect(style).toContain("18rem"); }); test("page header collapses compiled empty slot markers", () => { const source = readFileSync(uiComponentPath("PageHeader"), "utf8"); expect(source).toContain(".wrn-page-header__meta:not(:has(> :not(template)))"); expect(source).toContain(".wrn-page-header__body:not(:has(> :not(template)))"); expect(source).toContain(".wrn-page-header__actions--slot-only:not(:has(> :not(template)))"); }); test("layout components ship their own styles instead of Tailwind utilities", () => { /* * These were built from utility classes and class: conditionals. That works * only where Tailwind is present, and a variant cost a dozen lines of * conditionals. They now carry wrn-* classes, a local style block, and * data attributes the style block selects on. */ const migrated = [ "Container", "Columns", "Grid", "Divider", "Image", "Link", "Typography", "Kbd", "LayoutSplitter", "CustomScrollbar", "Section", "SectionHeader", "PublicPageShell", "PageHeader", ]; const utility = /class:(grid-cols-|sm:|lg:|md:|max-w-|gap-[0-9]|px-[0-9]|py-[0-9]|border-l|bg-\[|h-0|text-xs|items-)/; for (const name of migrated) { const source = readFileSync(uiComponentPath(name), "utf8"); expect({ name, hasStyleBlock: /^\s{2}style \{/m.test(source) }).toEqual({ name, hasStyleBlock: true, }); expect({ name, usesUtilityConditionals: utility.test(source) }).toEqual({ name, usesUtilityConditionals: false, }); expect({ name, hasWrnClass: /class='wrn-[a-z-]+/.test(source) }).toEqual({ name, hasWrnClass: true, }); } }); test("remaining utility-styled components use local BEM styles", () => { const migrated = ["List", "InputNumber", "Marquee", "TextLink", "Map", "SearchBox", "Timeline"]; const utilityMarkup = /class(?::[^=\s]+)?=['"][^'"]*\b(?:flex|grid|w-full|items-center|justify-between|gap-[0-9]|p[xytrbl]?-[0-9]|m[xytrbl]?-[0-9]|text-(?:xs|sm|lg|xl)|rounded-(?:md|lg|xl|2xl|full)|border-[lrtbxy]?|bg-\[|shadow-(?:sm|lg)|size-[0-9])\b/; for (const name of migrated) { const source = readFileSync(uiComponentPath(name), "utf8"); const markup = source.split(/^\s{2,4}style \{/m)[0] ?? source; expect({ name, hasStyleBlock: /^\s{2,4}style \{/m.test(source) }).toEqual({ name, hasStyleBlock: true, }); expect({ name, utilityMarkup: utilityMarkup.test(markup) }).toEqual({ name, utilityMarkup: false, }); } }); test("every wrn color token a component references is defined by the theme", () => { /* * Ten tokens were referenced by components and defined by nothing: * --wrn-color-focus, --wrn-color-surface-soft, --wrn-color-on-danger and * the input-* family. An undefined custom property does not warn, it simply * resolves to nothing, so focus rings drew with no colour and soft surfaces * rendered transparent. * * Checked against the rendered theme CSS rather than the source: most of * these tokens are derived per palette, so they never appear as literals. */ const css = renderThemeCss(resolveThemeConfig()); const defined = new Set( [...css.matchAll(/(--wrn-color-[a-z0-9-]+)\s*:/g)].map((match) => match[1]!), ); const missing = new Map(); 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.(). 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([]); });