From 955c23d321966c7b6b983fe07eccdfd8afca305e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 07:12:51 +0200 Subject: [PATCH] feat(tables): a glyph pager with first/last, and tablet-width columns step aside The pager is four named arrows round the page box (first, previous, next, last). A table hides a tabletHidden column below 64rem of its own width, as narrowHidden does below 42rem, through one shared helper that also hides the column's . The Channels table drops Prio, About and Length there: at 1024 the channel name had 60 px. --- frontend/src/components/Playlists.tsx | 10 +++- frontend/src/components/channelColumns.tsx | 4 ++ frontend/src/components/ui/DataTable.tsx | 13 +++-- frontend/src/components/ui/Pager.dom.test.tsx | 56 +++++++++++++++---- frontend/src/components/ui/Pager.tsx | 44 ++++++++++----- frontend/src/components/ui/tableHeader.tsx | 4 +- frontend/src/i18n/locales/en/datatable.json | 6 +- frontend/src/i18n/locales/hu/datatable.json | 6 +- frontend/src/lib/tableColumns.ts | 22 ++++++++ 9 files changed, 128 insertions(+), 37 deletions(-) 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({ 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 && (
- + + {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({
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;
{col.render(row)}