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 <col>. The Channels table drops Prio, About and
Length there: at 1024 the channel name had 60 px.
This commit is contained in:
2026-09-27 07:12:51 +02:00
parent d3e1d8219c
commit 955c23d321
9 changed files with 128 additions and 37 deletions
+9 -4
View File
@@ -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<T>({
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 ? (
<div>
// A container, so a column hides by the table's own width (`tabletHidden`), not the window's.
<div className="@container">
<table className={`w-full border-collapse text-sm ${fixedLayout ? "table-fixed" : ""}`}>
{/* 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<T>({
{fixedLayout && (
<colgroup>
{columns.map((col) => (
<col key={col.key} style={col.width ? { width: col.width } : undefined} />
<col
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={columnHideClass(col, "col") || undefined}
/>
))}
</colgroup>
)}
@@ -319,7 +324,7 @@ export default function DataTable<T>({
<td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}${columnHideClass(col)}`}
>
{col.render(row)}
</td>
+46 -10
View File
@@ -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(
<Pager
page={1}
page={page}
size={10}
total={120}
pageSizeOptions={[10, 25]}
onPageChange={() => {}}
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,
]);
});
});
+29 -15
View File
@@ -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({
<div className="flex items-center gap-3 flex-wrap">
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
{/* Glyphs only (the owner, 2026-09-27): the words made the row wide, and the four arrows are the
convention every pager shares. Each keeps its name as the tooltip and the accessible name. */}
<IconButton
variant="glass"
size="sm"
icon={<ChevronLeft />}
label={t("datatable.pager.first")}
onClick={() => goToPage(0)}
disabled={safePage === 0}
>
<ChevronsLeft />
</IconButton>
<IconButton
variant="glass"
label={t("datatable.pager.prev")}
onClick={() => 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. */}
<span className="max-sm:sr-only">{t("datatable.pager.prev")}</span>
</Button>
<ChevronLeft />
</IconButton>
<span className="text-muted flex items-center gap-1.5">
{t("datatable.pager.pageLabel")}
<Input
@@ -94,15 +101,22 @@ export default function Pager({
/>
{t("datatable.pager.ofTotal", { total: totalPages })}
</span>
<Button
<IconButton
variant="glass"
size="sm"
label={t("datatable.pager.next")}
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
>
<span className="max-sm:sr-only">{t("datatable.pager.next")}</span>
<ChevronRight className="w-4 h-4" />
</Button>
<ChevronRight />
</IconButton>
<IconButton
variant="glass"
label={t("datatable.pager.last")}
onClick={() => goToPage(totalPages - 1)}
disabled={safePage >= totalPages - 1}
>
<ChevronsRight />
</IconButton>
</div>
)}
{total > 0 && (
+2 -2
View File
@@ -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<T>({
<th
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`relative font-medium px-2 py-2 whitespace-nowrap bg-bg ${alignClass(col.align)}${col.narrowHidden ? " hidden @2xl:table-cell" : ""}`}
className={`relative font-medium px-2 py-2 whitespace-nowrap bg-bg ${alignClass(col.align)}${columnHideClass(col)}`}
aria-sort={
col.sortable
? isSorted