pagenation

This commit is contained in:
2026-09-07 11:00:44 +05:30
parent d3785cc303
commit 8defbd418f
22 changed files with 1094 additions and 226 deletions

View 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>
);
}

View 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;
}
}

View 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
View 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),
};
}