diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index 5698bbaa..d4433a36 100644
--- a/frontend/src/components/Playlists.tsx
+++ b/frontend/src/components/Playlists.tsx
@@ -46,7 +46,13 @@ import { usePref } from "../lib/usePref";
import { navItemAttrs } from "../lib/listNav";
import { ColumnHead } from "./ui/tableHeader";
import { alignClass } from "./ui/tableAlign";
-import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
+import {
+ columnHideClass,
+ filterRows,
+ isActive,
+ type Column,
+ type FilterMap,
+} from "../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
@@ -127,7 +133,7 @@ function Row({
{col.key === GRIP_KEY ? (
// The one cell the column defs can't render: the handle needs this row's sortable
diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx
index a8ffddb5..de5d3939 100644
--- a/frontend/src/components/channelColumns.tsx
+++ b/frontend/src/components/channelColumns.tsx
@@ -88,6 +88,8 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.priority,
@@ -127,6 +129,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column,
},
@@ -157,6 +160,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.total_duration_seconds,
render: (c) => (
diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx
index c2c027de..de0aea38 100644
--- a/frontend/src/components/ui/DataTable.tsx
+++ b/frontend/src/components/ui/DataTable.tsx
@@ -6,7 +6,7 @@ import { hasPagerContent } from "./pagerContent";
import { PageToolbar } from "./PageShell";
import { ColumnHead } from "./tableHeader";
import { alignClass } from "./tableAlign";
-import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
+import { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { SURFACE } from "./surfaces";
@@ -286,7 +286,8 @@ export default function DataTable({
DOM query two copies of each row. `md` (48rem) is the breakpoint the CSS used.
Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
{wide ? (
-
+ // A container, so a column hides by the table's own width (`tabletHidden`), not the window's.
+
{/* In fixed-layout mode a colgroup drives the column widths: those with an explicit
`width` keep it, and the unspecified (fluid) columns split the remainder — so the
@@ -294,7 +295,11 @@ export default function DataTable({
{fixedLayout && (
{columns.map((col) => (
-
+
))}
)}
@@ -319,7 +324,7 @@ export default function DataTable({
{col.render(row)}
diff --git a/frontend/src/components/ui/Pager.dom.test.tsx b/frontend/src/components/ui/Pager.dom.test.tsx
index d364a5e4..3221debf 100644
--- a/frontend/src/components/ui/Pager.dom.test.tsx
+++ b/frontend/src/components/ui/Pager.dom.test.tsx
@@ -111,27 +111,63 @@ describe("Pager · the page box follows the page without an effect", () => {
});
});
-// E28 S3f3: on a phone the row ran 18px past a 375 screen. The arrows keep their names (a screen
-// reader still hears Previous / Next) and only the visible words step aside below `sm`; the size label
-// stays one line rather than a word per line.
-describe("Pager · on a phone", () => {
- it("hides only the visible words of Previous / Next, never their names", () => {
+// Glyphs only (the owner, 2026-09-27): First / Previous / Next / Last are four arrows that carry their
+// names as the accessible name and the tooltip, and show no words at any width (the words once pushed
+// a phone row 18px past a 375 screen). The size label stays one line rather than a word per line.
+describe("Pager · four named arrows", () => {
+ function renderAt(page: number, onPageChange = vi.fn()) {
render(
{}}
+ onPageChange={onPageChange}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>,
);
- for (const name of ["datatable.pager.prev", "datatable.pager.next"]) {
+ return onPageChange;
+ }
+ const NAMES = ["first", "prev", "next", "last"].map((k) => `datatable.pager.${k}`);
+
+ it("draws each as a glyph with its name as the tooltip, and no visible word", () => {
+ renderAt(5);
+ for (const name of NAMES) {
const btn = screen.getByRole("button", { name });
- const word = [...btn.querySelectorAll("span")].find((s) => s.textContent === name)!;
- expect(word.className.split(/\s+/)).toContain("max-sm:sr-only");
+ expect(btn.getAttribute("title")).toBe(name);
+ expect(btn.textContent).toBe("");
+ expect(btn.querySelector("svg")).not.toBeNull();
}
expect(screen.getByText("Rows per page").className).toContain("whitespace-nowrap");
});
+
+ it("jumps to the first, previous, next and last page", () => {
+ const onPageChange = renderAt(5);
+ for (const name of NAMES) fireEvent.click(screen.getByRole("button", { name }));
+ expect(onPageChange.mock.calls).toEqual([[0], [4], [6], [11]]);
+ });
+
+ it("turns off the pair that points past an edge", () => {
+ renderAt(0);
+ const off = (k: string) =>
+ (screen.getByRole("button", { name: `datatable.pager.${k}` }) as HTMLButtonElement)
+ .disabled ||
+ screen.getByRole("button", { name: `datatable.pager.${k}` }).getAttribute("aria-disabled") ===
+ "true";
+ expect([off("first"), off("prev"), off("next"), off("last")]).toEqual([
+ true,
+ true,
+ false,
+ false,
+ ]);
+ cleanup();
+ renderAt(11);
+ expect([off("first"), off("prev"), off("next"), off("last")]).toEqual([
+ false,
+ false,
+ true,
+ true,
+ ]);
+ });
});
diff --git a/frontend/src/components/ui/Pager.tsx b/frontend/src/components/ui/Pager.tsx
index 8979ca5a..2b044235 100644
--- a/frontend/src/components/ui/Pager.tsx
+++ b/frontend/src/components/ui/Pager.tsx
@@ -1,12 +1,12 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { ChevronLeft, ChevronRight } from "lucide-react";
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent";
import SegmentedToggle from "./SegmentedToggle";
-import { Button } from "./Button";
+import { IconButton } from "./IconButton";
import { Input } from "./Input";
-// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
+// The First / Prev / page-input / Next / Last + "N per page" cluster, lifted out of DataTable so a non-table view
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
// size and slices its own rows; this only renders the controls and reports changes. `sizeLabel` lets
// the caller say "Rows per page" or "Cards per page". Returns null when there's nothing to page.
@@ -67,17 +67,24 @@ export default function Pager({
{totalPages > 1 && (
- }
+ label={t("datatable.pager.first")}
+ onClick={() => goToPage(0)}
+ disabled={safePage === 0}
+ >
+
+
+ goToPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
>
- {/* A phone keeps the arrows and the page box: the two words pushed the row 18px past a
- 375 screen (E28 S3f3). The name stays for a screen reader. */}
- {t("datatable.pager.prev")}
-
+
+
{t("datatable.pager.pageLabel")}
{t("datatable.pager.ofTotal", { total: totalPages })}
-
+
+
+ goToPage(totalPages - 1)}
+ disabled={safePage >= totalPages - 1}
+ >
+
+
)}
{total > 0 && (
diff --git a/frontend/src/components/ui/tableHeader.tsx b/frontend/src/components/ui/tableHeader.tsx
index 4b037af4..e80a9b1f 100644
--- a/frontend/src/components/ui/tableHeader.tsx
+++ b/frontend/src/components/ui/tableHeader.tsx
@@ -6,7 +6,7 @@ import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { useScrollFade } from "../../lib/useScrollFade";
import { type SortState } from "../../lib/columnSort";
-import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
+import { columnHideClass, isActive, type Column, type FilterMap } from "../../lib/tableColumns";
import { alignClass } from "./tableAlign";
import { useStickyTop } from "./stickyTop";
import { Button } from "./Button";
@@ -214,7 +214,7 @@ export function ColumnHead({
{
// no width. The table's own width, not the viewport's (an `@container` round it): beside the
// playlist rail a desktop window gave the table 432 px, and the title column was 0 px wide.
narrowHidden?: boolean;
+ // …and one a table leaves out while it is narrower than 64rem — a 1024 tablet window, or a desktop
+ // one beside a panel: a secondary column that gives its room back to the name (the owner,
+ // 2026-09-27: at 1024 the channel name had 60 px while About took as much). Same `@container` rule.
+ tabletHidden?: boolean;
+}
+
+// The classes that hide a column below its width (`narrowHidden` / `tabletHidden`), for its cells and,
+// in a fixed layout, for its
too: a hidden cell under a
that keeps its width would leave an
+// empty stripe. The table's OWN width decides, so the caller wraps the table in an `@container`.
+// Whole literals, because Tailwind only generates a class it can read in one piece.
+const HIDE_BELOW = {
+ narrow: { cell: " hidden @2xl:table-cell", col: " hidden @2xl:table-column" },
+ tablet: { cell: " hidden @5xl:table-cell", col: " hidden @5xl:table-column" },
+} as const;
+
+export function columnHideClass(
+ col: { narrowHidden?: boolean; tabletHidden?: boolean },
+ part: "cell" | "col" = "cell",
+): string {
+ if (col.narrowHidden) return HIDE_BELOW.narrow[part];
+ if (col.tabletHidden) return HIDE_BELOW.tablet[part];
+ return "";
}
export type FilterMap = Record;