import { composeStories } from "@storybook/react" import { expect, userEvent, waitFor } from "storybook/test" import { test } from "vitest" import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts" import { mountStory } from "../mountStory.testHelpers.ts" import { expectAgentDrivable } from "../testing/index.ts" import * as stories from "./Tabs.stories.tsx" const { AllStates, AllVariants, Playground, Manual, Responsive, Routed, RoutedAllStates, RoutedAllVariants, CurrentTabVisible, } = composeStories(stories) test("selecting a tab wires the panel back to it", async () => { const { canvas, canvasElement } = await mountStory(Playground) const tablist = expectAgentDrivable(canvas, { name: "Bay 3", role: "tablist", }) await expect(tablist).toHaveAttribute( "aria-orientation", "horizontal", ) const log = expectAgentDrivable(canvas, { name: "Log", role: "tab", }) await userEvent.click(log) await waitFor(() => { expect(log).toHaveAttribute("aria-selected", "true") }) // `aria-controls` really reaches the panel, and the panel names // itself back through `aria-labelledby`. Both ids come from one // `useUniqueId`, which is why they cannot drift. const panel = expectAgentDrivable(canvas, { name: "Log", role: "tabpanel", }) await expect(log).toHaveAttribute( "aria-controls", panel.id, ) await expectNoAxeViolations(canvasElement) }) test("activeKey decides the first render and nothing after", async () => { const { canvas } = await mountStory(AllVariants) // A controlled prop is the thing this library refuses to have. const tablist = expectAgentDrivable(canvas, { name: "Bay 3 starting on log", role: "tablist", }) const selected = tablist.querySelector( '[aria-selected="true"]', ) await expect(selected).toHaveTextContent("Log") }) test("a vertical tablist says so", async () => { const { canvas } = await mountStory(AllVariants) await expect( expectAgentDrivable(canvas, { name: "Bay 3 vertical", role: "tablist", }), ).toHaveAttribute("aria-orientation", "vertical") }) test("the arrow keys skip a disabled tab", async () => { const { canvas } = await mountStory(AllStates) const verdict = expectAgentDrivable(canvas, { name: "Verdict", role: "tab", }) await expect(verdict).toBeDisabled() // The disabled tab is out of the *focus* group and still one of // the *options* — it owns a panel and an id either way. // Registration is membership, so the arrow keys skip it with // nothing in `RovingFocus` knowing the word "disabled". const flags = expectAgentDrivable(canvas, { name: "Flags", role: "tab", }) await userEvent.click( expectAgentDrivable(canvas, { name: "Disc", role: "tab", }), ) await userEvent.keyboard("{ArrowRight}") await waitFor(() => { expect(flags).toHaveFocus() }) await expect(verdict).not.toHaveFocus() }) /** * A tab bar **scrolls**, it does not wrap. A wrapped tablist puts * tabs on two rows, which breaks both the visual row and the mental * model the arrow keys give — "right" stops meaning right. */ test("a narrow tab bar scrolls rather than wrapping", async () => { const { canvas } = await mountStory(Responsive) const narrow = expectAgentDrivable(canvas, { name: "Bay 3 at 15rem", role: "tablist", }) await expect(narrow.scrollWidth).toBeGreaterThan( narrow.clientWidth, ) // **And it clips what it cannot fit.** The line above says only // that the content is wider than the box, which is equally true of // a bar painting straight across the panel beside it — which is // what this component actually did until 2026-07-30, in this exact // story, with this exact assertion passing. Overflowing and // scrolling are different things and the test has to say which. await expect( globalThis.getComputedStyle(narrow).overflowX, ).not.toBe("visible") // One row, still. `clientHeight` growing past a single tab's // height is what wrapping would look like — and, now that this is // a scroll container, what a *classic* scrollbar taking layout // space would look like too. Both are caught here. const wide = expectAgentDrivable(canvas, { name: "Bay 3 at 34rem", role: "tablist", }) await expect(narrow.clientHeight).toBe(wide.clientHeight) }) test("the focus ring survives the scroll container", async () => { const { canvas } = await mountStory(Responsive) const narrow = expectAgentDrivable(canvas, { name: "Bay 3 at 15rem", role: "tablist", }) // Tabbed to, not `.focus()`ed: the ring is a `:focus-visible` // rule, so a programmatic focus leaves `outline-offset` at its // `0px` initial value and *any* assertion about it passes. That is // how the first version of this test passed against the bug it was // written for. await userEvent.tab() const tab = document.activeElement as HTMLElement await expect(narrow).toContainElement(tab) // An `overflow` other than `visible` clips its descendants' // outlines, and the focus ring *is* an outline. The tab fills the // bar's content box, so a positive offset would put the whole ring // in the clipped region — visible nowhere, on the one component in // the library that scrolls. Drawing it inward is what keeps it on // screen, and this is the assertion that fails if the shared // `--focus-ring-offset` is ever restored here. const { outlineOffset, outlineWidth } = globalThis.getComputedStyle(tab) await expect( Number.parseFloat(outlineWidth), ).toBeGreaterThan(0) await expect( Number.parseFloat(outlineOffset), ).toBeLessThanOrEqual(0) }) /** * The falsification point that M4 was built to run: `automatic` and * `manual` differ only in whether moving focus also *chooses*, and * that difference is one line because focus and selection are * separate kinds. */ test("manual activation moves focus without selecting", async () => { const { canvas } = await mountStory(Manual) const tablist = expectAgentDrivable(canvas, { name: "Bay 3 manual", role: "tablist", }) const tabs = Array.from( tablist.querySelectorAll( '[role="tab"]', ), ) const [progress, log] = tabs as [ HTMLButtonElement, HTMLButtonElement, ] // Exactly one tab stop for the whole bar — the roving-tabindex // rule, straight from `selectTabIndex`. await expect( tabs.filter((one) => one.tabIndex === 0), ).toHaveLength(1) progress.focus() await userEvent.keyboard("{ArrowRight}") await waitFor(() => { expect(log).toHaveFocus() }) // The whole point: focus moved, selection did not. await expect(log).toHaveAttribute( "aria-selected", "false", ) await expect(progress).toHaveAttribute( "aria-selected", "true", ) await userEvent.keyboard("{Enter}") await waitFor(() => { expect(log).toHaveAttribute("aria-selected", "true") }) }) test("automatic activation selects as focus moves, and wraps", async () => { const { canvas } = await mountStory(Playground) const tablist = expectAgentDrivable(canvas, { name: "Bay 3", role: "tablist", }) const tabs = tablist.querySelectorAll( '[role="tab"]', ) tabs[0]?.focus() await userEvent.keyboard("{ArrowRight}") await waitFor(() => { expect(tabs[1]).toHaveAttribute("aria-selected", "true") }) // Wrapping is on, because a tab list is one of the patterns ARIA // says wraps. await userEvent.keyboard("{ArrowRight}{ArrowRight}") await waitFor(() => { expect(tabs[0]).toHaveAttribute("aria-selected", "true") }) }) /** * The routed bar is a **navigation**, not a tablist, and this is the * assertion that says so. `role="tab"` on an anchor overrides the * link role: a screen reader announces a disclosure, the reader * waits for a panel, and the address changes under them instead. * * A screenshot cannot tell the two apart — the paint is deliberately * identical — so nothing but this catches a regression to it. */ test("a routed tab is a link in a nav, never a tab in a tablist", async () => { const { canvas, canvasElement } = await mountStory(Routed) const nav = expectAgentDrivable(canvas, { name: "Bay 3 sections", role: "navigation", }) await expect( canvas.queryByRole("tablist"), ).not.toBeInTheDocument() await expect( canvas.queryByRole("tab"), ).not.toBeInTheDocument() // No panels either: the sections are the router's, rendered // through an `` this component cannot see. await expect( canvas.queryByRole("tabpanel"), ).not.toBeInTheDocument() const links = Array.from( nav.querySelectorAll("a[href]"), ) await expect(links).toHaveLength(4) await expectNoAxeViolations(canvasElement) }) test("exactly one routed tab is current, and it is the deepest match", async () => { const { canvas } = await mountStory(RoutedAllVariants) // `/tasks` and the project root both match `/…/tasks`, and only // the deeper one may light up — a bar with two current items has // stopped saying anything. The rule is `resolveActiveKey`, shared // with `Nav` rather than reimplemented here. const nav = expectAgentDrivable(canvas, { name: "Routed horizontal", role: "navigation", }) const current = nav.querySelectorAll( '[aria-current="page"]', ) await expect(current).toHaveLength(1) await expect(current[0]).toHaveTextContent("Titles") // A child route is still inside the section it belongs to. const child = expectAgentDrivable(canvas, { name: "Routed on a child route", role: "navigation", }) const childCurrent = child.querySelectorAll( '[aria-current="page"]', ) await expect(childCurrent).toHaveLength(1) await expect(childCurrent[0]).toHaveTextContent("Titles") }) /** * A disabled anchor is the defect this guards. `` has no * `disabled` attribute, so the tempting version — a real `href` plus * `aria-disabled` — greys out and still navigates on Enter and on a * middle click. */ test("a disabled routed tab has no href, and an external one is never current", async () => { const { canvas, canvasElement } = await mountStory(RoutedAllStates) const nav = expectAgentDrivable(canvas, { name: "Bay 3 states", role: "navigation", }) const reports = canvas.getByText("Reports") await expect(reports).toHaveAttribute( "aria-disabled", "true", ) await expect(reports.closest("a")).toBeNull() const handbook = expectAgentDrivable(canvas, { name: /Handbook/u, role: "link", }) await expect(handbook).toHaveAttribute("target", "_blank") await expect(handbook).toHaveAttribute( "rel", "noopener noreferrer", ) // Never current: no address inside this app can be a place // outside it, and a plain string compare would match one. await expect(handbook).not.toHaveAttribute("aria-current") // `activeHref` points at `/overview`, which no tab owns — so the // bar honestly marks nothing rather than guessing the closest. await expect( nav.querySelectorAll('[aria-current="page"]'), ).toHaveLength(0) await expectNoAxeViolations(canvasElement) }) /** * The reason both modes live in one component: a routed tab and a * panel tab are one shape doing one job, and Docket shipped them on * adjacent screens wearing different paint because the routed one * had to be built out of `Nav`. */ test("a routed tab and a panel tab are painted identically", async () => { const { canvas } = await mountStory(RoutedAllVariants) const routedCurrent = expectAgentDrivable(canvas, { name: "Routed horizontal", role: "navigation", }).querySelector('[aria-current="page"]') const panelSelected = expectAgentDrivable(canvas, { name: "Panel horizontal", role: "tablist", }).querySelector('[aria-selected="true"]') const routedStyle = globalThis.getComputedStyle( routedCurrent as HTMLElement, ) const panelStyle = globalThis.getComputedStyle( panelSelected as HTMLElement, ) for (const property of [ "borderBottomColor", "borderBottomWidth", "color", "fontSize", "fontWeight", "paddingInlineStart", "paddingBlockStart", ] as const) { await expect(routedStyle[property]).toBe( panelStyle[property], ) } }) test("current routed and panel tabs remain visible without moving focus", async () => { const { canvas } = await mountStory(CurrentTabVisible) const assertVisible = async ( bar: HTMLElement, selector: string, ) => { await waitFor(() => { const selected = bar.querySelector(selector) expect(selected).not.toBeNull() const item = selected?.getBoundingClientRect() const bounds = bar.getBoundingClientRect() expect( item?.left ?? -Infinity, ).toBeGreaterThanOrEqual(bounds.left - 1) expect(item?.right ?? Infinity).toBeLessThanOrEqual( bounds.right + 1, ) }) } const routed = expectAgentDrivable(canvas, { role: "navigation", name: "Current routed section", }) const rtl = expectAgentDrivable(canvas, { role: "navigation", name: "Current RTL section", }) const panel = expectAgentDrivable(canvas, { role: "tablist", name: "Current manual panel", }) await assertVisible(routed, '[aria-current="page"]') await assertVisible(rtl, '[aria-current="page"]') await assertVisible(panel, '[aria-selected="true"]') const width = expectAgentDrivable(canvas, { role: "spinbutton", name: "Available width", }) await userEvent.clear(width) await userEvent.type(width, "160") await assertVisible(routed, '[aria-current="page"]') await expect(width).toHaveFocus() const activity = expectAgentDrivable(canvas, { role: "button", name: "Show activity", }) await userEvent.click(activity) await assertVisible(routed, '[aria-current="page"]') await expect(activity).toHaveFocus() const reports = expectAgentDrivable(canvas, { role: "button", name: "Show reports", }) await userEvent.click(reports) await assertVisible(routed, '[aria-current="page"]') await expect(reports).toHaveFocus() await userEvent.clear(width) await userEvent.type(width, "80") await userEvent.click(activity) await waitFor(() => { const bounds = routed.getBoundingClientRect() const selected = routed .querySelector('[aria-current="page"]') ?.getBoundingClientRect() expect( selected?.left ?? -Infinity, ).toBeGreaterThanOrEqual(bounds.left - 1) expect(Math.abs(routed.scrollLeft)).toBeLessThanOrEqual( 1, ) }) const disc = panel.querySelector( '[aria-selected="true"]', ) disc?.focus() await userEvent.keyboard("{ArrowLeft}") await assertVisible(panel, ":focus") await expect(disc).toHaveAttribute( "aria-selected", "true", ) })