fix(ui): Tabs is an ARIA tab list with roving focus and a wired panel

The rail was a row of aria-pressed toggles: every tab a Tab stop, no arrows,
nothing tying a tab to its content. tablist/tab/aria-selected, one Tab stop,
arrows on either axis, Home/End, and useTabPanel for aria-controls/labelledby.
This commit is contained in:
2026-09-27 14:22:37 +02:00
parent a975ab627a
commit 922d589b09
14 changed files with 311 additions and 139 deletions
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
//
// E28 review F3: the tab rail was a row of `aria-pressed` toggle buttons — every tab a Tab stop, no
// arrow keys, and nothing tying a tab to the content it shows. It is an ARIA tab list now.
import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import Tabs from "./Tabs";
import { useTabPanel } from "./tabPanel";
afterEach(cleanup);
const TABS = [
{ id: "a", label: "Alpha" },
{ id: "b", label: "Beta" },
{ id: "c", label: "Gamma" },
];
function Harness() {
const [active, setActive] = useState("a");
const panel = useTabPanel();
return (
<>
<Tabs idBase={panel.idBase} tabs={TABS} active={active} onChange={setActive} label="Pick" />
<div {...panel.panelProps(active)}>content of {active}</div>
</>
);
}
const tab = (name: string) => screen.getByRole("tab", { name });
describe("Tabs", () => {
it("is a tab list with one Tab stop, the selected tab", () => {
render(<Harness />);
expect(screen.getByRole("tablist", { name: "Pick" })).toBeTruthy();
expect(tab("Alpha").getAttribute("aria-selected")).toBe("true");
expect(tab("Beta").getAttribute("aria-selected")).toBe("false");
expect(TABS.map((t) => tab(t.label).tabIndex)).toEqual([0, -1, -1]);
});
it("moves and selects with the arrows, and Home/End jump to the ends", () => {
render(<Harness />);
tab("Alpha").focus();
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
expect(document.activeElement).toBe(tab("Beta"));
expect(tab("Beta").getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(document.activeElement!, { key: "End" });
expect(document.activeElement).toBe(tab("Gamma"));
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" }); // wraps
expect(document.activeElement).toBe(tab("Alpha"));
fireEvent.keyDown(document.activeElement!, { key: "ArrowUp" }); // either axis
expect(document.activeElement).toBe(tab("Gamma"));
fireEvent.keyDown(document.activeElement!, { key: "Home" });
expect(tab("Alpha").getAttribute("aria-selected")).toBe("true");
});
it("ties each tab to the panel, and the panel to the selected tab", () => {
render(<Harness />);
const panel = screen.getByRole("tabpanel");
expect(tab("Beta").getAttribute("aria-controls")).toBe(panel.id);
fireEvent.click(tab("Beta"));
expect(screen.getByRole("tabpanel", { name: "Beta" })).toBe(panel);
});
});
+54 -2
View File
@@ -1,3 +1,5 @@
import { useId, useRef, type KeyboardEvent } from "react";
import { panelId, tabId } from "./tabPanel";
import { type LucideIcon } from "lucide-react";
import CountBadge from "./CountBadge";
@@ -8,6 +10,11 @@ import CountBadge from "./CountBadge";
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
// it is a horizontal row that scrolls. Callers persist the active tab with lib/storage's
// useAccountPersistedState (account-scoped), so a reload keeps the user where they were.
//
// An ARIA tab list (E28 review F3), not a row of toggle buttons: `tablist` / `tab` / `aria-selected`,
// ONE Tab stop (the selected tab; roving tabindex), the arrows move and select (either axis, since the
// vertical rail is a row below `sm`), Home/End jump to the ends. The panel it controls is wired with
// `useTabPanel`: the tabs point at it (`aria-controls`) and it is labelled by the selected tab.
export interface TabDef {
id: string;
@@ -23,6 +30,8 @@ export default function Tabs({
onChange,
orientation = "horizontal",
className,
idBase,
label,
}: {
tabs: TabDef[];
active: string;
@@ -32,22 +41,65 @@ export default function Tabs({
// Override the row/rail wrapper — e.g. the channel manager embeds the rail in a justify-between
// header, or the settings rail adds a width + right border. Omit for the orientation default.
className?: string;
/** `useTabPanel().idBase`: the tabs then name the panel they control. */
idBase?: string;
/** The tab list's accessible name, when the page heading beside it does not say what it switches. */
label?: string;
}) {
const ownBase = useId();
const base = idBase ?? ownBase;
const refs = useRef(new Map<string, HTMLButtonElement>());
const vertical = orientation === "vertical";
const wrapCls =
className ??
(vertical ? "flex gap-1 overflow-x-auto sm:flex-col" : "flex flex-wrap gap-1 mb-4");
// Selection follows focus (automatic activation): every panel here renders at once, so the arrow
// can show what it lands on. An unknown `active` (still loading) leaves the first tab the stop.
const stop = tabs.some((tb) => tb.id === active) ? active : tabs[0]?.id;
const onKeyDown = (e: KeyboardEvent) => {
const at = tabs.findIndex((tb) => tb.id === stop);
const to =
e.key === "ArrowRight" || e.key === "ArrowDown"
? (at + 1) % tabs.length
: e.key === "ArrowLeft" || e.key === "ArrowUp"
? (at - 1 + tabs.length) % tabs.length
: e.key === "Home"
? 0
: e.key === "End"
? tabs.length - 1
: -1;
const next = tabs[to];
if (!next || e.altKey || e.ctrlKey || e.metaKey) return;
e.preventDefault();
refs.current.get(next.id)?.focus();
if (next.id !== active) onChange(next.id);
};
return (
<div className={wrapCls}>
<div
role="tablist"
aria-label={label}
aria-orientation={vertical ? "vertical" : "horizontal"}
onKeyDown={onKeyDown}
className={wrapCls}
>
{tabs.map((tb) => {
const on = tb.id === active;
const Icon = tb.icon;
return (
<button
key={tb.id}
ref={(node) => {
if (node) refs.current.set(tb.id, node);
else refs.current.delete(tb.id);
}}
type="button"
role="tab"
id={tabId(base, tb.id)}
aria-selected={on}
aria-controls={idBase ? panelId(idBase) : undefined}
tabIndex={tb.id === stop ? 0 : -1}
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
aria-pressed={on}
className={`pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
+21
View File
@@ -0,0 +1,21 @@
import { useId } from "react";
// The id half of ui/Tabs (its own file so Tabs.tsx exports only components — Fast Refresh).
export const tabId = (base: string, id: string) => `${base}tab-${id}`;
export const panelId = (base: string) => `${base}panel`;
/** The ids that tie a `Tabs` to the panel it switches: pass `idBase` to the `Tabs` and spread
* `panelProps(active)` on the element that shows the selected tab's content. Takes no argument, so
* it can sit above a page's loading / error returns while the active tab is only known below. */
export function useTabPanel() {
const base = useId();
return {
idBase: base,
panelProps: (active: string) => ({
id: panelId(base),
role: "tabpanel" as const,
"aria-labelledby": tabId(base, active),
}),
};
}