import { act, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChoiceGroup, ChoiceOption } from "./ChoiceGroup"; import { Toggle } from "./Toggle"; let root: Root | null = null; let container: HTMLDivElement; const SHAPES = ["square", "rounded", "circle"] as const; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { act(() => root?.unmount()); root = null; container.remove(); document.documentElement.removeAttribute("dir"); vi.restoreAllMocks(); }); function render(node: React.ReactNode) { act(() => { root = createRoot(container); root.render(node); }); } function radios(scope: ParentNode = container): HTMLButtonElement[] { return Array.from( scope.querySelectorAll('[role="radio"]'), ); } function press(element: Element, key: string) { act(() => { element.dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), ); }); } function ShapeGroup({ initial = "rounded", onChange, }: { initial?: string; onChange?: (value: string) => void; }) { const [value, setValue] = useState(initial); return ( { setValue(next); onChange?.(next); }} > {SHAPES.map((shape) => ( {shape} ))} ); } describe("ChoiceGroup — radiogroup semantics", () => { it("names the group and exposes the options as radios", () => { render(); const group = container.querySelector('[role="radiogroup"]'); expect(group).not.toBeNull(); expect(group?.getAttribute("aria-label")).toBe("Shape"); expect(radios()).toHaveLength(3); }); it("marks exactly one radio checked and never emits aria-pressed", () => { render(); const checked = radios().filter( (radio) => radio.getAttribute("aria-checked") === "true", ); expect(checked).toHaveLength(1); expect(checked[0].textContent).toBe("circle"); expect(container.querySelector("[aria-pressed]")).toBeNull(); }); it("keeps a single tab stop, parked on the checked radio", () => { render(); const tabbable = radios().filter((radio) => radio.tabIndex === 0); expect(tabbable).toHaveLength(1); expect(tabbable[0].textContent).toBe("circle"); }); it("wires aria-describedby to a caller-supplied description", () => { render( <>

Pick a shape.

{SHAPES.map((shape) => ( {shape} ))} , ); const group = container.querySelector('[role="radiogroup"]'); const describedBy = group?.getAttribute("aria-describedby"); expect(describedBy).toBe("shape-desc"); // The reference must resolve — no dangling aria-describedby. expect(document.getElementById(describedBy!)).not.toBeNull(); }); it("selects on click", () => { const onChange = vi.fn(); render(); act(() => radios()[2].click()); expect(onChange).toHaveBeenCalledWith("circle"); expect(radios()[2].getAttribute("aria-checked")).toBe("true"); }); }); describe("ChoiceGroup — keyboard", () => { it("moves and selects with ArrowRight/ArrowLeft, wrapping at the ends", () => { render(); press(radios()[0], "ArrowRight"); expect(radios()[1].getAttribute("aria-checked")).toBe("true"); // Wrap backwards off the first option onto the last. press(radios()[1], "ArrowLeft"); press(radios()[0], "ArrowLeft"); expect(radios()[2].getAttribute("aria-checked")).toBe("true"); // Wrap forwards off the last option onto the first. press(radios()[2], "ArrowRight"); expect(radios()[0].getAttribute("aria-checked")).toBe("true"); }); it("moves and selects with ArrowDown/ArrowUp (radiogroups own both axes)", () => { render(); press(radios()[0], "ArrowDown"); expect(radios()[1].getAttribute("aria-checked")).toBe("true"); press(radios()[1], "ArrowUp"); expect(radios()[0].getAttribute("aria-checked")).toBe("true"); }); it("jumps to the first and last option with Home/End", () => { render(); press(radios()[1], "End"); expect(radios()[2].getAttribute("aria-checked")).toBe("true"); press(radios()[2], "Home"); expect(radios()[0].getAttribute("aria-checked")).toBe("true"); }); it("mirrors horizontal arrows under RTL", () => { document.documentElement.setAttribute("dir", "rtl"); render(); press(radios()[1], "ArrowRight"); expect(radios()[0].getAttribute("aria-checked")).toBe("true"); }); it("lets a nested LTR group override an RTL document", () => { document.documentElement.setAttribute("dir", "rtl"); render(
, ); press(radios()[1], "ArrowRight"); expect(radios()[2].getAttribute("aria-checked")).toBe("true"); }); it("uses computed RTL when no ancestor declares direction", () => { vi.spyOn(window, "getComputedStyle").mockReturnValue({ direction: "rtl", } as CSSStyleDeclaration); render(); press(radios()[1], "ArrowRight"); expect(radios()[0].getAttribute("aria-checked")).toBe("true"); }); it("moves focus along with selection so the tab stop follows the user", () => { render(); press(radios()[0], "ArrowRight"); expect(document.activeElement).toBe(radios()[1]); expect(radios()[1].tabIndex).toBe(0); expect(radios().filter((radio) => radio.tabIndex === 0)).toHaveLength(1); }); it("consumes the keys it handles and ignores the rest", () => { render(); const handled = new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true, }); act(() => { radios()[0].dispatchEvent(handled); }); expect(handled.defaultPrevented).toBe(true); const ignored = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true, }); act(() => { radios()[0].dispatchEvent(ignored); }); expect(ignored.defaultPrevented).toBe(false); }); it("preserves Space/Enter selection intent on the focused choice", () => { const onChange = vi.fn(); render(); press(radios()[1], " "); press(radios()[1], "Enter"); expect(onChange).toHaveBeenNthCalledWith(1, "rounded"); expect(onChange).toHaveBeenNthCalledWith(2, "rounded"); }); }); describe("ChoiceGroup — disabled and hidden options", () => { it("keeps disabled cards rendered but skips them in the roving order", () => { const onChange = vi.fn(); render( {SHAPES.map((shape) => ( {shape} ))} , ); expect(radios()[1].getAttribute("aria-disabled")).toBe("true"); expect(radios()[1].tabIndex).toBe(-1); press(radios()[0], "ArrowRight"); expect(onChange).toHaveBeenCalledWith("circle"); expect(document.activeElement).toBe(radios()[2]); act(() => radios()[1].click()); expect(onChange).toHaveBeenCalledTimes(1); }); it("leaves no tab stop when the whole visible group is disabled", () => { render( {SHAPES.map((shape) => ( {shape} ))} , ); expect( container .querySelector('[role="radiogroup"]') ?.getAttribute("aria-disabled"), ).toBe("true"); expect(radios().every((radio) => radio.tabIndex === -1)).toBe(true); }); it("lets callers omit hidden choices without leaving a ghost tab stop", () => { render( square circle , ); expect(radios()).toHaveLength(2); expect(radios().filter((radio) => radio.tabIndex === 0)).toHaveLength(1); }); }); describe("ChoiceGroup — zero-or-one selection (custom color active)", () => { const PRESETS = ["#ff0000", "#00ff00"] as const; function Presets({ value }: { value: string }) { return ( {PRESETS.map((hex) => ( ))} ); } it("leaves every radio unchecked when the active value matches no preset", () => { render(); expect( radios().every((radio) => radio.getAttribute("aria-checked") === "false"), ).toBe(true); }); it("still exposes exactly one tab stop, parked on the first preset", () => { render(); const tabbable = radios().filter((radio) => radio.tabIndex === 0); expect(tabbable).toHaveLength(1); expect(tabbable[0]).toBe(radios()[0]); }); it("checks the matching preset once the value lines up again", () => { render(); expect(radios()[1].getAttribute("aria-checked")).toBe("true"); expect(radios()[0].getAttribute("aria-checked")).toBe("false"); }); }); describe("ChoiceGroup — group ownership", () => { function TwoGroups() { const [cut, setCut] = useState("vertical"); const [gradient, setGradient] = useState("none"); return ( <> vertical horizontal none soft ); } it("gives each radio exactly one owning radiogroup", () => { render(); const groups = Array.from( container.querySelectorAll('[role="radiogroup"]'), ); expect(groups).toHaveLength(2); for (const group of groups) { for (const radio of radios(group)) { expect(radio.closest('[role="radiogroup"]')).toBe(group); } } }); it("gives each group its own independent tab stop", () => { render(); const groups = Array.from( container.querySelectorAll('[role="radiogroup"]'), ); for (const group of groups) { expect( radios(group).filter((radio) => radio.tabIndex === 0), ).toHaveLength(1); } }); it("does not let an arrow key in one group move focus into the other", () => { render(); const [cutGroup, gradientGroup] = Array.from( container.querySelectorAll('[role="radiogroup"]'), ); // Wrap off the end of the first group — must land back inside it. press(radios(cutGroup)[1], "ArrowRight"); expect(document.activeElement).toBe(radios(cutGroup)[0]); expect(cutGroup.contains(document.activeElement)).toBe(true); expect(gradientGroup.contains(document.activeElement)).toBe(false); // The other group's selection is untouched. expect(radios(gradientGroup)[0].getAttribute("aria-checked")).toBe("true"); }); }); describe("independent toggles are left alone", () => { it("still renders a switch, not a radio", () => { render( , ); const toggle = container.querySelector('[role="switch"]'); expect(toggle).not.toBeNull(); expect(toggle?.getAttribute("aria-checked")).toBe("true"); expect(container.querySelector('[role="radio"]')).toBeNull(); expect(container.querySelector("[aria-pressed]")).toBeNull(); }); });