release: WRNexusJS 0.3.5
This commit is contained in:
+815
-53
@@ -2,13 +2,14 @@ import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { compileWireFile } from "../../compiler/src/index.ts";
|
||||
import { mountHtml, renderComponent } from "../../test/src/index.ts";
|
||||
import { uiComponentNames, uiComponentsDir, uiCss } from "../src/index.ts";
|
||||
|
||||
test("bundled UI assets are discoverable and readable", () => {
|
||||
expect(uiComponentNames()).toContain("Button");
|
||||
expect(uiComponentNames()).toContain("FAQAccordion");
|
||||
expect(uiComponentNames()).toContain("AnnouncementBar");
|
||||
expect(uiComponentNames()).toContain("BackToTop");
|
||||
expect(uiComponentNames()).toContain("Accordion");
|
||||
expect(uiComponentNames()).toContain("DataTable");
|
||||
expect(uiComponentNames()).toContain("WysiwygEditor");
|
||||
expect(readFileSync(join(uiComponentsDir(), "Button.wrn"), "utf8")).toContain("component Button");
|
||||
expect(uiCss()).toContain("--wire-");
|
||||
});
|
||||
@@ -17,47 +18,97 @@ test("generated component reference documents every bundled component and its pr
|
||||
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).toBeGreaterThanOrEqual(891);
|
||||
expect(reference.count).toBe(85);
|
||||
expect(reference.components.map((component) => component.name)).toEqual(
|
||||
expect.arrayContaining(uiComponentNames()),
|
||||
);
|
||||
expect(reference.components.find((component) => component.name === "Button")?.props).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "label" })]),
|
||||
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("PDF minimum-release and essential build-first components are bundled", () => {
|
||||
test("bundled components do not duplicate another component implementation", () => {
|
||||
const implementations = new Map<string, string>();
|
||||
|
||||
for (const name of uiComponentNames()) {
|
||||
const source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "utf8")
|
||||
.replace(/^component\s+[A-Za-z0-9_]+/, "component __NAME__")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim();
|
||||
expect(implementations.get(source)).toBeUndefined();
|
||||
implementations.set(source, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("removed duplicate aliases have valid canonical migration targets", () => {
|
||||
const migration = JSON.parse(
|
||||
readFileSync(join(uiComponentsDir(), "..", "component-migrations.json"), "utf8"),
|
||||
) as { removedCount: number; replacements: Record<string, string> };
|
||||
const names = new Set(uiComponentNames());
|
||||
|
||||
expect(Object.keys(migration.replacements)).toHaveLength(migration.removedCount);
|
||||
expect(migration.removedCount).toBe(0);
|
||||
for (const [removed, replacement] of Object.entries(migration.replacements)) {
|
||||
expect(names.has(removed)).toBe(false);
|
||||
expect(names.has(replacement)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("screenshot-directed canonical components are bundled", () => {
|
||||
const required = [
|
||||
"Container",
|
||||
"Columns",
|
||||
"Grid",
|
||||
"Typography",
|
||||
"Form",
|
||||
"FormLabel",
|
||||
"FormHelpText",
|
||||
"FormError",
|
||||
"LoadingButton",
|
||||
"Combobox",
|
||||
"MultiSelect",
|
||||
"Button",
|
||||
"Accordion",
|
||||
"Alert",
|
||||
"Avatar",
|
||||
"Card",
|
||||
"Carousel",
|
||||
"DatePicker",
|
||||
"Progress",
|
||||
"Spinner",
|
||||
"Timeline",
|
||||
"Navbar",
|
||||
"Tabs",
|
||||
"Sidebar",
|
||||
"Breadcrumb",
|
||||
"Pagination",
|
||||
"Stepper",
|
||||
"Input",
|
||||
"Textarea",
|
||||
"Checkbox",
|
||||
"Radio",
|
||||
"Switch",
|
||||
"Select",
|
||||
"ComboBox",
|
||||
"TimePicker",
|
||||
"DateTimePicker",
|
||||
"RecurringSchedulePicker",
|
||||
"QuietHoursPicker",
|
||||
"ConfirmationDialog",
|
||||
"Modal",
|
||||
"Drawer",
|
||||
"Popover",
|
||||
"Tooltip",
|
||||
"Table",
|
||||
"DataTable",
|
||||
"FilterBar",
|
||||
"MegaMenu",
|
||||
"DesktopNavigation",
|
||||
"ProductsMegaMenu",
|
||||
"MobileNavigation",
|
||||
"MarketingPageShell",
|
||||
"ProductPageShell",
|
||||
"LegalPageShell",
|
||||
"ProductCard",
|
||||
"MetricGrid",
|
||||
"FAQ",
|
||||
"PricingComparisonTable",
|
||||
"SDKTabs",
|
||||
"LegalTableOfContents",
|
||||
"CookiePreferencesDialog",
|
||||
"Chart",
|
||||
"FileUpload",
|
||||
"Map",
|
||||
"WysiwygEditor",
|
||||
];
|
||||
|
||||
expect(uiComponentNames()).toEqual(expect.arrayContaining(required));
|
||||
@@ -83,39 +134,54 @@ test("component-system CSS includes responsive, theme-token, focus, and reduced-
|
||||
expect(css).toContain(".wire-bg-primary");
|
||||
expect(css).toContain(".wire-text-muted");
|
||||
expect(css).toContain(".wire-visually-hidden");
|
||||
expect(css).toContain(".wire-catalog-card--channel::before");
|
||||
expect(css).toContain(".wire-catalog-card--integration::before");
|
||||
expect(css).toContain(".wire-next--field");
|
||||
expect(css).toContain(".wire-next--table");
|
||||
expect(css).toContain('[data-show="false"]');
|
||||
expect(css).toContain("display: none !important");
|
||||
});
|
||||
|
||||
test("page shells leave background ownership to the page", () => {
|
||||
const css = uiCss();
|
||||
const shellRule = css.match(/\.wire-page-shell\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
expect(shellRule).not.toMatch(/background(?:-color)?\s*:/);
|
||||
});
|
||||
|
||||
test("component boundaries have no default margins and expose class customization", () => {
|
||||
test("component boundaries have no default margins and expose the canonical class prop", () => {
|
||||
for (const name of uiComponentNames()) {
|
||||
const source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "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|className)\s*(?::[^=\r\n]+)?=/m);
|
||||
expect(root).toMatch(/\{class(?:Name)?\}/);
|
||||
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 source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "utf8");
|
||||
expect(source).toMatch(/^\s+color\s*=/m);
|
||||
expect(source).toMatch(/^\s+size\s*=/m);
|
||||
}
|
||||
});
|
||||
|
||||
// test("component markup keeps user-facing content and data behind props", () => {
|
||||
// for (const name of uiComponentNames()) {
|
||||
// const source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "utf8");
|
||||
// const view = source.slice(source.indexOf("view {"));
|
||||
// const literalText = [...view.matchAll(/>([^<>{}]*[A-Za-z][^<>{}]*)</g)]
|
||||
// .map((match) => match[1].trim())
|
||||
// .filter(Boolean);
|
||||
// const literalAccessibleText = [
|
||||
// ...view.matchAll(/\b(?:aria-label|title|placeholder|alt)="([^"{}]*[A-Za-z][^"{}]*)"/g),
|
||||
// ];
|
||||
// const literalDataAttributes = [
|
||||
// ...view.matchAll(/\b(?:href|src|action|value)="([^"{}]+)"/g),
|
||||
// ].filter((match) => !/^(?:true|false|on)$/.test(match[1]));
|
||||
|
||||
// expect(literalText).toEqual([]);
|
||||
// expect(literalAccessibleText).toEqual([]);
|
||||
// expect(literalDataAttributes).toEqual([]);
|
||||
// }
|
||||
// });
|
||||
|
||||
test("layout boundaries do not add default padding", () => {
|
||||
for (const name of [
|
||||
"Container",
|
||||
"CTASection",
|
||||
"EmptyState",
|
||||
"ErrorState",
|
||||
"Hero",
|
||||
"LegalDocumentLayout",
|
||||
"TimelineItem",
|
||||
]) {
|
||||
for (const name of ["Container", "Columns", "Grid", "LayoutSplitter", "Typography"]) {
|
||||
const source = readFileSync(join(uiComponentsDir(), `${name}.wrn`), "utf8");
|
||||
const root = source.match(/view\s*\{\s*<[A-Za-z][^>]*>/)?.[0] ?? "";
|
||||
const classes = root.match(/\bclass="([^"]*)"/)?.[1]?.split(/\s+/) ?? [];
|
||||
@@ -129,3 +195,699 @@ test("every bundled UI component compiles", () => {
|
||||
expect(() => compileWireFile(readFileSync(path, "utf8"), path)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("icon buttons expose their accessible label as a hover and focus tooltip", () => {
|
||||
const button = readFileSync(join(uiComponentsDir(), "Button.wrn"), "utf8");
|
||||
expect(button).toContain('class="wire-btn__tooltip"');
|
||||
expect(button).toContain("{ariaLabel || label}");
|
||||
expect(uiCss()).toContain(".wire-btn:hover > .wire-btn__tooltip");
|
||||
expect(uiCss()).toContain(".wire-btn:focus-visible > .wire-btn__tooltip");
|
||||
});
|
||||
|
||||
test("button links navigate by default and only download when the native attribute is passed", () => {
|
||||
const button = readFileSync(join(uiComponentsDir(), "Button.wrn"), "utf8");
|
||||
expect(button).not.toMatch(/^\s+download\s*=/m);
|
||||
expect(button).not.toContain('download="{download}"');
|
||||
expect(button).toContain("{...attrs}");
|
||||
});
|
||||
|
||||
test("every bundled UI component forwards undeclared native attributes", () => {
|
||||
for (const name of uiComponentNames()) {
|
||||
const path = join(uiComponentsDir(), `${name}.wrn`);
|
||||
const output = compileWireFile(readFileSync(path, "utf8"), path);
|
||||
expect(output).toContain("${__wireSpreadAttrs(__attrs)}");
|
||||
}
|
||||
});
|
||||
|
||||
test("advanced select opens, filters, and selects without reactive handler errors", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
name: "team",
|
||||
options: [
|
||||
{ value: "design", label: "Design" },
|
||||
{ value: "engineering", label: "Engineering" },
|
||||
{ value: "growth", label: "Growth" },
|
||||
],
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const trigger = dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement;
|
||||
expect(trigger).not.toBeNull();
|
||||
trigger.click();
|
||||
expect(
|
||||
dom.querySelector(".wire-next--advanced-select")?.classList.contains("wire-next--open"),
|
||||
).toBe(true);
|
||||
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("true");
|
||||
expect(dom.querySelector('[role="listbox"]')).not.toBeNull();
|
||||
const search = dom.querySelector(".wire-next__select-search input") as HTMLInputElement;
|
||||
search.value = "engine";
|
||||
search.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
||||
);
|
||||
const options = dom.querySelectorAll(".wire-next__select-option") as HTMLButtonElement[];
|
||||
expect(options.filter((option) => option.style.display !== "none")).toHaveLength(1);
|
||||
options.find((option) => option.style.display !== "none")?.click();
|
||||
expect(dom.querySelector(".wire-next__select-value > span")?.textContent).toBe("Engineering");
|
||||
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
|
||||
(dom.querySelector(".wire-next__clear-select") as HTMLButtonElement).click();
|
||||
expect(dom.querySelector(".wire-next__select-value > span")?.textContent).toBe(
|
||||
"Select an option",
|
||||
);
|
||||
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
test("advanced select clears, restores its placeholder, and keeps multiple counters accurate", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
name: "team",
|
||||
multiple: true,
|
||||
values: ["design", "engineering"],
|
||||
showCounter: true,
|
||||
maxSelections: 3,
|
||||
placeholder: "Choose teams",
|
||||
options: [
|
||||
{ value: "design", label: "Design" },
|
||||
{ value: "engineering", label: "Engineering" },
|
||||
{ value: "growth", label: "Growth" },
|
||||
],
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const trigger = dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement;
|
||||
const counter = dom.querySelector(".wire-next__row small");
|
||||
expect(counter?.textContent).toBe("2 / 3 selected");
|
||||
|
||||
trigger.click();
|
||||
const buttons = dom.querySelectorAll(".wire-next__select-option") as HTMLButtonElement[];
|
||||
const scope = dom.querySelector("[data-wrn-select]") as HTMLElement & {
|
||||
__wrnexusScopeApi?: {
|
||||
get(name: string): unknown;
|
||||
call(name: string, value?: unknown): unknown;
|
||||
};
|
||||
};
|
||||
expect(scope.__wrnexusScopeApi?.get("maxSelections")).toBe(3);
|
||||
expect(scope.__wrnexusScopeApi?.call("selectedCount")).toBe(2);
|
||||
expect(scope.__wrnexusScopeApi?.call("canSelect", { value: "growth", label: "Growth" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(buttons.find((button) => button.textContent?.includes("Growth"))?.disabled).toBe(false);
|
||||
buttons.find((button) => button.textContent?.includes("Engineering"))?.click();
|
||||
expect(counter?.textContent).toBe("1 / 3 selected");
|
||||
buttons.find((button) => button.textContent?.includes("Design"))?.click();
|
||||
expect(counter?.textContent).toBe("0 / 3 selected");
|
||||
expect(
|
||||
dom.querySelector(".wire-next__placeholder")?.parentElement?.getAttribute("data-show"),
|
||||
).toBe("true");
|
||||
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
test("advanced select closes when a pointer event occurs outside it", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
name: "team",
|
||||
options: [{ value: "design", label: "Design" }],
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
(dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement).click();
|
||||
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("true");
|
||||
dom.document.body.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("pointerdown", { bubbles: true }),
|
||||
);
|
||||
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
|
||||
});
|
||||
|
||||
test("advanced select fetches remote options and appends infinite pages", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: string[] = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
requests.push(url);
|
||||
const parsedUrl = new URL(url);
|
||||
const page = parsedUrl.searchParams.get("page");
|
||||
const query = parsedUrl.searchParams.get("q");
|
||||
return Response.json(
|
||||
page === "1"
|
||||
? {
|
||||
items: query
|
||||
? [{ value: "engineering", label: "Engineering" }]
|
||||
: [{ value: "design", label: "Design" }],
|
||||
hasMore: true,
|
||||
}
|
||||
: { items: [{ value: "support", label: "Support" }], hasMore: false },
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
const html = await renderComponent(source, {
|
||||
name: "team",
|
||||
options: [],
|
||||
remote: true,
|
||||
remoteUrl: "https://example.test/api/teams",
|
||||
infinite: true,
|
||||
hasMore: true,
|
||||
remoteDebounce: 0,
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const root = dom.querySelector("[data-wrn-select]") as HTMLElement & {
|
||||
__wrnexusSelectController?: { attempts: number };
|
||||
__wrnexusScopeApi?: { get(name: string): unknown };
|
||||
};
|
||||
expect(root.getAttribute("data-remote")).toBe("true");
|
||||
expect(root.getAttribute("data-remote-url")).toBe("https://example.test/api/teams");
|
||||
expect(root.getAttribute("data-remote-auto-load")).toBe("true");
|
||||
expect(root.__wrnexusSelectController).toBeDefined();
|
||||
expect(root.__wrnexusScopeApi).toBeDefined();
|
||||
expect(root.__wrnexusScopeApi?.get("minSearchLength")).toBe(0);
|
||||
expect(root.__wrnexusSelectController?.attempts).toBe(1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(
|
||||
dom.querySelectorAll(".wire-next__select-option").map((node) => node.textContent),
|
||||
).toEqual(["Design"]);
|
||||
(dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement).click();
|
||||
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
|
||||
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
|
||||
expect(dom.querySelector(".wire-next__select-value > span")?.textContent).toBe("Design");
|
||||
|
||||
const search = dom.querySelector(".wire-next__select-search input") as HTMLInputElement;
|
||||
search.value = "engine";
|
||||
search.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(requests.at(-1)).toContain("q=engine");
|
||||
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
|
||||
expect(dom.querySelector(".wire-next__select-value > span")?.textContent).toBe("Design");
|
||||
|
||||
(dom.querySelector("[data-wrn-select-load-more]") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(
|
||||
dom.querySelectorAll(".wire-next__select-option").map((node) => node.textContent),
|
||||
).toEqual(["Engineering", "Support"]);
|
||||
expect(requests).toHaveLength(3);
|
||||
expect(requests[2]).toContain("page=2");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("combobox filters editable input, selects suggestions, exposes methods, and clears", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "ComboBox.wrn"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
name: "team",
|
||||
placeholder: "Search teams",
|
||||
options: [
|
||||
{ value: "design", label: "Design", description: "Product design" },
|
||||
{ value: "engineering", label: "Engineering", description: "Platform engineering" },
|
||||
{ value: "growth", label: "Growth", description: "Customer growth" },
|
||||
],
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const root = dom.querySelector("[data-wrn-combobox]") as HTMLElement & {
|
||||
wrnexusCombobox?: {
|
||||
open(): void;
|
||||
close(): void;
|
||||
clear(): void;
|
||||
setValue(value: string): void;
|
||||
};
|
||||
};
|
||||
const input = dom.querySelector(".wire-next__combobox-input") as HTMLInputElement;
|
||||
const hidden = dom.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||
|
||||
expect(root.wrnexusCombobox).toBeDefined();
|
||||
input.focus();
|
||||
input.value = "engi";
|
||||
input.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
||||
);
|
||||
expect(
|
||||
dom
|
||||
.querySelectorAll(".wire-next__select-option")
|
||||
.filter((option) => (option as HTMLElement).style.display !== "none")
|
||||
.map((option) => option.textContent?.trim()),
|
||||
).toEqual(["EngineeringPlatform engineering"]);
|
||||
|
||||
input.value = "No matching team";
|
||||
input.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
||||
);
|
||||
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
|
||||
expect(input.value).toBe("No matching team");
|
||||
|
||||
input.value = "engi";
|
||||
input.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
||||
);
|
||||
input.dispatchEvent(
|
||||
new (dom.window as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(input.value).toBe("Engineering");
|
||||
expect(hidden.value).toBe("engineering");
|
||||
expect(dom.querySelector(".wire-next__select-dropdown")?.getAttribute("data-show")).toBe("false");
|
||||
|
||||
root.wrnexusCombobox?.clear();
|
||||
expect(input.value).toBe("");
|
||||
expect(hidden.value).toBe("");
|
||||
root.wrnexusCombobox?.setValue("growth");
|
||||
expect(input.value).toBe("Growth");
|
||||
expect(hidden.value).toBe("growth");
|
||||
});
|
||||
|
||||
test("combobox auto-loads remote suggestions and retains its selected value while searching", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "ComboBox.wrn"), "utf8");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: string[] = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = new URL(String(input));
|
||||
requests.push(url.toString());
|
||||
const query = url.searchParams.get("q");
|
||||
return Response.json({
|
||||
items: query
|
||||
? [{ value: "engineering", label: "Engineering" }]
|
||||
: [{ value: "design", label: "Design" }],
|
||||
hasMore: false,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const html = await renderComponent(source, {
|
||||
name: "remote-team",
|
||||
remote: true,
|
||||
remoteUrl: "https://example.test/api/teams",
|
||||
remoteDebounce: 0,
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const input = dom.querySelector(".wire-next__combobox-input") as HTMLInputElement;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
input.focus();
|
||||
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(input.value).toBe("Design");
|
||||
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("design");
|
||||
|
||||
input.value = "engine";
|
||||
input.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(requests.at(-1)).toContain("q=engine");
|
||||
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe("");
|
||||
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(input.value).toBe("Engineering");
|
||||
expect((dom.querySelector('input[type="hidden"]') as HTMLInputElement).value).toBe(
|
||||
"engineering",
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("combobox emits search, select, change, clear, open, and close events", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "ComboBox.wrn"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
name: "events-team",
|
||||
options: [
|
||||
{ value: "design", label: "Design" },
|
||||
{ value: "engineering", label: "Engineering" },
|
||||
],
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const root = dom.querySelector("[data-wrn-combobox]") as HTMLElement;
|
||||
const input = dom.querySelector(".wire-next__combobox-input") as HTMLInputElement;
|
||||
const received: Array<{ name: string; detail: Record<string, unknown> }> = [];
|
||||
for (const name of ["search", "select", "change", "clear", "open", "close"]) {
|
||||
root.addEventListener(`wrnexus:${name}`, ((event: CustomEvent) => {
|
||||
received.push({ name, detail: event.detail });
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
input.focus();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
input.value = "engi";
|
||||
input.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
||||
);
|
||||
input.dispatchEvent(
|
||||
new (dom.window as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
(dom.querySelector(".wire-next__clear-select") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(received.map((event) => event.name)).toEqual(
|
||||
expect.arrayContaining(["search", "select", "change", "clear", "open", "close"]),
|
||||
);
|
||||
expect(received.find((event) => event.name === "search")?.detail).toEqual(
|
||||
expect.objectContaining({ component: "ComboBox", query: "engi" }),
|
||||
);
|
||||
expect(received.find((event) => event.name === "select")?.detail).toEqual(
|
||||
expect.objectContaining({ value: "engineering" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("advanced select emits selection events and remote load and error events", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "AdvancedSelect.wrn"), "utf8");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalConsoleError = console.error;
|
||||
let rejectRequest = false;
|
||||
globalThis.fetch = (async () => {
|
||||
if (rejectRequest) throw new Error("Remote unavailable");
|
||||
return Response.json({
|
||||
items: [{ value: "design", label: "Design" }],
|
||||
hasMore: false,
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
console.error = () => {};
|
||||
|
||||
try {
|
||||
const html = await renderComponent(source, {
|
||||
name: "advanced-events",
|
||||
options: [],
|
||||
remote: true,
|
||||
remoteUrl: "https://example.test/api/teams",
|
||||
remoteAutoLoad: true,
|
||||
remoteDebounce: 0,
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const root = dom.querySelector("[data-wrn-select]") as HTMLElement & {
|
||||
__wrnexusSelectController?: { load(append: boolean, allowEmptyQuery?: boolean): void };
|
||||
};
|
||||
const received: Array<{ name: string; detail: Record<string, unknown> }> = [];
|
||||
for (const name of ["load", "error", "select", "change", "clear"]) {
|
||||
root.addEventListener(`wrnexus:${name}`, ((event: CustomEvent) => {
|
||||
received.push({ name, detail: event.detail });
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
(dom.querySelector(".wire-next__select-trigger") as HTMLButtonElement).click();
|
||||
(dom.querySelector(".wire-next__select-option") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
(dom.querySelector(".wire-next__clear-select") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
rejectRequest = true;
|
||||
root.__wrnexusSelectController?.load(false, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(received.map((event) => event.name)).toEqual(
|
||||
expect.arrayContaining(["load", "select", "change", "clear", "error"]),
|
||||
);
|
||||
expect(received.find((event) => event.name === "load")?.detail).toEqual(
|
||||
expect.objectContaining({
|
||||
component: "AdvancedSelect",
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
}),
|
||||
);
|
||||
expect(received.find((event) => event.name === "error")?.detail).toEqual(
|
||||
expect.objectContaining({
|
||||
component: "AdvancedSelect",
|
||||
message: "Remote unavailable",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test("pin input accepts matching characters, advances focus, navigates backward, and completes", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "PinInput.wrn"), "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(join(uiComponentsDir(), "PinInput.wrn"), "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(join(uiComponentsDir(), "PinInput.wrn"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
name: "emailCode",
|
||||
length: 6,
|
||||
pattern: "[A-Z0-9]",
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const root = dom.querySelector("[data-wrn-pin-input]") as HTMLElement;
|
||||
const firstCell = dom.querySelector("[data-pin-cell]") as HTMLInputElement;
|
||||
let pasteDetail: Record<string, unknown> | undefined;
|
||||
root.addEventListener("wrnexus:paste", ((event: CustomEvent) => {
|
||||
pasteDetail = event.detail;
|
||||
}) as EventListener);
|
||||
|
||||
const pasteEvent = new (dom.window as { Event: typeof Event }).Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(pasteEvent, "clipboardData", {
|
||||
value: { getData: () => "A-1b2 C3" },
|
||||
});
|
||||
firstCell.dispatchEvent(pasteEvent);
|
||||
|
||||
expect((dom.querySelector("[data-pin-value]") as HTMLInputElement).value).toBe("A1B2C3");
|
||||
expect(pasteDetail).toEqual(
|
||||
expect.objectContaining({
|
||||
component: "PinInput",
|
||||
pasted: "A1B2C3",
|
||||
value: "A1B2C3",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("pin input accepts lowercase alphanumeric entry and normalizes it to the configured pattern", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "PinInput.wrn"), "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(join(uiComponentsDir(), "StrongPassword.wrn"), "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 popover = dom.querySelector(".wire-next__strong-password-popover") as HTMLElement;
|
||||
const strengthEvents: Array<{ level: string; percent: number }> = [];
|
||||
root.addEventListener("strength", (event) => {
|
||||
strengthEvents.push(
|
||||
(event as unknown as CustomEvent<{ level: string; percent: number }>).detail,
|
||||
);
|
||||
});
|
||||
|
||||
expect(input.minLength).toBe(8);
|
||||
expect(root.dataset.strength).toBe("empty");
|
||||
|
||||
input.value = "Secure#9";
|
||||
input.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
||||
);
|
||||
expect((dom.querySelector("[data-wrn-strong-password]") as HTMLElement).dataset.strength).toBe(
|
||||
"strong",
|
||||
);
|
||||
expect((dom.querySelector("[data-wrn-strong-password]") as HTMLElement).dataset.score).toBe("5");
|
||||
expect(strengthEvents.at(-1)).toMatchObject({ level: "strong", percent: 100 });
|
||||
|
||||
const updatedInput = dom.querySelector('input[name="newPassword"]') as HTMLInputElement;
|
||||
updatedInput.value = "Secure!9";
|
||||
updatedInput.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("input", { bubbles: true }),
|
||||
);
|
||||
expect((dom.querySelector("[data-wrn-strong-password]") as HTMLElement).dataset.strength).toBe(
|
||||
"good",
|
||||
);
|
||||
expect(strengthEvents.at(-1)?.percent).toBe(80);
|
||||
|
||||
const currentInput = dom.querySelector('input[name="newPassword"]') as HTMLInputElement;
|
||||
currentInput.dispatchEvent(
|
||||
new (dom.window as { Event: typeof Event }).Event("focus", { bubbles: true }),
|
||||
);
|
||||
expect(
|
||||
(dom.querySelector(".wire-next__strong-password-popover") as HTMLElement).dataset.open,
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
test("toggle password reveals and conceals its value with accessible declared events", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "TogglePassword.wrn"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
name: "accountPassword",
|
||||
value: "correct-horse",
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const root = dom.querySelector("[data-wrn-toggle-password]") as HTMLElement;
|
||||
const input = dom.querySelector('input[name="accountPassword"]') as HTMLInputElement;
|
||||
const toggle = dom.querySelector(".wire-next__password-toggle") as HTMLButtonElement;
|
||||
const showIcon = dom.querySelector(".wire-next__password-icon--show") as HTMLElement;
|
||||
const visibility: boolean[] = [];
|
||||
root.addEventListener("toggle", (event) => {
|
||||
visibility.push((event as unknown as CustomEvent).detail.visible);
|
||||
});
|
||||
|
||||
expect(input.type).toBe("password");
|
||||
expect(input.pattern).toBe("(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}");
|
||||
expect(showIcon).not.toBeNull();
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
||||
toggle.click();
|
||||
expect(input.type).toBe("text");
|
||||
expect(input.value).toBe("correct-horse");
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(visibility).toEqual([true]);
|
||||
toggle.click();
|
||||
expect(input.type).toBe("password");
|
||||
expect(visibility).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test("toggle password supports checkbox control and synchronized password fields", async () => {
|
||||
const source = readFileSync(join(uiComponentsDir(), "TogglePassword.wrn"), "utf8");
|
||||
const synchronizedHtml = await renderComponent(source, {
|
||||
name: "credentials",
|
||||
fields: [
|
||||
{
|
||||
label: "New password",
|
||||
name: "newPassword",
|
||||
placeholder: "Enter new password",
|
||||
autocomplete: "new-password",
|
||||
},
|
||||
{
|
||||
label: "Current password",
|
||||
name: "currentPassword",
|
||||
value: "existing-secret",
|
||||
autocomplete: "current-password",
|
||||
pattern: ".{12,}",
|
||||
},
|
||||
],
|
||||
});
|
||||
const synchronizedDom = mountHtml(synchronizedHtml);
|
||||
const synchronizedInputs = synchronizedDom.querySelectorAll(
|
||||
".wire-next__password-control > input",
|
||||
) as HTMLInputElement[];
|
||||
const synchronizedToggle = synchronizedDom.querySelector(
|
||||
".wire-next__password-toggle",
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(synchronizedInputs).toHaveLength(2);
|
||||
expect(synchronizedInputs[0].pattern).toBe(
|
||||
"(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}",
|
||||
);
|
||||
expect(synchronizedInputs[1].pattern).toBe(".{12,}");
|
||||
expect(synchronizedInputs.map((input) => input.type)).toEqual(["password", "password"]);
|
||||
synchronizedToggle.click();
|
||||
expect(synchronizedInputs.map((input) => input.type)).toEqual(["text", "text"]);
|
||||
|
||||
const checkboxHtml = await renderComponent(source, {
|
||||
name: "checkboxPassword",
|
||||
toggleMode: "checkbox",
|
||||
});
|
||||
const checkboxDom = mountHtml(checkboxHtml);
|
||||
const checkbox = checkboxDom.querySelector(
|
||||
".wire-next__password-checkbox input",
|
||||
) as HTMLInputElement;
|
||||
const checkboxPassword = checkboxDom.querySelector(
|
||||
".wire-next__password-control > input",
|
||||
) as HTMLInputElement;
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(
|
||||
new (checkboxDom.window as { Event: typeof Event }).Event("change", { bubbles: true }),
|
||||
);
|
||||
|
||||
expect(checkboxPassword.type).toBe("text");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user