import { useState } from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; const has = (element: HTMLElement, token: string) => element.className.split(/\s+/).includes(token); function Harness({ initial = "a" }: { initial?: string }) { const [value, setValue] = useState(initial); return ( setValue(String(next))}> Völker Königinnen Ernte Panel Völker Panel Königinnen Panel Ernte ); } describe("Tabs (spec 10.4)", () => { it("arrow keys move the focus, Enter or Space switch the tab, and the panel is labelled by it", async () => { const user = userEvent.setup(); render(); const first = screen.getByRole("tab", { name: "Völker" }); await user.click(first); await user.keyboard("{ArrowRight}"); // Manual activation (APG): the panels fetch on mount, so an arrow only moves the focus. const second = screen.getByRole("tab", { name: "Königinnen" }); expect(second).toHaveFocus(); expect(second).toHaveAttribute("aria-selected", "false"); expect(screen.getByRole("tabpanel")).toHaveTextContent("Panel Völker"); await user.keyboard("{Enter}"); expect(second).toHaveAttribute("aria-selected", "true"); const panel = screen.getByRole("tabpanel"); expect(panel).toHaveTextContent("Panel Königinnen"); expect(panel).toHaveAccessibleName("Königinnen"); expect(second).toHaveAttribute("aria-controls", panel.id); // Focus wraps around at the end (one tab stop, arrows inside the list); Space selects too. await user.keyboard("{ArrowRight}{ArrowRight}"); expect(first).toHaveFocus(); await user.keyboard(" "); expect(first).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tabpanel")).toHaveTextContent("Panel Völker"); }); it("the active tab has the surface, bold text and the 3 px ink bar", () => { render(); const active = screen.getByRole("tab", { name: "Königinnen" }); expect(active).toHaveAttribute("data-active"); for (const token of [ "data-active:bg-card", "data-active:text-foreground", "data-active:font-semibold", "border-b-3", "data-active:border-b-primary-ink", ]) { expect(has(active, token), token).toBe(true); } // Inactive tabs: --muted-foreground on --muted (≥ 4.5:1 in all four sets, contrast.spec.ts). const inactive = screen.getByRole("tab", { name: "Völker" }); expect(has(inactive, "text-muted-foreground")).toBe(true); expect(inactive.className).not.toMatch(/text-foreground\/\d+/); expect(has(screen.getByRole("tablist"), "bg-muted")).toBe(true); }); it("tabs are 44 px for a thumb, compact only with a fine pointer, 16 px text, 4 px apart", () => { render(); const list = screen.getByRole("tablist"); expect(has(list, "p-1")).toBe(true); expect(has(list, "gap-1")).toBe(true); for (const tab of screen.getAllByRole("tab")) { expect(has(tab, "h-11")).toBe(true); expect(has(tab, "pointer-fine:h-9")).toBe(true); expect(has(tab, "text-base")).toBe(true); expect(tab.className).not.toMatch(/(^|\s)sm:(?:min-)?h-/); } }); it("the list wraps on a narrow screen instead of overflowing", () => { render(); const list = screen.getByRole("tablist"); expect(has(list, "flex-wrap")).toBe(true); expect(has(list, "max-w-full")).toBe(true); expect(list.className).not.toMatch(/(^|\s)h-10(\s|$)/); }); it("has one variant, segment, and no own focus ring or movement", () => { render(); expect(screen.getByRole("tablist")).toHaveAttribute("data-variant", "segment"); for (const tab of screen.getAllByRole("tab")) { expect(tab.className).not.toMatch(/outline-none|ring-ring|focus-visible:ring|translate|transition-all/); } }); });