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 "./SegmentedControl.stories.tsx" const { AllStates, Playground, FullWidth, Interactive, RichLabels, NarrowRichLabels, FragmentLabels, } = composeStories(stories) test("rich label boxes are centered inside both segmented options", async () => { const { canvas } = await mountStory(RichLabels) const group = expectAgentDrivable(canvas, { name: "Message type", role: "radiogroup", }) for (const option of getOptions(group)) { const label = option.firstElementChild if (!label) throw new Error("The rich label is missing") const optionBox = option.getBoundingClientRect() const labelBox = label.getBoundingClientRect() expect( Math.abs( optionBox.y + optionBox.height / 2 - labelBox.y - labelBox.height / 2, ), ).toBeLessThan(0.5) expect( Math.abs( optionBox.x + optionBox.width / 2 - labelBox.x - labelBox.width / 2, ), ).toBeLessThan(0.5) } }) test("rich labels stay inside their options in constrained containers", async () => { const { canvas } = await mountStory(NarrowRichLabels) for (const group of canvas.getAllByRole("radiogroup", { name: /Message type at/, })) { const groupBox = group.getBoundingClientRect() for (const option of getOptions(group)) { const label = option.firstElementChild if (!label) throw new Error("The rich label is missing") const optionBox = option.getBoundingClientRect() const labelBox = label.getBoundingClientRect() expect(labelBox.left).toBeGreaterThanOrEqual( optionBox.left - 0.5, ) expect(labelBox.right).toBeLessThanOrEqual( optionBox.right + 0.5, ) expect(optionBox.left).toBeGreaterThanOrEqual( groupBox.left - 0.5, ) expect(optionBox.right).toBeLessThanOrEqual( groupBox.right + 0.5, ) } } }) test("fragment label children align and have readable accessible names", async () => { const { canvas } = await mountStory(FragmentLabels) const group = expectAgentDrivable(canvas, { name: "Message format", role: "radiogroup", }) expectAgentDrivable(canvas, { name: "Type a message", role: "radio", }) expectAgentDrivable(canvas, { name: "Your voice Later", role: "radio", }) for (const option of getOptions(group)) { const optionBox = option.getBoundingClientRect() let previousRight = optionBox.left for (const child of option.children) { const box = child.getBoundingClientRect() expect( Math.abs( optionBox.y + optionBox.height / 2 - box.y - box.height / 2, ), ).toBeLessThan(0.5) expect(box.left).toBeGreaterThanOrEqual(previousRight) expect(box.right).toBeLessThanOrEqual( optionBox.right + 0.5, ) previousRight = box.right } } }) /** * `querySelectorAll` rather than `getAllByRole("radio")`, and not * for typing convenience: a board renders several groups, and an * assertion about "exactly one tab stop" is only the roving rule * when it is scoped to **one** group. */ const getOptions = (group: HTMLElement) => Array.from( group.querySelectorAll( '[role="radio"]', ), ) test("it is a radio group with exactly one option checked", async () => { const { canvas, canvasElement } = await mountStory(Playground) const group = expectAgentDrivable(canvas, { name: "Columns", role: "radiogroup", }) const options = getOptions(group) await expect(options).toHaveLength(5) await waitFor(() => { expect( options.filter( (one) => one.getAttribute("aria-checked") === "true", ), ).toHaveLength(1) }) // The state kind's invariant, visible in the DOM: `SinglePicker` // holds one intent field, so "at most one checked" is true by // representation rather than by bookkeeping. await expect(group).toContainElement(options[0] ?? null) await expectNoAxeViolations(canvasElement) }) test("Tab enters the group once, and the arrows move within it", async () => { const { canvas } = await mountStory(Interactive) const options = getOptions( expectAgentDrivable(canvas, { name: "Columns", role: "radiogroup", }), ) // Zero tab stops strands the widget; several mean the roving // pattern was never implemented. Exactly one is the rule, read // straight out of `selectTabIndex`. await expect( options.filter((one) => one.tabIndex === 0), ).toHaveLength(1) await userEvent.tab() await waitFor(() => { expect(options[0]).toHaveFocus() }) await userEvent.keyboard("{ArrowRight}") await waitFor(() => { expect(options[1]).toHaveFocus() }) }) /** * The radio-group rule, and the thing that makes this component a * different composition from `Tabs` rather than a restyle of it: * moving focus **checks**. `Tabs` has two activation modes because a * panel can cost a network request; there is nothing behind an * option here to be expensive. */ test("selection follows focus, and wraps", async () => { const { canvas } = await mountStory(Interactive) const options = getOptions( expectAgentDrivable(canvas, { name: "Columns", role: "radiogroup", }), ) options[0]?.focus() await userEvent.keyboard("{ArrowRight}") await waitFor(() => { expect(options[1]).toHaveAttribute( "aria-checked", "true", ) }) await expect(options[0]).toHaveAttribute( "aria-checked", "false", ) // Home and End, then wrapping — all three from `RovingFocus`, // none of them written here. await userEvent.keyboard("{End}") await waitFor(() => { expect(options[4]).toHaveAttribute( "aria-checked", "true", ) }) await userEvent.keyboard("{ArrowRight}") await waitFor(() => { expect(options[0]).toHaveAttribute( "aria-checked", "true", ) }) }) test("a pointer press checks the option it landed on", async () => { const { canvas } = await mountStory(Interactive) const four = expectAgentDrivable(canvas, { name: "4", role: "radio", }) await userEvent.click(four) await waitFor(() => { expect(four).toHaveAttribute("aria-checked", "true") }) }) test("the arrow keys skip a disabled option", async () => { const { canvas, canvasElement } = await mountStory(AllStates) const group = expectAgentDrivable(canvas, { name: "Columns with 2 unavailable", role: "radiogroup", }) const options = getOptions(group) const [first, , two, three] = options as [ HTMLButtonElement, HTMLButtonElement, HTMLButtonElement, HTMLButtonElement, ] await expect(two).toBeDisabled() first.focus() await userEvent.keyboard("{ArrowRight}{ArrowRight}") // Two presses from `auto` lands on `3`, not on the disabled `2` — // registration is membership, and a disabled option never joined // the focus group. await waitFor(() => { expect(three).toHaveFocus() }) await expect(two).not.toHaveFocus() await expectNoAxeViolations(canvasElement) }) test("selectedValue decides the first render and nothing after", async () => { const { canvas } = await mountStory(AllStates) const group = expectAgentDrivable(canvas, { name: "Columns starting on 3", role: "radiogroup", }) // A controlled prop is the thing this library refuses to have — // and reading it on the first paint is why the component falls // back to `pendingValue`: members register from an effect, so // `selectedValue` is still null when the group first paints. await waitFor(() => { expect( group.querySelector('[aria-checked="true"]'), ).toHaveTextContent("3") }) }) test("full-width mode fills its container without wrapping labels", async () => { const { canvas } = await mountStory(FullWidth) for (const group of canvas.getAllByRole("radiogroup", { name: /Board lanes at/, })) { const options = getOptions(group) const widths = options.map( (option) => option.getBoundingClientRect().width, ) if ( group.getAttribute("aria-label")?.includes("15rem") ) { // Two equal targets fit on the first row. The third fills the // second row, so every label stays intact and there is no gap. expect(widths[0]).toBeCloseTo(widths[1] ?? 0, 0) expect(widths[2]).toBeGreaterThan(widths[0] ?? 0) expect( group.getBoundingClientRect().width - (widths[2] ?? 0), ).toBeLessThanOrEqual(8) } else { expect( group.getBoundingClientRect().width, ).toBeGreaterThan( widths.reduce((sum, width) => sum + width, 0), ) expect( Math.max(...widths) - Math.min(...widths), ).toBeLessThan(0.5) } } })