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:
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,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]")!;
|
||||
|
||||
@@ -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). */}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"\][^)]*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user