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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user