From 4ab988beea9def48ff1eeb8d825625db75d4bc8b Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Mon, 7 Sep 2026 20:36:00 +0900 Subject: [PATCH] fix(ui): forward ToggleGroup's orientation to the primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base UI's composite drives arrow-key movement from `orientation`, but our wrapper destructured the prop and spent it on `data-orientation` and the class list only, so the primitive never saw it and kept its horizontal default. The vertical date-preset group added in #165 was the first caller to notice: visually a column, but Left/Right moved through it and Up/Down did nothing. ArrowDown → focus did not move ArrowRight → focus moved The four pre-existing callers are horizontal, which is why this survived until a vertical group existed. Comes with a regression test, which needs vitest to accept `.tsx` at all: `include` now covers `src/**/*.test.tsx`, and a component test declares `@vitest-environment jsdom` in its own docblock so the node default still applies to everything else. No projects, no CI change. Worth stating why the sibling wrappers are not the same bug: `size` on Card, Avatar, Select, Switch and AlertDialog, and `align` on InputGroup, are styling-only props shadcn expresses as data attributes on purpose. `orientation` is the one that changes what the primitive does. --- src/components/ui/toggle-group.test.tsx | 53 +++++++++++++++++++++++++ src/components/ui/toggle-group.tsx | 4 ++ vitest.config.ts | 5 ++- 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/toggle-group.test.tsx diff --git a/src/components/ui/toggle-group.test.tsx b/src/components/ui/toggle-group.test.tsx new file mode 100644 index 00000000..af47e6c2 --- /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 4a5b0009..e6ba0247 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 07095807..9dfab944 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,