Add paginate helper (todo §1); page model with row window + ellipsis sequence for pagination.ejs, clamped/guarded inputs
This commit is contained in:
43
src/paginate.test.ts
Normal file
43
src/paginate.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { paginate, type PageModel } from "./paginate.ts";
|
||||
|
||||
// Compact view of the page sequence: ellipsis → "…", current → "[n]", else the number.
|
||||
const shape = (m: PageModel): (number | string | null)[] =>
|
||||
m.pages.map((p) => (p.ellipsis ? "…" : p.current ? `[${p.page}]` : p.page));
|
||||
|
||||
test("paginate computes the page model: counts, row window, prev/next, page sequence", () => {
|
||||
const m = paginate(1284, 3, 12);
|
||||
assert.deepEqual(
|
||||
{ from: m.from, next: m.next, page: m.page, pageCount: m.pageCount, pageSize: m.pageSize, prev: m.prev, to: m.to, total: m.total },
|
||||
{ from: 25, next: 4, page: 3, pageCount: 107, pageSize: 12, prev: 2, to: 36, total: 1284 },
|
||||
);
|
||||
assert.deepEqual(shape(m), [1, 2, "[3]", 4, "…", 107]);
|
||||
});
|
||||
|
||||
test("paginate clamps out-of-range input, handles the empty list and guards sizes", () => {
|
||||
// Page past the end clamps to the last page; no next.
|
||||
const last = paginate(50, 99, 25);
|
||||
assert.deepEqual([last.page, last.pageCount, last.from, last.to, last.prev, last.next], [2, 2, 26, 50, 1, null]);
|
||||
assert.deepEqual(shape(last), [1, "[2]"]);
|
||||
|
||||
// Empty list → one empty page, row window 0–0, no prev/next.
|
||||
const empty = paginate(0, 1, 25);
|
||||
assert.deepEqual([empty.page, empty.pageCount, empty.from, empty.to, empty.prev, empty.next], [1, 1, 0, 0, null, null]);
|
||||
assert.deepEqual(shape(empty), ["[1]"]);
|
||||
|
||||
// page < 1 → 1; pageSize < 1 coerces to 1; non-finite page → 1.
|
||||
assert.equal(paginate(50, 0, 25).page, 1);
|
||||
assert.equal(paginate(10, 1, 0).pageCount, 10);
|
||||
assert.equal(paginate(50, Number.NaN, 25).page, 1);
|
||||
});
|
||||
|
||||
test("paginate windows the sequence: single gaps fill, wider gaps ellipsize, siblings/boundaries tune it", () => {
|
||||
// A one-page gap is filled, never collapsed to an ellipsis.
|
||||
assert.deepEqual(shape(paginate(70, 4, 10)), [1, 2, 3, "[4]", 5, 6, 7]);
|
||||
// Gaps on both sides → an ellipsis each side.
|
||||
assert.deepEqual(shape(paginate(200, 10, 10)), [1, "…", 9, "[10]", 11, "…", 20]);
|
||||
// Wider sibling window and more boundary pages.
|
||||
assert.deepEqual(shape(paginate(200, 10, 10, { siblings: 2 })), [1, "…", 8, 9, "[10]", 11, 12, "…", 20]);
|
||||
assert.deepEqual(shape(paginate(200, 10, 10, { boundaries: 2 })), [1, 2, "…", 9, "[10]", 11, "…", 19, 20]);
|
||||
});
|
||||
70
src/paginate.ts
Normal file
70
src/paginate.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// paginate (todo §1): pagination math → the model pagination.ejs renders. Pure and
|
||||
// URL-free (README signature `paginate(total, page, pageSize)`); the caller maps each
|
||||
// page number to an href. Inputs are clamped/guarded so it never produces a broken model:
|
||||
// page is pinned to [1, pageCount], total/pageSize coerced to sane integers.
|
||||
|
||||
export interface PageItem {
|
||||
current: boolean;
|
||||
ellipsis: boolean; // a gap; `page` is null
|
||||
page: number | null;
|
||||
}
|
||||
|
||||
export interface PageModel {
|
||||
from: number; // 1-based index of the first row on this page (0 when empty)
|
||||
next: number | null; // next page, or null on the last page
|
||||
page: number; // clamped current page (≥ 1)
|
||||
pageCount: number; // total pages (≥ 1)
|
||||
pageSize: number; // effective page size (≥ 1)
|
||||
pages: PageItem[]; // page-number sequence with ellipsis gaps
|
||||
prev: number | null; // previous page, or null on the first page
|
||||
to: number; // 1-based index of the last row on this page (0 when empty)
|
||||
total: number; // effective total rows (≥ 0)
|
||||
}
|
||||
|
||||
export interface PaginateOptions {
|
||||
boundaries?: number; // pages always shown at each end (default 1)
|
||||
siblings?: number; // pages shown each side of the current page (default 1)
|
||||
}
|
||||
|
||||
export function paginate(total: number, page: number, pageSize: number, options: PaginateOptions = {}): PageModel {
|
||||
const t = Math.max(0, Math.floor(total) || 0);
|
||||
const size = Math.max(1, Math.floor(pageSize) || 0);
|
||||
const pageCount = Math.max(1, Math.ceil(t / size));
|
||||
const reqPage = Number.isFinite(page) ? Math.floor(page) : 1;
|
||||
const current = Math.min(Math.max(reqPage, 1), pageCount);
|
||||
return {
|
||||
from: t === 0 ? 0 : (current - 1) * size + 1,
|
||||
next: current < pageCount ? current + 1 : null,
|
||||
page: current,
|
||||
pageCount,
|
||||
pageSize: size,
|
||||
pages: pageItems(current, pageCount, options.siblings ?? 1, options.boundaries ?? 1),
|
||||
prev: current > 1 ? current - 1 : null,
|
||||
to: Math.min(current * size, t),
|
||||
total: t,
|
||||
};
|
||||
}
|
||||
|
||||
const item = (page: number, current = false): PageItem => ({ current, ellipsis: false, page });
|
||||
const gap = (): PageItem => ({ current: false, ellipsis: true, page: null });
|
||||
|
||||
// First/last `boundaries` pages + a `siblings`-wide window around current, deduped and sorted;
|
||||
// gaps wider than one page become an ellipsis, a lone missing page is shown instead.
|
||||
function pageItems(current: number, pageCount: number, siblings: number, boundaries: number): PageItem[] {
|
||||
const show = new Set<number>();
|
||||
const add = (n: number): void => { if (n >= 1 && n <= pageCount) show.add(n); };
|
||||
for (let i = 1; i <= boundaries; i++) { add(i); add(pageCount - i + 1); }
|
||||
for (let n = current - siblings; n <= current + siblings; n++) add(n);
|
||||
|
||||
const items: PageItem[] = [];
|
||||
let prev = 0;
|
||||
for (const n of [...show].sort((a, b) => a - b)) {
|
||||
if (prev) {
|
||||
if (n - prev === 2) items.push(item(prev + 1)); // single hole → show the page
|
||||
else if (n - prev > 2) items.push(gap());
|
||||
}
|
||||
items.push(item(n, n === current));
|
||||
prev = n;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
Reference in New Issue
Block a user