diff --git a/src/components/ui/toggle-group.test.tsx b/src/components/ui/toggle-group.test.tsx new file mode 100644 index 0000000..af47e6c --- /dev/null +++ b/src/components/ui/toggle-group.test.tsx @@ -0,0 +1,53 @@ +/** + * @vitest-environment jsdom + * + * Arrow-key movement in a ToggleGroup comes from Base UI's composite, which + * reads `orientation` off the primitive. Our wrapper takes `orientation` as its + * own prop, so it is one destructure away from being spent on the data + * attribute and never reaching the composite — which is exactly what happened: + * a vertical group answered to Left/Right instead of Up/Down. + */ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ToggleGroup, ToggleGroupItem } from "./toggle-group"; + +function renderGroup(orientation: "horizontal" | "vertical") { + render( + {}} orientation={orientation} aria-label="Test"> + A + B + C + , + ); + return ["A", "B", "C"].map((n) => screen.getByRole("button", { name: n })); +} + +async function movesFocus(items: HTMLElement[], key: string) { + items[0].focus(); + fireEvent.keyDown(items[0], { key }); + try { + await waitFor(() => expect(document.activeElement).toBe(items[1]), { timeout: 300 }); + return true; + } catch { + return false; + } +} + +describe("ToggleGroup keyboard navigation", () => { + it("is a single tab stop, not one per item", () => { + const items = renderGroup("horizontal"); + expect(items.filter((el) => (el as HTMLButtonElement).tabIndex === 0)).toHaveLength(1); + }); + + it("moves a vertical group with ArrowDown", async () => { + expect(await movesFocus(renderGroup("vertical"), "ArrowDown")).toBe(true); + }); + + it("does not move a vertical group with ArrowRight", async () => { + expect(await movesFocus(renderGroup("vertical"), "ArrowRight")).toBe(false); + }); + + it("moves a horizontal group with ArrowRight", async () => { + expect(await movesFocus(renderGroup("horizontal"), "ArrowRight")).toBe(true); + }); +}); diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index 4a5b000..e6ba024 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -39,6 +39,10 @@ function ToggleGroup({ data-variant={variant} data-size={size} data-spacing={spacing} + // Base UI drives arrow-key movement from this, so it has to reach the + // primitive and not just the data attribute the styles read. Without it + // a vertical group still answered to Left/Right instead of Up/Down. + orientation={orientation} data-orientation={orientation} style={{ "--gap": spacing } as React.CSSProperties} className={cn( diff --git a/vitest.config.ts b/vitest.config.ts index 0709580..9dfab94 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,10 @@ export default defineConfig({ test: { globals: true, environment: "node", - include: ["src/**/*.test.ts", "tests/**/*.test.ts"], + // `.tsx` files are component tests; each one declares + // `@vitest-environment jsdom` in its own docblock, so the default node + // environment still applies to everything else. + include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"], exclude: ["e2e/**", "node_modules/**"], globalSetup: ["./tests/global-setup.ts"], testTimeout: 30_000,