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:
@@ -46,7 +46,13 @@ import { usePref } from "../lib/usePref";
|
|||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { ColumnHead } from "./ui/tableHeader";
|
import { ColumnHead } from "./ui/tableHeader";
|
||||||
import { alignClass } from "./ui/tableAlign";
|
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 { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||||||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||||||
import { DragHandle } from "./ui/DragHandle";
|
import { DragHandle } from "./ui/DragHandle";
|
||||||
@@ -127,7 +133,7 @@ function Row({
|
|||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
style={col.width ? { width: col.width } : undefined}
|
style={col.width ? { width: col.width } : undefined}
|
||||||
className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}${col.narrowHidden ? " hidden @2xl:table-cell" : ""}`}
|
className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}${columnHideClass(col)}`}
|
||||||
>
|
>
|
||||||
{col.key === GRIP_KEY ? (
|
{col.key === GRIP_KEY ? (
|
||||||
// The one cell the column defs can't render: the handle needs this row's sortable
|
// The one cell the column defs can't render: the handle needs this row's sortable
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
|||||||
header: t("channels.cols.priority"),
|
header: t("channels.cols.priority"),
|
||||||
align: "center",
|
align: "center",
|
||||||
width: "5.5rem",
|
width: "5.5rem",
|
||||||
|
// Off on a tablet-wide table, with About and Length: the row's "…" menu sets the priority too.
|
||||||
|
tabletHidden: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
hideInCard: true,
|
hideInCard: true,
|
||||||
sortValue: (c) => c.priority,
|
sortValue: (c) => c.priority,
|
||||||
@@ -127,6 +129,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
|||||||
key: "about",
|
key: "about",
|
||||||
header: t("channels.cols.about"),
|
header: t("channels.cols.about"),
|
||||||
hideInCard: true,
|
hideInCard: true,
|
||||||
|
tabletHidden: true,
|
||||||
// The other FLUID column — AboutCell already truncates to its cell.
|
// The other FLUID column — AboutCell already truncates to its cell.
|
||||||
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||||
},
|
},
|
||||||
@@ -157,6 +160,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
|||||||
align: "right",
|
align: "right",
|
||||||
nowrap: true,
|
nowrap: true,
|
||||||
width: "4.5rem",
|
width: "4.5rem",
|
||||||
|
tabletHidden: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => c.total_duration_seconds,
|
sortValue: (c) => c.total_duration_seconds,
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { hasPagerContent } from "./pagerContent";
|
|||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { ColumnHead } from "./tableHeader";
|
import { ColumnHead } from "./tableHeader";
|
||||||
import { alignClass } from "./tableAlign";
|
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 { navItemAttrs, useListNav } from "../../lib/listNav";
|
||||||
import { useMediaQuery } from "../../lib/useMediaQuery";
|
import { useMediaQuery } from "../../lib/useMediaQuery";
|
||||||
import { SURFACE } from "./surfaces";
|
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.
|
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 screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
||||||
{wide ? (
|
{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" : ""}`}>
|
<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
|
{/* 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
|
`width` keep it, and the unspecified (fluid) columns split the remainder — so the
|
||||||
@@ -294,7 +295,11 @@ export default function DataTable<T>({
|
|||||||
{fixedLayout && (
|
{fixedLayout && (
|
||||||
<colgroup>
|
<colgroup>
|
||||||
{columns.map((col) => (
|
{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>
|
</colgroup>
|
||||||
)}
|
)}
|
||||||
@@ -319,7 +324,7 @@ export default function DataTable<T>({
|
|||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
style={col.width ? { width: col.width } : undefined}
|
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)}
|
{col.render(row)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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
|
// Glyphs only (the owner, 2026-09-27): First / Previous / Next / Last are four arrows that carry their
|
||||||
// reader still hears Previous / Next) and only the visible words step aside below `sm`; the size label
|
// names as the accessible name and the tooltip, and show no words at any width (the words once pushed
|
||||||
// stays one line rather than a word per line.
|
// a phone row 18px past a 375 screen). The size label stays one line rather than a word per line.
|
||||||
describe("Pager · on a phone", () => {
|
describe("Pager · four named arrows", () => {
|
||||||
it("hides only the visible words of Previous / Next, never their names", () => {
|
function renderAt(page: number, onPageChange = vi.fn()) {
|
||||||
render(
|
render(
|
||||||
<Pager
|
<Pager
|
||||||
page={1}
|
page={page}
|
||||||
size={10}
|
size={10}
|
||||||
total={120}
|
total={120}
|
||||||
pageSizeOptions={[10, 25]}
|
pageSizeOptions={[10, 25]}
|
||||||
onPageChange={() => {}}
|
onPageChange={onPageChange}
|
||||||
onSizeChange={() => {}}
|
onSizeChange={() => {}}
|
||||||
sizeLabel="Rows per page"
|
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 btn = screen.getByRole("button", { name });
|
||||||
const word = [...btn.querySelectorAll("span")].find((s) => s.textContent === name)!;
|
expect(btn.getAttribute("title")).toBe(name);
|
||||||
expect(word.className.split(/\s+/)).toContain("max-sm:sr-only");
|
expect(btn.textContent).toBe("");
|
||||||
|
expect(btn.querySelector("svg")).not.toBeNull();
|
||||||
}
|
}
|
||||||
expect(screen.getByText("Rows per page").className).toContain("whitespace-nowrap");
|
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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { hasPagerContent, pageCount } from "./pagerContent";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import { Button } from "./Button";
|
import { IconButton } from "./IconButton";
|
||||||
import { Input } from "./Input";
|
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 +
|
// (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
|
// 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.
|
// 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">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center gap-2">
|
<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"
|
variant="glass"
|
||||||
size="sm"
|
label={t("datatable.pager.first")}
|
||||||
icon={<ChevronLeft />}
|
onClick={() => goToPage(0)}
|
||||||
|
disabled={safePage === 0}
|
||||||
|
>
|
||||||
|
<ChevronsLeft />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant="glass"
|
||||||
|
label={t("datatable.pager.prev")}
|
||||||
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
||||||
disabled={safePage === 0}
|
disabled={safePage === 0}
|
||||||
>
|
>
|
||||||
{/* A phone keeps the arrows and the page box: the two words pushed the row 18px past a
|
<ChevronLeft />
|
||||||
375 screen (E28 S3f3). The name stays for a screen reader. */}
|
</IconButton>
|
||||||
<span className="max-sm:sr-only">{t("datatable.pager.prev")}</span>
|
|
||||||
</Button>
|
|
||||||
<span className="text-muted flex items-center gap-1.5">
|
<span className="text-muted flex items-center gap-1.5">
|
||||||
{t("datatable.pager.pageLabel")}
|
{t("datatable.pager.pageLabel")}
|
||||||
<Input
|
<Input
|
||||||
@@ -94,15 +101,22 @@ export default function Pager({
|
|||||||
/>
|
/>
|
||||||
{t("datatable.pager.ofTotal", { total: totalPages })}
|
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<IconButton
|
||||||
variant="glass"
|
variant="glass"
|
||||||
size="sm"
|
label={t("datatable.pager.next")}
|
||||||
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
||||||
disabled={safePage >= totalPages - 1}
|
disabled={safePage >= totalPages - 1}
|
||||||
>
|
>
|
||||||
<span className="max-sm:sr-only">{t("datatable.pager.next")}</span>
|
<ChevronRight />
|
||||||
<ChevronRight className="w-4 h-4" />
|
</IconButton>
|
||||||
</Button>
|
<IconButton
|
||||||
|
variant="glass"
|
||||||
|
label={t("datatable.pager.last")}
|
||||||
|
onClick={() => goToPage(totalPages - 1)}
|
||||||
|
disabled={safePage >= totalPages - 1}
|
||||||
|
>
|
||||||
|
<ChevronsRight />
|
||||||
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
|||||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||||
import { useScrollFade } from "../../lib/useScrollFade";
|
import { useScrollFade } from "../../lib/useScrollFade";
|
||||||
import { type SortState } from "../../lib/columnSort";
|
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 { alignClass } from "./tableAlign";
|
||||||
import { useStickyTop } from "./stickyTop";
|
import { useStickyTop } from "./stickyTop";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
@@ -214,7 +214,7 @@ export function ColumnHead<T>({
|
|||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
style={col.width ? { width: col.width } : undefined}
|
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={
|
aria-sort={
|
||||||
col.sortable
|
col.sortable
|
||||||
? isSorted
|
? isSorted
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
"noOptions": "No options",
|
"noOptions": "No options",
|
||||||
"empty": "Nothing to show.",
|
"empty": "Nothing to show.",
|
||||||
"pager": {
|
"pager": {
|
||||||
"prev": "Previous",
|
"first": "First page",
|
||||||
"next": "Next",
|
"prev": "Previous page",
|
||||||
|
"next": "Next page",
|
||||||
|
"last": "Last page",
|
||||||
"pageLabel": "Page",
|
"pageLabel": "Page",
|
||||||
"ofTotal": "of {{total}}"
|
"ofTotal": "of {{total}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
"noOptions": "Nincs lehetőség",
|
"noOptions": "Nincs lehetőség",
|
||||||
"empty": "Nincs megjeleníthető elem.",
|
"empty": "Nincs megjeleníthető elem.",
|
||||||
"pager": {
|
"pager": {
|
||||||
"prev": "Előző",
|
"first": "Első oldal",
|
||||||
"next": "Következő",
|
"prev": "Előző oldal",
|
||||||
|
"next": "Következő oldal",
|
||||||
|
"last": "Utolsó oldal",
|
||||||
"pageLabel": "Oldal",
|
"pageLabel": "Oldal",
|
||||||
"ofTotal": "/ {{total}}"
|
"ofTotal": "/ {{total}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ export interface Column<T> {
|
|||||||
// no width. The table's own width, not the viewport's (an `@container` round it): beside the
|
// 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.
|
// playlist rail a desktop window gave the table 432 px, and the title column was 0 px wide.
|
||||||
narrowHidden?: boolean;
|
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 <col> too: a hidden cell under a <col> 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<string, string | string[]>;
|
export type FilterMap = Record<string, string | string[]>;
|
||||||
|
|||||||
Reference in New Issue
Block a user