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
+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"