From 922d589b094106825c14ebc35105c54f1ae0241f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 14:22:37 +0200 Subject: [PATCH] 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. --- frontend/src/components/AdminUsers.tsx | 6 +- frontend/src/components/ChannelPage.tsx | 79 ++++---- frontend/src/components/Channels.tsx | 173 +++++++++--------- frontend/src/components/ConfigPanel.tsx | 11 +- .../DownloadCenter.confirm.dom.test.tsx | 2 +- .../DownloadCenter.menu.dom.test.tsx | 2 +- frontend/src/components/DownloadCenter.tsx | 12 +- frontend/src/components/SettingsPanel.tsx | 5 +- frontend/src/components/Stats.tsx | 6 +- frontend/src/components/ui/Tabs.dom.test.tsx | 64 +++++++ frontend/src/components/ui/Tabs.tsx | 56 +++++- frontend/src/components/ui/tabPanel.ts | 21 +++ frontend/src/index.css | 11 +- frontend/src/lib/theme.contract.test.ts | 2 +- 14 files changed, 311 insertions(+), 139 deletions(-) create mode 100644 frontend/src/components/ui/Tabs.dom.test.tsx create mode 100644 frontend/src/components/ui/tabPanel.ts diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 5005b81e..20157888 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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. */}
- +
-
+
{active === "roles" && } {active === "access" && ( <> diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 0407f21e..86a400a3 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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. */}
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. */} - {tab === "videos" ? ( - <> - {}} - onExitYtSearch={() => {}} - channelScoped - /> - {!ch?.subscribed && nextToken && ( -
- -
- )} - - ) : ( -
- {ch ? ( - - ) : ( -

{t("channel.noDescription")}

- )} -
- )} +
+ {tab === "videos" ? ( + <> + {}} + onExitYtSearch={() => {}} + channelScoped + /> + {!ch?.subscribed && nextToken && ( +
+ +
+ )} + + ) : ( +
+ {ch ? ( + + ) : ( +

{t("channel.noDescription")}

+ )} +
+ )} +
); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index b8e0c40f..bb240d3c 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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 = { 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 = (
setView(id as ChannelsView)} @@ -653,97 +656,99 @@ export default function Channels() { setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )} - {view === "discovery" ? ( - - ) : view === "blocked" ? ( - /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */ -
- } - title={t("channels.blocked.title")} - hint={t("channels.blocked.hint")} - emptyText={t("channels.blocked.empty")} - actionLabel={t("channel.unblock")} - actionIcon={} - onAction={(id) => unblock.mutate(id)} - onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} +
+ {view === "discovery" ? ( + -
- ) : 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. */ +
+ } + title={t("channels.blocked.title")} + hint={t("channels.blocked.hint")} + emptyText={t("channels.blocked.empty")} + actionLabel={t("channel.unblock")} + actionIcon={} + onAction={(id) => unblock.mutate(id)} + onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} + /> +
+ ) : 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). */ -
- } - title={t("channels.hidden.title")} - hint={t("channels.hidden.hint")} - emptyText={t("channels.hidden.empty")} - actionLabel={t("channels.hidden.unhide")} - actionIcon={} - onAction={channelActions.unhide} - onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} - /> -
- ) : ( - /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when +
+ } + title={t("channels.hidden.title")} + hint={t("channels.hidden.hint")} + emptyText={t("channels.hidden.empty")} + actionLabel={t("channels.hidden.unhide")} + actionIcon={} + onAction={channelActions.unhide} + onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} + /> +
+ ) : ( + /* 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). */ -
- {/* Channel table — only the rows scroll; the header row is sticky, and the controls row +
+ {/* 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 ? ( - channelsQuery.refetch()}> - {t("common.loadError")} - - ) : channelsQuery.isLoading ? ( - - ) : layout === "table" ? ( - 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} - /> - ) : ( - c.id} - leadKey="priority" - aboutKey="about" - moreKey="actions" - emptyText={t("channels.empty")} - rowProps={rowMenu.triggerProps} - rowClassName={() => "group"} - /> - )} - -
- )} + {channelsQuery.isError && !channelsQuery.data ? ( + channelsQuery.refetch()}> + {t("common.loadError")} + + ) : channelsQuery.isLoading ? ( + + ) : layout === "table" ? ( + 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} + /> + ) : ( + c.id} + leadKey="priority" + aboutKey="about" + moreKey="actions" + emptyText={t("channels.empty")} + rowProps={rowMenu.triggerProps} + rowClassName={() => "group"} + /> + )} + +
+ )} +
); } diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index d2fde2d6..e9b6e28a 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -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. */}
- +

{t("config.intro")}

{activeGroup && ( -
+
{(data.groups[activeGroup] ?? []).map((item) => ( { 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)); diff --git a/frontend/src/components/DownloadCenter.menu.dom.test.tsx b/frontend/src/components/DownloadCenter.menu.dom.test.tsx index 6b7eb269..7a6695ac 100644 --- a/frontend/src/components/DownloadCenter.menu.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.menu.dom.test.tsx @@ -84,7 +84,7 @@ function mount(tab?: string) { , ); 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("[data-nav-index]")!; diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index bca8487c..432ba064 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -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(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. */}
- +