pagenation
This commit is contained in:
49
src/components/TablePager.tsx
Normal file
49
src/components/TablePager.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import type { Paged } from './usePaged';
|
||||
import { PAGE_SIZES } from './usePaged';
|
||||
import './tablePager.css';
|
||||
|
||||
/**
|
||||
* The pager that sits under a table.
|
||||
*
|
||||
* One component so every table in the console counts, labels and behaves the
|
||||
* same way, rather than each page inventing its own row of buttons.
|
||||
*
|
||||
* ── It hides itself ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* Nothing renders while everything fits on one page. That is what makes it safe
|
||||
* to put under EVERY table, including the ones that usually hold four rows: a
|
||||
* pager reading "1–4 of 4" next to a dead prev/next pair is noise, and noise
|
||||
* under every table is worse than no pager at all. It appears exactly when it
|
||||
* has something to offer.
|
||||
*
|
||||
* `variant="count"` — "21–40 of 96" — rather than a strip of page numbers.
|
||||
* With 25 rows a page a busy day is four pages, and the number an operator
|
||||
* actually wants is how much is left, not which of four buttons is lit.
|
||||
*/
|
||||
export function TablePager({
|
||||
paged,
|
||||
label = 'rows',
|
||||
}: {
|
||||
paged: Paged<unknown>;
|
||||
/** What is being counted, for the screen-reader label: "orders", "products". */
|
||||
label?: string;
|
||||
}) {
|
||||
if (paged.totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="table-pager">
|
||||
<Pagination
|
||||
page={paged.page}
|
||||
onChange={paged.setPage}
|
||||
totalItems={paged.total}
|
||||
pageSize={paged.pageSize}
|
||||
pageSizeOptions={PAGE_SIZES}
|
||||
onPageSizeChange={paged.setPageSize}
|
||||
variant="count"
|
||||
size="sm"
|
||||
label={`Page through ${label}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/tablePager.css
Normal file
24
src/components/tablePager.css
Normal file
@@ -0,0 +1,24 @@
|
||||
/* ══ The pager under a table ═══════════════════════════════════════════════
|
||||
Sits inside the table's own card, below the last row, so it reads as part of
|
||||
the table rather than as a separate control floating beneath it. */
|
||||
|
||||
.table-pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
/* The rule is the seam between the last row and the controls. Rows already
|
||||
draw their own bottom border, so this only shows where the table ends. */
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
/* On a narrow window the count and the buttons stop fitting side by side.
|
||||
Centred rather than left-aligned once wrapped, so the two lines read as one
|
||||
block instead of a ragged edge. */
|
||||
@media (max-width: 560px) {
|
||||
.table-pager {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
104
src/components/usePaged.test.ts
Normal file
104
src/components/usePaged.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from './usePaged';
|
||||
|
||||
/**
|
||||
* `usePaged` is a hook, and there is no React renderer in this suite — the repo
|
||||
* runs `tsx --test`, not jsdom. So the arithmetic it depends on is written here
|
||||
* as the pure function the hook applies, and asserted directly.
|
||||
*
|
||||
* That is worth doing rather than skipping: every bug this hook exists to
|
||||
* prevent is an arithmetic one at a boundary — an empty list, a list that
|
||||
* shrinks under a page, a page size that divides exactly.
|
||||
*/
|
||||
|
||||
interface Slice {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
from: number;
|
||||
to: number;
|
||||
rows: number[];
|
||||
}
|
||||
|
||||
/** Mirrors the derivation in `usePaged`, including the clamp. */
|
||||
function slice(rows: readonly number[], requestedPage: number, pageSize: number): Slice {
|
||||
const total = rows.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const page = Math.min(requestedPage, totalPages);
|
||||
return {
|
||||
page,
|
||||
totalPages,
|
||||
from: total === 0 ? 0 : (page - 1) * pageSize + 1,
|
||||
to: Math.min(page * pageSize, total),
|
||||
rows: rows.slice((page - 1) * pageSize, page * pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
const upTo = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
|
||||
|
||||
test('a full first page', () => {
|
||||
const out = slice(upTo(96), 1, 25);
|
||||
assert.equal(out.totalPages, 4);
|
||||
assert.deepEqual([out.from, out.to], [1, 25]);
|
||||
assert.equal(out.rows[0], 1);
|
||||
assert.equal(out.rows.at(-1), 25);
|
||||
});
|
||||
|
||||
test('a middle page counts from the right place', () => {
|
||||
// The off-by-one everybody writes at least once.
|
||||
const out = slice(upTo(96), 3, 25);
|
||||
assert.deepEqual([out.from, out.to], [51, 75]);
|
||||
assert.equal(out.rows[0], 51);
|
||||
});
|
||||
|
||||
test('the last page is short, and says so', () => {
|
||||
const out = slice(upTo(96), 4, 25);
|
||||
assert.deepEqual([out.from, out.to], [76, 96]);
|
||||
assert.equal(out.rows.length, 21);
|
||||
});
|
||||
|
||||
test('an exact multiple does not produce a trailing empty page', () => {
|
||||
// 100 rows at 25 is four pages, not five.
|
||||
assert.equal(slice(upTo(100), 1, 25).totalPages, 4);
|
||||
});
|
||||
|
||||
test('no rows is one page reading 0–0, not zero pages', () => {
|
||||
/*
|
||||
`Math.ceil(0 / 25)` is 0, and a totalPages of 0 makes the pager render "page 1
|
||||
of 0" and every control dead. One empty page is the honest shape.
|
||||
*/
|
||||
const out = slice([], 1, 25);
|
||||
assert.equal(out.totalPages, 1);
|
||||
assert.deepEqual([out.from, out.to], [0, 0]);
|
||||
assert.deepEqual(out.rows, []);
|
||||
});
|
||||
|
||||
test('a list that shrinks under you clamps instead of going blank', () => {
|
||||
/*
|
||||
The bug this hook exists for. Sitting on page 4, somebody narrows the filter
|
||||
to nine results. Slicing at the requested page would read rows 76–100 of a
|
||||
nine-row list and render an empty table with the controls saying page 4 —
|
||||
which looks exactly like the data vanished.
|
||||
*/
|
||||
const out = slice(upTo(9), 4, 25);
|
||||
assert.equal(out.page, 1, 'clamped to the last page that exists');
|
||||
assert.equal(out.rows.length, 9);
|
||||
assert.deepEqual([out.from, out.to], [1, 9]);
|
||||
});
|
||||
|
||||
test('clamping lands on the LAST page, not always the first', () => {
|
||||
// 60 rows at 25 is three pages; from page 9 you belong on 3, not on 1.
|
||||
const out = slice(upTo(60), 9, 25);
|
||||
assert.equal(out.page, 3);
|
||||
assert.deepEqual([out.from, out.to], [51, 60]);
|
||||
});
|
||||
|
||||
test('every offered page size divides the work sensibly', () => {
|
||||
// Guards the constants themselves: a 0 or a negative here would make
|
||||
// totalPages Infinity and hang the pager.
|
||||
for (const size of PAGE_SIZES) {
|
||||
assert.ok(size > 0 && Number.isInteger(size), `${size} is a usable page size`);
|
||||
assert.equal(slice(upTo(100), 1, size).rows.length, Math.min(size, 100));
|
||||
}
|
||||
assert.ok(PAGE_SIZES.includes(DEFAULT_PAGE_SIZE), 'the default is one of the choices');
|
||||
});
|
||||
112
src/components/usePaged.ts
Normal file
112
src/components/usePaged.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Paging for a table, over rows already in hand.
|
||||
*
|
||||
* ── Why client-side ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* Fiesta pages properly — `pageno` genuinely shifts the window, verified
|
||||
* against `getorders`. What it does NOT return is a total: the envelope carries
|
||||
* `code`, `details`, `message`, `status` and nothing else. So a server-paged
|
||||
* table could offer next/prev and never honestly say "of 12 pages", and could
|
||||
* not tell a last page from an empty one until it fetched it.
|
||||
*
|
||||
* Every list here already fetches a bounded window (200 rows, 500 for the
|
||||
* customer book) and renders all of it. Paging that window client-side gives a
|
||||
* real total, real page numbers, instant page turns, and works identically for
|
||||
* the tables that have no server paging at all — grouped dispatch stops,
|
||||
* reports, anything derived. When a table outgrows its fetch window the answer
|
||||
* is to raise the window or move that ONE table to cursor paging with
|
||||
* `hasMore`, not to make every table pretend.
|
||||
*
|
||||
* ── What this hook is actually for ──────────────────────────────────────────
|
||||
*
|
||||
* The slicing is the trivial part. The part worth having in one place, tested,
|
||||
* is what happens when the rows underneath change — which is where hand-rolled
|
||||
* paging goes wrong: you filter down to three results while on page 5 and the
|
||||
* table renders empty with no way back.
|
||||
*/
|
||||
|
||||
export interface Paged<T> {
|
||||
/** 1-based, matching the design system's Pagination. */
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
pageSize: number;
|
||||
setPageSize: (size: number) => void;
|
||||
/** Just this page's rows. */
|
||||
rows: T[];
|
||||
/** Every row, before slicing. */
|
||||
total: number;
|
||||
totalPages: number;
|
||||
/** 1-based inclusive range on screen, for "showing 21–40 of 96". Zero when empty. */
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
/** The choices offered in the page-size selector. */
|
||||
export const PAGE_SIZES = [10, 25, 50, 100];
|
||||
|
||||
export function usePaged<T>(
|
||||
rows: readonly T[],
|
||||
options: {
|
||||
pageSize?: number;
|
||||
/**
|
||||
* Changing this sends the table back to page 1.
|
||||
*
|
||||
* Clamping alone is not enough. Switching branch, day or status tab can
|
||||
* hand back a DIFFERENT set of rows that happens to be at least as long as
|
||||
* the old one — nothing to clamp — and the operator is left reading page 4
|
||||
* of something they just started looking at. Pass whatever identifies the
|
||||
* query: a day, a branch id, a status, or a template string of several.
|
||||
*/
|
||||
resetKey?: string | number;
|
||||
} = {},
|
||||
): Paged<T> {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(options.pageSize ?? DEFAULT_PAGE_SIZE);
|
||||
|
||||
const total = rows.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const { resetKey } = options;
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [resetKey]);
|
||||
|
||||
/*
|
||||
Clamped on the way out as well as reset above.
|
||||
|
||||
A row set can shrink under a page without the query changing at all — a
|
||||
delivery gets marked delivered and leaves the tab, someone types another
|
||||
letter into the search. Reading `page` directly would then slice past the end
|
||||
and render an empty table on page 5 of 2, which looks like the data
|
||||
disappeared. Deriving the safe page rather than setting state in an effect
|
||||
also means the correct rows render on the FIRST pass, with no empty frame in
|
||||
between.
|
||||
*/
|
||||
const safePage = Math.min(page, totalPages);
|
||||
|
||||
const pageRows = useMemo(
|
||||
() => rows.slice((safePage - 1) * pageSize, safePage * pageSize),
|
||||
[rows, safePage, pageSize],
|
||||
);
|
||||
|
||||
return {
|
||||
page: safePage,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize: (size: number) => {
|
||||
// Back to the first page: keeping the number would land you somewhere
|
||||
// unrelated, since page 4 of 10-per-page is page 1 of 50-per-page.
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
},
|
||||
rows: pageRows,
|
||||
total,
|
||||
totalPages,
|
||||
from: total === 0 ? 0 : (safePage - 1) * pageSize + 1,
|
||||
to: Math.min(safePage * pageSize, total),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user