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
+4 -2
View File
@@ -40,6 +40,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import AccountPanel from "./AccountPanel";
import { useConfirm } from "./confirmContext";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { useAccountPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
@@ -65,6 +66,7 @@ export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const panel = useTabPanel();
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
@@ -83,10 +85,10 @@ export default function AdminUsers() {
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
<Tabs idBase={panel.idBase} tabs={tabs} active={active} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
<div {...panel.panelProps(active)} className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && (
<>
+42 -37
View File
@@ -22,6 +22,7 @@ import {
} from "lucide-react";
import Avatar from "./ui/Avatar";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar } from "./ui/PageShell";
@@ -76,6 +77,7 @@ export default function ChannelPage({
// inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these.
const globalFilters = useFeedFilters();
const [tab, setTab] = useState<"videos" | "about">("videos");
const panel = useTabPanel();
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
@@ -424,6 +426,7 @@ export default function ChannelPage({
within a few pixels of each other instead of across the page. */}
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
<Tabs
idBase={panel.idBase}
className="flex gap-1"
active={tab}
onChange={(id) => setTab(id as "videos" | "about")}
@@ -569,43 +572,45 @@ export default function ChannelPage({
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
only the grid (and About) render here, in the scroller. */}
<PageToolbarSlot.Provider value={chromeSlot}>
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<Button
// Busy blocks the click, not the focus (see Block above).
variant="outline"
icon={<RefreshCw />}
loading={exploring}
onClick={() => runExplore(nextToken)}
>
{t("channel.loadMore")}
</Button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
<div {...panel.panelProps(tab)}>
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<Button
// Busy blocks the click, not the focus (see Block above).
variant="outline"
icon={<RefreshCw />}
loading={exploring}
onClick={() => runExplore(nextToken)}
>
{t("channel.loadMore")}
</Button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
</div>
</PageToolbarSlot.Provider>
</>
);
+89 -84
View File
@@ -15,6 +15,7 @@ import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import DataTable from "./ui/DataTable";
import Tabs, { type TabDef } from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { pinChannel, useComfortPin } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
@@ -70,6 +71,7 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
export default function Channels() {
const me = useMe();
const panel = useTabPanel();
const canWrite = me.can_write;
// "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
const { openWizard: onOpenWizard } = useWizard();
@@ -507,6 +509,7 @@ export default function Channels() {
const tabs = (
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
<Tabs
idBase={panel.idBase}
tabs={viewTabs}
active={view}
onChange={(id) => setView(id as ChannelsView)}
@@ -653,97 +656,99 @@ export default function Channels() {
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={blockedQuery.data ?? []}
icon={<Ban className="w-4 h-4 text-muted" />}
title={t("channels.blocked.title")}
hint={t("channels.blocked.hint")}
emptyText={t("channels.blocked.empty")}
actionLabel={t("channel.unblock")}
actionIcon={<X className="w-3.5 h-3.5" />}
onAction={(id) => unblock.mutate(id)}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
<div {...panel.panelProps(view)}>
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
</div>
) : view === "hidden" ? (
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={blockedQuery.data ?? []}
icon={<Ban className="w-4 h-4 text-muted" />}
title={t("channels.blocked.title")}
hint={t("channels.blocked.hint")}
emptyText={t("channels.blocked.empty")}
actionLabel={t("channel.unblock")}
actionIcon={<X className="w-3.5 h-3.5" />}
onAction={(id) => unblock.mutate(id)}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : view === "hidden" ? (
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={hiddenChannels}
icon={<EyeOff className="w-4 h-4 text-muted" />}
title={t("channels.hidden.title")}
hint={t("channels.hidden.hint")}
emptyText={t("channels.hidden.empty")}
actionLabel={t("channels.hidden.unhide")}
actionIcon={<Eye className="w-3.5 h-3.5" />}
onAction={channelActions.unhide}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={hiddenChannels}
icon={<EyeOff className="w-4 h-4 text-muted" />}
title={t("channels.hidden.title")}
hint={t("channels.hidden.hint")}
emptyText={t("channels.hidden.empty")}
actionLabel={t("channels.hidden.unhide")}
actionIcon={<Eye className="w-3.5 h-3.5" />}
onAction={channelActions.unhide}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
`position: sticky` engages on the first scroll (the band's controls row above gives the
visual gap). */
<div ref={listRef} className="px-4 pb-4 max-w-384 mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
<div ref={listRef} className="px-4 pb-4 max-w-384 mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
card layout swaps the table for a virtualized grid over the same rows (its pager and
status chips ride in the fixed band above instead). */}
{channelsQuery.isError && !channelsQuery.data ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<LoadingState shape="table" label={t("channels.loading")} />
) : layout === "table" ? (
<DataTable
rows={syncedChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="both"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
filters={filters}
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
rowClassName={() => "group"}
keyboardNav
reveal={follow}
/>
) : (
<ChannelLayoutGrid
rows={pagedCards}
columns={columns}
rowKey={(c) => c.id}
leadKey="priority"
aboutKey="about"
moreKey="actions"
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
rowClassName={() => "group"}
/>
)}
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
</div>
)}
{channelsQuery.isError && !channelsQuery.data ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<LoadingState shape="table" label={t("channels.loading")} />
) : layout === "table" ? (
<DataTable
rows={syncedChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="both"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
filters={filters}
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
rowClassName={() => "group"}
keyboardNav
reveal={follow}
/>
) : (
<ChannelLayoutGrid
rows={pagedCards}
columns={columns}
rowKey={(c) => c.id}
leadKey="priority"
aboutKey="about"
moreKey="actions"
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
rowClassName={() => "group"}
/>
)}
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
</div>
)}
</div>
</>
);
}
+9 -2
View File
@@ -9,6 +9,7 @@ import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { useMe } from "../lib/useMe";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
@@ -30,6 +31,7 @@ import { SURFACE } from "./ui/surfaces";
export default function ConfigPanel() {
const { t } = useTranslation();
const panel = useTabPanel();
const qc = useQueryClient();
const me = useMe();
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
@@ -191,13 +193,18 @@ export default function ConfigPanel() {
took three lines of a short window for text read once. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<Tabs tabs={groupTabs} active={activeGroup ?? ""} onChange={setTab} />
<Tabs
idBase={panel.idBase}
tabs={groupTabs}
active={activeGroup ?? ""}
onChange={setTab}
/>
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-1 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
{activeGroup && (
<div className={`${SURFACE.panel} p-4 mb-4`}>
<div {...panel.panelProps(activeGroup ?? "")} className={`${SURFACE.panel} p-4 mb-4`}>
<div className="divide-y divide-border/60">
{(data.groups[activeGroup] ?? []).map((item) => (
<Field
@@ -108,7 +108,7 @@ describe("DownloadCenter confirm dialogs", () => {
await pick("Clip 1", "downloads.actions.cancel");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
// The tab's label shares its button with a count badge.
fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ }));
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
await screen.findByText("Clip 2");
await pick("Clip 2", "downloads.actions.redownload");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
@@ -84,7 +84,7 @@ function mount(tab?: string) {
</QueryClientProvider>,
);
if (tab)
fireEvent.click(screen.getByRole("button", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
+10 -2
View File
@@ -15,6 +15,7 @@ import {
UploadCloud,
} from "lucide-react";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
@@ -50,6 +51,7 @@ export default function DownloadCenter() {
// Always land on the queue tab (not a persisted last-tab) — the queue is where new work and
// any failures are, so it's the useful default every visit.
const [tab, setTab] = useState("queue");
const panel = useTabPanel();
const [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null);
const [upload, setUpload] = useState(false);
@@ -146,7 +148,13 @@ export default function DownloadCenter() {
nav rail and the tab title name the page, the shell's hidden h1 names it to a screen reader. */}
<PageToolbar>
<div className="px-4 pt-2 pb-2 max-w-4xl w-full mx-auto flex flex-wrap items-center justify-between gap-2">
<Tabs tabs={tabs} active={tab} onChange={setTab} className="flex flex-wrap gap-1" />
<Tabs
idBase={panel.idBase}
tabs={tabs}
active={tab}
onChange={setTab}
className="flex flex-wrap gap-1"
/>
<Button
variant="ghost"
size="sm"
@@ -159,7 +167,7 @@ export default function DownloadCenter() {
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
<div {...panel.panelProps(tab)} className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
{tab === "queue" && (
<>
{/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
+4 -1
View File
@@ -24,6 +24,7 @@ import { useWizard } from "./wizardContext";
import { START_ON_HOME } from "./navigationInit";
import { usePrefs } from "./prefsContext";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { useMe } from "../lib/useMe";
import { Pressable } from "./ui/Pressable";
import { Slider } from "./ui/NativeControls";
@@ -68,6 +69,7 @@ export default function SettingsPanel() {
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
const { openWizard } = useWizard();
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
const panel = useTabPanel();
// Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
? (tabRaw as TabId)
@@ -80,6 +82,7 @@ export default function SettingsPanel() {
{/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see
Tabs). Active is a clear accent pill. */}
<Tabs
idBase={panel.idBase}
orientation="vertical"
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-36 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
active={tab}
@@ -95,7 +98,7 @@ export default function SettingsPanel() {
{/* Render only the active tab so the panel sizes to its actual content. (Stacking
every tab in one grid cell made the whole panel as tall as the tallest tab —
Account — leaving the short tabs with dead space and a needless scrollbar.) */}
<div className="flex-1 min-w-0 p-4">
<div {...panel.panelProps(tab)} className="flex-1 min-w-0 p-4">
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
+5 -1
View File
@@ -13,6 +13,7 @@ import Tooltip from "./ui/Tooltip";
import { LoadingState, StateMessage } from "./ui/QueryState";
import FilterChipGroup from "./ui/FilterChipGroup";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { PageToolbar } from "./ui/PageShell";
import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe";
@@ -34,15 +35,18 @@ export default function Stats() {
const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
const panel = useTabPanel();
return (
<div className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
// A tab panel only where there are tabs: everyone but an admin sees the Overview alone.
<div {...(isAdmin ? panel.panelProps(tab) : {})} className="px-4 pb-4 pt-3 max-w-4xl mx-auto">
{/* Only admins have a second (System) tab; everyone else just sees the Overview. The tabs are
the page's band (B4), so they stay in reach on the long System tab. */}
{isAdmin && (
<PageToolbar>
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<Tabs
idBase={panel.idBase}
active={tab}
onChange={setTab}
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
@@ -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),
}),
};
}
+6 -5
View File
@@ -89,10 +89,10 @@
not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle, whose
solid accent is its state and must not turn grey under the pointer. */
@utility state-layer {
&:hover:not(:disabled, [aria-disabled="true"], [aria-pressed="true"]) {
&:hover:not(:disabled, [aria-disabled="true"], [aria-pressed="true"], [aria-selected="true"]) {
background-color: var(--hover);
}
&:active:not(:disabled, [aria-disabled="true"], [aria-pressed="true"]) {
&:active:not(:disabled, [aria-disabled="true"], [aria-pressed="true"], [aria-selected="true"]) {
background-color: var(--press);
}
}
@@ -674,8 +674,8 @@ body {
The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME
bg-accent fill via an inline ternary (`sel ? "bg-accent text-accent-fg border-accent" : "…"`),
which drifted over time. These recipes OWN that paint, keyed on the semantic ARIA the control
already carries — the JSX just toggles `aria-pressed` (a segment/toggle) or `aria-current` (the
single current item) and the CSS paints, so one control's selected look can never diverge from
already carries — the JSX just toggles `aria-pressed` (a segment/toggle), `aria-selected` (a tab,
ui/Tabs) or `aria-current` (the single current item) and the CSS paints, so one control's selected look can never diverge from
the next's. Geometry (padding / radius / border-width / font) stays a per-site utility since it
legitimately varies. NO focus-visible here on purpose: the `@layer base` rule above already rings
every button/a/[role]; re-adding it would duplicate that (an R9 review finding). Because these sit
@@ -705,7 +705,8 @@ body {
}
/* selected — SOLID accent fill (the dominant idiom). (The nav rail's "you are here" is ui/NavRail's
tint + edge bar since E28 S3d; its old `.is-current` recipe is gone.) */
.pill[aria-pressed="true"] {
.pill[aria-pressed="true"],
.pill[role="tab"][aria-selected="true"] {
background-color: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
+1 -1
View File
@@ -72,7 +72,7 @@ it("the state layers stand down on a disabled, busy or pressed control", () => {
for (const [, state, sel] of states) expect(sel, `${name} :${state}`).toMatch(guard);
}
expect(css.slice(css.indexOf("@utility state-layer {"))).toMatch(
/&:hover:not\([^)]*\[aria-pressed="true"\]\)/,
/&:hover:not\([^)]*\[aria-pressed="true"\][^)]*\)/,
);
});