Visual-only redesign ("instrument panel"): cool graphite neutrals for light
and dark, layered hairline elevation, larger radii, a mono token for
eyebrows/data, keycap-style solid and secondary buttons, recessed input
wells and tab tracks, a stronger dialog, and a theme-driven focus outline.
No props, outputs or behaviour change.
Also records the already-published csr 0.8.42, styles 0.8.25 and ui 0.8.85
changes (optional chaining/nullish in the reactive runtime, shared
theme/accent cookie sync, BackToTop hidden attribute).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
9.8 KiB
TypeScript
249 lines
9.8 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { readFileSync } from "node:fs";
|
|
import { renderComponent, mountHtml } from "../../test/src/index.ts";
|
|
import { uiComponentPath } from "../src/registry.ts";
|
|
import { parse } from "../../compiler/src/index.ts";
|
|
import { Window } from "happy-dom";
|
|
|
|
const source = (name: string) => readFileSync(uiComponentPath(name), "utf8");
|
|
|
|
describe("account shell UI regressions", () => {
|
|
test("BackToTop is hidden in server HTML unless explicitly always visible", async () => {
|
|
for (const alwaysVisible of [false, true]) {
|
|
const html = await renderComponent(source("BackToTop"), { alwaysVisible });
|
|
const server = new Window();
|
|
try {
|
|
server.document.body.innerHTML = html;
|
|
expect(server.document.querySelector(".wrn-back-to-top")?.hasAttribute("hidden")).toBe(
|
|
!alwaysVisible,
|
|
);
|
|
} finally {
|
|
await server.happyDOM.close();
|
|
}
|
|
}
|
|
});
|
|
test("Drawer has a self-contained close icon and a compact centered mobile header", async () => {
|
|
const dom = mountHtml(
|
|
await renderComponent(source("Drawer"), { title: "Account center", open: true, duration: 0 }),
|
|
);
|
|
const close = dom.querySelector(".wrn-drawer__close") as HTMLButtonElement;
|
|
expect(close.getAttribute("aria-label")).toBe("Close drawer");
|
|
expect(close.querySelector('svg[aria-hidden="true"] path')).not.toBeNull();
|
|
expect(close.querySelector('[class*="icon-"]')).toBeNull();
|
|
expect(source("Drawer")).toMatch(/\.wrn-drawer__header\s*\{[^}]*align-items: center;/);
|
|
expect(source("Drawer")).toContain("padding-block: 0.75rem;");
|
|
let closed = false;
|
|
dom.querySelector('[data-ui-component="Drawer"]')?.addEventListener("wrnexus:close", () => {
|
|
closed = true;
|
|
});
|
|
close.click();
|
|
expect(closed).toBe(true);
|
|
});
|
|
test("Dropdown allows anchored mobile menus without changing existing sheet defaults", async () => {
|
|
for (const mobilePanel of [undefined, "dropdown", "sheet"]) {
|
|
const dom = mountHtml(
|
|
await renderComponent(source("Dropdown"), {
|
|
label: "User",
|
|
placement: "bottom-end",
|
|
...(mobilePanel ? { mobilePanel } : {}),
|
|
}),
|
|
);
|
|
expect(dom.querySelector(".wrn-dropdown")?.getAttribute("data-mobile-panel")).toBe(
|
|
mobilePanel ?? "sheet",
|
|
);
|
|
(dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement).click();
|
|
expect(dom.querySelector(".wrn-dropdown__panel")?.hasAttribute("hidden")).toBe(false);
|
|
}
|
|
expect(source("Dropdown")).not.toContain(
|
|
".wrn-dropdown[data-placement] .wrn-dropdown__panel {",
|
|
);
|
|
});
|
|
test("Dropdown is natively hidden before CSS or hydration and still toggles afterward", async () => {
|
|
for (const props of [{}, { open: true }, { defaultOpen: true }]) {
|
|
const html = await renderComponent(source("Dropdown"), { label: "User", ...props });
|
|
const server = new Window();
|
|
try {
|
|
server.document.body.innerHTML = html;
|
|
const closed = !(props.open || props.defaultOpen);
|
|
for (const selector of [".wrn-dropdown__panel", ".wrn-dropdown__dismiss-layer"]) {
|
|
expect(server.document.querySelector(selector)?.hasAttribute("hidden")).toBe(closed);
|
|
}
|
|
} finally {
|
|
await server.happyDOM.close();
|
|
}
|
|
}
|
|
const dom = mountHtml(await renderComponent(source("Dropdown"), { label: "User" }));
|
|
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
|
|
const panel = dom.querySelector(".wrn-dropdown__panel")!;
|
|
expect(panel.hasAttribute("hidden")).toBe(true);
|
|
trigger.click();
|
|
expect(panel.hasAttribute("hidden")).toBe(false);
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
trigger.click();
|
|
expect(panel.hasAttribute("hidden")).toBe(true);
|
|
});
|
|
|
|
test("Sidebar supplies navigation to its mobile Drawer without an app slot", async () => {
|
|
const dom = mountHtml(
|
|
await renderComponent(source("Sidebar"), {
|
|
items: [
|
|
{ heading: "Account", items: [{ label: "Profile", href: "/profile", value: "profile" }] },
|
|
],
|
|
active: "profile",
|
|
}),
|
|
);
|
|
const mobileNav = dom.querySelector(".wrn-sidebar__drawer-nav");
|
|
expect(mobileNav?.querySelector('a[href="/profile"]')?.getAttribute("aria-current")).toBe(
|
|
"page",
|
|
);
|
|
expect(dom.querySelector('.wrn-sidebar__rail a[href="/profile"]')).not.toBeNull();
|
|
});
|
|
|
|
test("Sidebar embedded in another drawer does not generate a second mobile drawer", async () => {
|
|
const dom = mountHtml(
|
|
await renderComponent(source("Sidebar"), {
|
|
mobile: false,
|
|
items: [{ label: "Profile", href: "/profile" }],
|
|
}),
|
|
);
|
|
expect(dom.querySelector('[data-component="Drawer"]')).toBeNull();
|
|
expect(dom.querySelector(".wrn-sidebar")?.getAttribute("data-mobile")).toBe("false");
|
|
});
|
|
|
|
test("Sidebar blocks disabled links at every nesting level", async () => {
|
|
const dom = mountHtml(
|
|
await renderComponent(source("Sidebar"), {
|
|
items: [
|
|
{
|
|
label: "Parent",
|
|
disabled: true,
|
|
href: "/parent",
|
|
items: [
|
|
{
|
|
label: "Child",
|
|
disabled: true,
|
|
href: "/child",
|
|
items: [{ label: "Leaf", disabled: true, href: "/leaf" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
for (const href of ["/parent", "/child", "/leaf"]) {
|
|
const link = dom.querySelector(`.wrn-sidebar__rail a[href="${href}"]`)!;
|
|
expect(link.getAttribute("aria-disabled")).toBe("true");
|
|
expect(link.getAttribute("tabindex")).toBe("-1");
|
|
const click = new (dom.window as { MouseEvent: typeof MouseEvent }).MouseEvent("click", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
link.dispatchEvent(click);
|
|
expect(click.defaultPrevented).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("Dropdown keyboard navigation skips disabled links and ArrowUp starts at the last item", async () => {
|
|
const dom = mountHtml(
|
|
await renderComponent(source("Dropdown"), {
|
|
label: "Account",
|
|
items: [
|
|
{ label: "Profile", href: "/profile" },
|
|
{ label: "Disabled", href: "/disabled", disabled: true },
|
|
{ label: "Security", href: "/security" },
|
|
],
|
|
}),
|
|
);
|
|
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
|
|
// Execute the component's real JS functions: the lightweight mount runtime
|
|
// intentionally does not interpret nested timer callbacks.
|
|
const functions = parse(source("Dropdown"))
|
|
.functions.join("\n")
|
|
.replace(/\b(client|shared) function/g, "function");
|
|
const handle = new Function(
|
|
"document",
|
|
"window",
|
|
"output",
|
|
`
|
|
let open = false, visible = false, disabled = false;
|
|
${functions}
|
|
return handleKeydown;
|
|
`,
|
|
)(dom.document, dom.window, {
|
|
open() {},
|
|
close() {},
|
|
toggle({ open }: { open: boolean }) {
|
|
trigger.setAttribute("aria-expanded", String(open));
|
|
},
|
|
});
|
|
const key = (_target: Element, value: string) =>
|
|
handle({
|
|
key: value,
|
|
currentTarget: dom.querySelector(".wrn-dropdown"),
|
|
preventDefault() {},
|
|
stopPropagation() {},
|
|
});
|
|
trigger.focus();
|
|
key(trigger, "ArrowUp");
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(dom.document.activeElement?.textContent).toContain("Security");
|
|
key(dom.document.activeElement!, "ArrowUp");
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(dom.document.activeElement?.textContent).toContain("Profile");
|
|
key(dom.document.activeElement!, "Escape");
|
|
expect(dom.document.activeElement).toBe(trigger);
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
});
|
|
|
|
test("Dropdown preserves falsy action values and has an explicit accessible trigger name", async () => {
|
|
const dom = mountHtml(
|
|
await renderComponent(source("Dropdown"), {
|
|
label: "",
|
|
ariaLabel: "Account menu",
|
|
items: [{ label: "Zero", value: 0, action: true }],
|
|
}),
|
|
);
|
|
let value: unknown = undefined;
|
|
dom.querySelector(".wrn-dropdown")!.addEventListener("action", (event) => {
|
|
value = (event as CustomEvent).detail.value;
|
|
});
|
|
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
|
|
expect(trigger.getAttribute("aria-label")).toBe("Account menu");
|
|
trigger.click();
|
|
(dom.querySelector("[data-dropdown-item]") as HTMLButtonElement).click();
|
|
expect(value).toBe(0);
|
|
});
|
|
|
|
test("Dropdown closes when keyboard focus leaves the component", async () => {
|
|
const dom = mountHtml(
|
|
await renderComponent(source("Dropdown"), {
|
|
label: "Menu",
|
|
items: [{ label: "Profile", href: "/profile" }],
|
|
}),
|
|
);
|
|
const trigger = dom.querySelector(".wrn-dropdown__trigger") as HTMLButtonElement;
|
|
trigger.click();
|
|
const outside = dom.document.createElement("button");
|
|
dom.document.body.append(outside);
|
|
trigger.dispatchEvent(
|
|
new (dom.window as { FocusEvent: typeof FocusEvent }).FocusEvent("focusout", {
|
|
bubbles: true,
|
|
relatedTarget: outside,
|
|
}),
|
|
);
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
});
|
|
|
|
test("layout fixes belong to components, not consuming applications", () => {
|
|
expect(source("Sidebar")).toMatch(
|
|
/\.wrn-sidebar__icon\s*\{[^}]*width: 1.05rem;[^}]*height: 1.05rem;/s,
|
|
);
|
|
expect(source("Sidebar")).toContain("margin-inline-start");
|
|
expect(source("Dropdown")).toContain(
|
|
'.wrn-dropdown[data-placement][data-mobile-panel="sheet"] .wrn-dropdown__panel',
|
|
);
|
|
expect(source("Footer")).toMatch(/\.wrn-footer__copyright-left\s*\{\s*grid-column: 1;/);
|
|
expect(source("Footer")).toMatch(/\.wrn-footer__copyright-right\s*\{\s*grid-column: 3;/);
|
|
});
|
|
});
|