import { act, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PanelTabs } from "./PanelTabs"; import { tabPanelProps } from "./panelTabsUtils"; let root: Root | null = null; let container: HTMLDivElement; const TABS = [ { id: "button", label: "Cart Button" }, { id: "notification", label: "Cart Notification" }, { id: "advanced", label: "Advanced" }, ]; const BASE_ID = "cart-subtabs"; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { act(() => root?.unmount()); root = null; container.remove(); document.documentElement.removeAttribute("dir"); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); function render(node: React.ReactNode) { act(() => { root = createRoot(container); root.render(node); }); } function tabs(): HTMLButtonElement[] { return Array.from( container.querySelectorAll('[role="tab"]'), ); } function press(element: Element, key: string) { act(() => { element.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), ); }); } /** Mirrors the real call sites: only the active panel is mounted. */ function CartHarness({ initial = "button" }: { initial?: string }) { const [active, setActive] = useState(initial); return ( <> {active === "button" && (
button panel
)} {active === "notification" && (
notification panel
)} {active === "advanced" && (
advanced panel
)} ); } describe("PanelTabs — tab pattern semantics", () => { it("renders a named tablist of tabs, not a radiogroup", () => { render(); const tablist = container.querySelector('[role="tablist"]'); expect(tablist).not.toBeNull(); expect(tablist?.getAttribute("aria-label")).toBe("Cart settings sections"); expect(tablist?.getAttribute("aria-orientation")).toBe("horizontal"); expect(tabs()).toHaveLength(3); expect(container.querySelector('[role="radiogroup"]')).toBeNull(); expect(container.querySelector('[role="radio"]')).toBeNull(); expect(container.querySelector("[aria-pressed]")).toBeNull(); }); it("marks exactly one tab selected", () => { render(); const selected = tabs().filter( (tab) => tab.getAttribute("aria-selected") === "true", ); expect(selected).toHaveLength(1); expect(selected[0].textContent).toBe("Cart Notification"); }); it("keeps a single tab stop, parked on the selected tab", () => { render(); const tabbable = tabs().filter((tab) => tab.tabIndex === 0); expect(tabbable).toHaveLength(1); expect(tabbable[0].textContent).toBe("Advanced"); }); }); describe("PanelTabs — tab/panel relationship", () => { it("points the active tab at its panel, and that panel back at the tab", () => { render(); const activeTab = tabs()[0]; const panelId = activeTab.getAttribute("aria-controls"); expect(panelId).toBe("cart-subtabs-panel-button"); const panel = document.getElementById(panelId!); expect(panel).not.toBeNull(); expect(panel?.getAttribute("role")).toBe("tabpanel"); expect(panel?.getAttribute("aria-labelledby")).toBe(activeTab.id); expect(panel?.textContent).toBe("button panel"); }); it("never leaves a dangling aria-controls for the unmounted panels", () => { render(); for (const tab of tabs()) { const controls = tab.getAttribute("aria-controls"); if (controls === null) continue; expect(document.getElementById(controls)).not.toBeNull(); } // Inactive tabs have no panel mounted, so they must not reference one. const inactive = tabs().filter( (tab) => tab.getAttribute("aria-selected") === "false", ); expect(inactive).toHaveLength(2); expect(inactive.every((tab) => !tab.hasAttribute("aria-controls"))).toBe( true, ); }); it("keeps every aria-labelledby resolving to a real tab id", () => { render(); const panel = container.querySelector('[role="tabpanel"]'); const labelledBy = panel?.getAttribute("aria-labelledby"); expect(labelledBy).toBe("cart-subtabs-tab-advanced"); expect(document.getElementById(labelledBy!)?.getAttribute("role")).toBe( "tab", ); }); it("moves the relationship across when the tab changes", () => { render(); act(() => tabs()[2].click()); expect(container.querySelectorAll('[role="tabpanel"]')).toHaveLength(1); expect(tabs()[2].getAttribute("aria-controls")).toBe( "cart-subtabs-panel-advanced", ); expect(tabs()[0].hasAttribute("aria-controls")).toBe(false); expect( document.getElementById("cart-subtabs-panel-advanced")?.textContent, ).toBe("advanced panel"); }); it("namespaces nested tablists so ids never collide", () => { render( <>
, ); const ids = Array.from(container.querySelectorAll("[id]")).map( (element) => element.id, ); expect(new Set(ids).size).toBe(ids.length); }); }); describe("PanelTabs — keyboard", () => { it("switches panels with ArrowRight/ArrowLeft, wrapping at the ends", () => { render(); press(tabs()[0], "ArrowRight"); expect(tabs()[1].getAttribute("aria-selected")).toBe("true"); press(tabs()[1], "ArrowLeft"); expect(tabs()[0].getAttribute("aria-selected")).toBe("true"); press(tabs()[0], "ArrowLeft"); expect(tabs()[2].getAttribute("aria-selected")).toBe("true"); press(tabs()[2], "ArrowRight"); expect(tabs()[0].getAttribute("aria-selected")).toBe("true"); }); it("jumps to the first and last tab with Home/End", () => { render(); press(tabs()[1], "End"); expect(tabs()[2].getAttribute("aria-selected")).toBe("true"); press(tabs()[2], "Home"); expect(tabs()[0].getAttribute("aria-selected")).toBe("true"); }); it("mirrors horizontal arrows under RTL", () => { document.documentElement.setAttribute("dir", "rtl"); render(); press(tabs()[1], "ArrowRight"); expect(tabs()[0].getAttribute("aria-selected")).toBe("true"); }); it("lets a nested LTR tablist override an RTL document", () => { document.documentElement.setAttribute("dir", "rtl"); render(
, ); press(tabs()[1], "ArrowRight"); expect(tabs()[2].getAttribute("aria-selected")).toBe("true"); }); it("uses computed RTL when no ancestor has an explicit dir", () => { vi.spyOn(window, "getComputedStyle").mockReturnValue({ direction: "rtl", } as CSSStyleDeclaration); render(); press(tabs()[1], "ArrowRight"); expect(tabs()[0].getAttribute("aria-selected")).toBe("true"); }); it("does not swallow ArrowUp/ArrowDown — the panel stays scrollable", () => { render(); const down = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true, }); act(() => { tabs()[0].dispatchEvent(down); }); expect(down.defaultPrevented).toBe(false); expect(tabs()[0].getAttribute("aria-selected")).toBe("true"); }); it("keeps the single tab stop on whichever tab the arrows landed on", () => { render(); press(tabs()[0], "ArrowRight"); const tabbable = tabs().filter((tab) => tab.tabIndex === 0); expect(tabbable).toHaveLength(1); expect(tabbable[0].textContent).toBe("Cart Notification"); }); it("waits one animation frame before focusing the newly mounted active tab", () => { const frames: FrameRequestCallback[] = []; vi.stubGlobal( "requestAnimationFrame", vi.fn((callback: FrameRequestCallback) => { frames.push(callback); return frames.length; }), ); render(); tabs()[0].focus(); press(tabs()[0], "ArrowRight"); expect(document.activeElement).toBe(tabs()[0]); act(() => { while (frames.length > 0) { frames.shift()?.(0); } }); expect(document.activeElement).toBe(tabs()[1]); }); });