From b67a5e43eb613b87161936a5cfab52ebc9d40f12 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Fri, 7 Aug 2026 16:41:56 +0530 Subject: [PATCH] feat(ui): build the Pagination component Replaces a scaffold that rendered a bare list of anchors with real page controls: compact arrows or windowed page numbers, a range summary, and a change output carrying the requested page. Out-of-range pages clamp rather than rendering nothing, because page arrives as an HTML attribute and callers compute it from data that may have shrunk. Co-Authored-By: Claude Opus 5 --- .../scripts/showcase-profiles.mjs | 19 ++ packages/ui/components/Pagination.wrn | 259 +++++++++++++++++- packages/ui/test/ui.test.ts | 35 +++ 3 files changed, 303 insertions(+), 10 deletions(-) diff --git a/examples/component-showcase/scripts/showcase-profiles.mjs b/examples/component-showcase/scripts/showcase-profiles.mjs index f36ea355..93ab74e7 100644 --- a/examples/component-showcase/scripts/showcase-profiles.mjs +++ b/examples/component-showcase/scripts/showcase-profiles.mjs @@ -253,6 +253,25 @@ const DATATABLE_ROWS = '[{"id": 1, "name": "Northwind", "plan": "Scale", "owner": "A. Okafor", "seats": 6, "status": "Active", "statusHtml": "Active"}, {"id": 2, "name": "Acme Industrial", "plan": "Team", "owner": "R. Silva", "seats": 13, "status": "Trial", "statusHtml": "Trial"}, {"id": 3, "name": "Globex", "plan": "Enterprise", "owner": "M. Chen", "seats": 20, "status": "Past due", "statusHtml": "Past due"}, {"id": 4, "name": "Initech", "plan": "Starter", "owner": "J. Dubois", "seats": 27, "status": "Active", "statusHtml": "Active"}, {"id": 5, "name": "Umbrella", "plan": "Scale", "owner": "P. Novak", "seats": 34, "status": "Trial", "statusHtml": "Trial"}, {"id": 6, "name": "Stark Labs", "plan": "Team", "owner": "A. Okafor", "seats": 41, "status": "Past due", "statusHtml": "Past due"}, {"id": 7, "name": "Wayne Foods", "plan": "Enterprise", "owner": "R. Silva", "seats": 48, "status": "Active", "statusHtml": "Active"}, {"id": 8, "name": "Soylent", "plan": "Starter", "owner": "M. Chen", "seats": 55, "status": "Trial", "statusHtml": "Trial"}, {"id": 9, "name": "Hooli", "plan": "Scale", "owner": "J. Dubois", "seats": 62, "status": "Past due", "statusHtml": "Past due"}, {"id": 10, "name": "Vehement", "plan": "Team", "owner": "P. Novak", "seats": 69, "status": "Active", "statusHtml": "Active"}, {"id": 11, "name": "Massive Dynamic", "plan": "Enterprise", "owner": "A. Okafor", "seats": 76, "status": "Trial", "statusHtml": "Trial"}, {"id": 12, "name": "Cyberdyne", "plan": "Starter", "owner": "R. Silva", "seats": 83, "status": "Past due", "statusHtml": "Past due"}, {"id": 13, "name": "Tyrell", "plan": "Scale", "owner": "M. Chen", "seats": 90, "status": "Active", "statusHtml": "Active"}, {"id": 14, "name": "Aperture", "plan": "Team", "owner": "J. Dubois", "seats": 97, "status": "Trial", "statusHtml": "Trial"}, {"id": 15, "name": "Black Mesa", "plan": "Enterprise", "owner": "P. Novak", "seats": 104, "status": "Past due", "statusHtml": "Past due"}]'; export const componentProfiles = { + Pagination: { + demos: [ + standard( + "Compact arrows", + "The default: previous and next with a page counter, and a summary of the range in view.", + { page: 2, pageSize: 10, total: 137, variant: "compact" }, + ), + standard( + "Numbered pages", + "Page numbers windowed around the current page with ellipsis gaps, so a large set never renders hundreds of buttons.", + { page: 5, pageSize: 10, total: 200, variant: "numbered", siblingCount: 1 }, + ), + advanced( + "Wider window", + "siblingCount widens how many pages sit either side of the current one.", + { page: 8, pageSize: 25, total: 900, variant: "numbered", siblingCount: 2 }, + ), + ], + }, DataTable: { demos: [ standard( diff --git a/packages/ui/components/Pagination.wrn b/packages/ui/components/Pagination.wrn index 2a55358e..85b14244 100644 --- a/packages/ui/components/Pagination.wrn +++ b/packages/ui/components/Pagination.wrn @@ -1,23 +1,262 @@ +// Pagination -- page controls over a known total. +// +// +// +// The component owns no data. It reports the requested page through its +// change output and lets the caller fetch or slice. +// +// NOTE: the style block uses /* */ comments only -- // is not a CSS comment +// and silently swallows the rule that follows it. component Pagination { outputs { - change(payload: { value?: string | number | boolean | null; values?: Array; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) - previous(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) - next(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object }) + change(payload: { page: number; pageSize: number }) + previous(payload: { page: number }) + next(payload: { page: number }) } props { -size: string = "default" - color: string = "primary" + page: number = 1 + pageSize: number = 10 + total: number = 0 + variant: string = "compact" + siblingCount: number = 1 + showSummary: boolean = true label: string = "Pagination" - items: unknown[] = [] - active: string = "" - orientation: string = "horizontal" + previousLabel: string = "Previous" + nextLabel: string = "Next" class: string = "" } + + functions { + shared function lastPage() { + var size = Number(pageSize) > 0 ? Number(pageSize) : 10 + var count = Number(total) > 0 ? Number(total) : 0 + return Math.max(1, Math.ceil(count / size)) + } + + // Out-of-range values arrive routinely: page travels as an HTML attribute + // and callers compute it from data that may have shrunk underneath them. + shared function currentPage() { + var value = Number(page) + if (!value || value < 1) { + return 1 + } + return Math.min(value, lastPage()) + } + + shared function firstShown() { + if (Number(total) < 1) { + return 0 + } + return (currentPage() - 1) * (Number(pageSize) || 10) + 1 + } + + shared function lastShown() { + return Math.min(currentPage() * (Number(pageSize) || 10), Number(total) || 0) + } + + shared function pageNumbers() { + var last = lastPage() + var current = currentPage() + var siblings = Math.max(0, Number(siblingCount) || 0) + var start = Math.max(1, current - siblings) + var end = Math.min(last, current + siblings) + var pages = [] + if (start > 1) { + pages.push({ value: 1, label: "1", gap: false }) + if (start > 2) { + pages.push({ value: 0, label: "...", gap: true }) + } + } + for (var index = start; index <= end; index += 1) { + pages.push({ value: index, label: String(index), gap: false }) + } + if (end < last) { + if (end < last - 1) { + pages.push({ value: 0, label: "...", gap: true }) + } + pages.push({ value: last, label: String(last), gap: false }) + } + return pages + } + + client function goToPage(target) { + var next = Math.min(Math.max(1, Number(target) || 1), lastPage()) + output.change({ page: next, pageSize: Number(pageSize) || 10 }) + } + + client function goPrevious() { + var target = Math.max(1, currentPage() - 1) + output.previous({ page: target }) + output.change({ page: target, pageSize: Number(pageSize) || 10 }) + } + + client function goNext() { + var target = Math.min(lastPage(), currentPage() + 1) + output.next({ page: target }) + output.change({ page: target, pageSize: Number(pageSize) || 10 }) + } + } + view { -