feat(ui): rewrite Tabs onto wire classes with URL sync and roving focus
Tabs was the only component in the library styled with Tailwind utilities, so it could not be themed like the rest and assumed Tailwind was present. It also fired raw CustomEvents instead of declaring outputs, and set a roving tabindex with no keydown handler at all -- which left every inactive tab unreachable by Tab while the arrows did nothing. It now uses wire-* classes and a local style block, declares change and select outputs, and opts into the roving runtime. mode=url mirrors the selection into a query parameter via pushState. Back and forward are handled in the runtime, which activates the matching tab rather than assigning to component state: a popstate listener writing state would be writing after the client function returned, and that write is dropped. The round trip is marked so the component does not push a second history entry for a navigation that came from history. Also anchors Nav submenus so the viewport clamp can pull them back on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2614,3 +2614,76 @@ test("nav renders its shell with no items and rejects a non-array", async () =>
|
||||
"Expected an array prop",
|
||||
);
|
||||
});
|
||||
|
||||
test("nav submenus are anchored so the viewport clamp can pull them back", async () => {
|
||||
const source = readFileSync(uiComponentPath("Nav"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
items: [
|
||||
{
|
||||
label: "Products",
|
||||
value: "products",
|
||||
items: [{ label: "Overview", href: "/p", value: "p" }],
|
||||
},
|
||||
],
|
||||
active: "p",
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
// visibility:hidden keeps layout, so the clamp can place a submenu correctly
|
||||
// before it is ever shown -- which is why CSS-driven hover still composes
|
||||
// with the runtime clamp.
|
||||
expect(dom.querySelectorAll(".wire-nav__submenu[data-wrn-anchored]").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("tabs use wire classes and declare real outputs instead of raw events", async () => {
|
||||
const source = readFileSync(uiComponentPath("Tabs"), "utf8");
|
||||
// The whole point of the rewrite: themeable wire-* classes, not Tailwind.
|
||||
expect(source).toContain("outputs {");
|
||||
expect(source).toContain("output.change(");
|
||||
expect(source).not.toContain("new CustomEvent(");
|
||||
|
||||
const html = await renderComponent(source, {
|
||||
items: [
|
||||
{ label: "Overview", value: "overview", content: "First panel" },
|
||||
{ label: "Pricing", value: "pricing", content: "Second panel" },
|
||||
],
|
||||
active: "overview",
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
|
||||
const list = dom.querySelector('[role="tablist"]') as HTMLElement;
|
||||
expect(list.getAttribute("data-wrn-roving")).toBe("horizontal");
|
||||
const tabs = dom.querySelectorAll('[role="tab"]');
|
||||
expect(tabs).toHaveLength(2);
|
||||
expect(tabs[0]!.getAttribute("aria-selected")).toBe("true");
|
||||
expect(tabs[1]!.getAttribute("aria-selected")).toBe("false");
|
||||
expect(dom.querySelector(".wire-tabs")).not.toBeNull();
|
||||
expect(dom.querySelectorAll('[role="tabpanel"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("tabs in url mode declare the query parameter they sync to", async () => {
|
||||
const source = readFileSync(uiComponentPath("Tabs"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
items: [
|
||||
{ label: "One", value: "one" },
|
||||
{ label: "Two", value: "two" },
|
||||
],
|
||||
active: "one",
|
||||
mode: "url",
|
||||
param: "tab",
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
const root = dom.querySelector(".wire-tabs") as HTMLElement;
|
||||
expect(root.getAttribute("data-mode")).toBe("url");
|
||||
expect(root.getAttribute("data-param")).toBe("tab");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user