Skip to content

Commit 0ae511f

Browse files
feat(ui): <Pagination> stx component (P5)
Drops a paginator from @stacksjs/orm into a layout and renders the right UI for whichever variant comes back — full Paginator (prev / page-numbers / next + jumps), SimplePaginator (prev / next), or CursorPaginator (prev / next with cursor URLs). The pure algorithms live in @stacksjs/ui/pagination so they're unit- testable independent of the stx render pipeline: - buildPageSequence(current, last, window) → [1, '…', 3, 4, 5, '…', 12] Anchors page 1 + last_page; single-page gaps render the page instead of an ellipsis (UX nicety — clicking '…' does nothing, so showing "2" beats showing "…" when 2 is the only hidden page). - urlForPage(view, n) → re-templates the page= param on whichever paginator URL is present; preserves all other query params so search filters survive (status=active&page=2 → page=5&status=active). - paginatorVariant(p) → 'full' | 'simple' | 'cursor' | 'unknown' duck-typed mirror of the @stacksjs/orm guards, so the view doesn't need a runtime import of orm just to format page numbers. The stx component (defaults/resources/components/Pagination.stx) imports these helpers, picks the variant, and emits accessible markup: <nav aria-label="Pagination">, aria-current="page" on the active, real <a> tags, mobile breakpoint that drops the page-numbers row in favor of prev/next, and "Showing X to Y of Z" copy on desktop. Tailwind classes match the framework's dashboard look + dark mode. Closes #1909. Refs umbrella #1910. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9e9d527 commit 0ae511f

4 files changed

Lines changed: 398 additions & 0 deletions

File tree

storage/framework/core/ui/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,13 @@ export {
1313
renderFontPreloads,
1414
} from './fonts'
1515
export type { FontEntry } from './fonts'
16+
17+
// Pagination view helpers — pure functions consumed by the
18+
// <Pagination> stx component (defaults/resources/components/Pagination.stx).
19+
// stacksjs/stacks#1909 P5.
20+
export {
21+
buildPageSequence,
22+
paginatorVariant,
23+
urlForPage,
24+
} from './pagination'
25+
export type { PaginatorView } from './pagination'
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Pagination view helpers (stacksjs/stacks#1909, P5 from #1910).
3+
*
4+
* Pure functions consumed by the `<Pagination>` stx component
5+
* (`defaults/resources/components/Pagination.stx`). Extracted here so
6+
* the page-sequence + URL-templating logic is unit-testable independent
7+
* of the stx render pipeline, and so apps that want to roll their own
8+
* pagination UI can reuse the same algorithms.
9+
*
10+
* The functions operate on the canonical paginator shapes from
11+
* `@stacksjs/orm` (Paginator / SimplePaginator / CursorPaginator), but
12+
* accept any duck-typed object with the right fields so callers don't
13+
* need a runtime import dependency on the orm module just to format
14+
* page numbers.
15+
*/
16+
17+
/**
18+
* Subset of {@link Paginator} fields that the view-side helpers actually
19+
* touch. Keeping this minimal keeps the helper decoupled from the orm.
20+
*/
21+
export interface PaginatorView {
22+
current_page?: number
23+
last_page?: number
24+
prev_page_url?: string | null
25+
next_page_url?: string | null
26+
first_page_url?: string
27+
last_page_url?: string
28+
}
29+
30+
/**
31+
* Build the page-number sequence for a full paginator, inserting an
32+
* ellipsis placeholder (`'…'`) for the gap between page 1 / the
33+
* current-page window / the last page.
34+
*
35+
* Examples (window=2):
36+
*
37+
* current=5, last=12 → [1, '…', 3, 4, 5, 6, 7, '…', 12]
38+
* current=1, last=3 → [1, 2, 3] (window covers all)
39+
* current=1, last=1 → [] (single page → no UI)
40+
* current=5, last=5 → [1, 2, 3, 4, 5] (last is current, no trailing ellipsis)
41+
*
42+
* Always anchors the sequence with `1` and `last_page` (when they
43+
* exist and differ from the current window) so users always have a
44+
* "jump to start" / "jump to end" affordance.
45+
*
46+
* @param current 1-indexed current page
47+
* @param last 1-indexed last page (`Paginator.last_page`)
48+
* @param window Number of neighbors on EACH side of `current` to
49+
* show before the ellipsis kicks in. Default 2 gives
50+
* the canonical compact shape.
51+
*/
52+
export function buildPageSequence(
53+
current: number,
54+
last: number,
55+
window: number = 2,
56+
): Array<number | '…'> {
57+
if (last <= 1) return []
58+
const out: Array<number | '…'> = []
59+
// Window bounds, clamped to [2, last-1] so we don't double-emit 1 or last.
60+
const lo = Math.max(2, current - window)
61+
const hi = Math.min(last - 1, current + window)
62+
out.push(1)
63+
// Only emit the leading ellipsis when the gap is >1 page wide; a gap of
64+
// exactly 1 (e.g. lo=3, hiding only page 2) is just shown as the real
65+
// page number — the ellipsis would be wider on screen than the digit it
66+
// replaces, and clicking it does nothing.
67+
if (lo === 3) out.push(2)
68+
else if (lo > 3) out.push('…')
69+
for (let i = lo; i <= hi; i++) out.push(i)
70+
// Symmetric for the trailing side.
71+
if (hi === last - 2) out.push(last - 1)
72+
else if (hi < last - 2) out.push('…')
73+
if (last > 1) out.push(last)
74+
return out
75+
}
76+
77+
/**
78+
* Compute the URL for a specific page number, re-templating the
79+
* `page=N` parameter on whichever existing paginator URL is present.
80+
* Preserves all other query params (search filters, sort, etc.) — the
81+
* URLs filled in by `enrichPaginatorUrls()` (P2) already carry them,
82+
* so the re-template just swaps the page number.
83+
*
84+
* Falls back to `?page=N` when no template URL is available — covers
85+
* the case where the paginator was built outside a request scope (CLI
86+
* / queue / cron) and rendered via a non-default view; produces a
87+
* relative link that still works against the active page.
88+
*/
89+
export function urlForPage(view: PaginatorView, page: number): string {
90+
const template = view.next_page_url || view.prev_page_url || view.first_page_url || view.last_page_url
91+
if (template) {
92+
if (/[?&]page=\d+/.test(template))
93+
return template.replace(/([?&])page=\d+/, `$1page=${page}`)
94+
// Template has no page= param yet (rare — paginator built without P2
95+
// enrichment but a URL was attached manually); append it.
96+
return `${template}${template.includes('?') ? '&' : '?'}page=${page}`
97+
}
98+
return `?page=${page}`
99+
}
100+
101+
/**
102+
* Classify a paginator instance by shape so the view picks the right
103+
* UI variant. Returns one of `'full'` / `'simple'` / `'cursor'` based
104+
* on which fields are present. This mirrors `isPaginator` /
105+
* `isSimplePaginator` / `isCursorPaginator` in `@stacksjs/orm` but
106+
* lives here so the view layer doesn't need to import the orm.
107+
*/
108+
export function paginatorVariant(p: unknown): 'full' | 'simple' | 'cursor' | 'unknown' {
109+
if (p === null || typeof p !== 'object') return 'unknown'
110+
const v = p as Record<string, unknown>
111+
if ('next_cursor' in v) return 'cursor'
112+
if ('total' in v && 'last_page' in v) return 'full'
113+
if ('current_page' in v && 'per_page' in v) return 'simple'
114+
return 'unknown'
115+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { buildPageSequence, paginatorVariant, urlForPage } from '../src/pagination'
3+
4+
// stacksjs/stacks#1909 (P5) — pagination view helpers.
5+
6+
describe('buildPageSequence', () => {
7+
test('empty when there is only one page (no pagination UI needed)', () => {
8+
expect(buildPageSequence(1, 1, 2)).toEqual([])
9+
})
10+
11+
test('current page far from both ends: [1, …, lo..hi, …, last]', () => {
12+
// current=8 in last=20, window=2 → 6..10 around current, both ellipses fire
13+
expect(buildPageSequence(8, 20, 2)).toEqual([1, '…', 6, 7, 8, 9, 10, '…', 20])
14+
})
15+
16+
test('single-page gap shows the page instead of ellipsis (UX nicety)', () => {
17+
// current=5, last=12, window=2: pages 3..7 in window, page 2 is the only
18+
// page between 1 and lo=3 → show "2" instead of "…" (one-page gap rule)
19+
expect(buildPageSequence(5, 12, 2)).toEqual([1, 2, 3, 4, 5, 6, 7, '…', 12])
20+
})
21+
22+
test('current near start: no leading ellipsis at all', () => {
23+
expect(buildPageSequence(2, 12, 2)).toEqual([1, 2, 3, 4, '…', 12])
24+
})
25+
26+
test('current near end: no trailing ellipsis', () => {
27+
// current=11, last=12, window=2 → lo=9, hi=11, push 9..11, then 12.
28+
// page 8 is a single-page gap on the leading side → shown, not ellipsis
29+
expect(buildPageSequence(11, 12, 2)).toEqual([1, '…', 9, 10, 11, 12])
30+
})
31+
32+
test('small last_page (window covers everything)', () => {
33+
expect(buildPageSequence(2, 3, 2)).toEqual([1, 2, 3])
34+
})
35+
36+
test('current is last page', () => {
37+
expect(buildPageSequence(5, 5, 2)).toEqual([1, 2, 3, 4, 5])
38+
})
39+
40+
test('current is first page', () => {
41+
expect(buildPageSequence(1, 5, 2)).toEqual([1, 2, 3, 4, 5])
42+
})
43+
44+
test('window=0 still anchors first and last', () => {
45+
expect(buildPageSequence(5, 10, 0)).toEqual([1, '…', 5, '…', 10])
46+
})
47+
48+
test('window=1 minimal middle', () => {
49+
expect(buildPageSequence(5, 10, 1)).toEqual([1, '…', 4, 5, 6, '…', 10])
50+
})
51+
})
52+
53+
describe('urlForPage', () => {
54+
test('re-templates page param on an existing paginator URL', () => {
55+
const view = { next_page_url: '/users?page=3&status=active' }
56+
expect(urlForPage(view, 5)).toBe('/users?page=5&status=active')
57+
})
58+
59+
test('uses prev_page_url as template if next is null', () => {
60+
const view = { next_page_url: null, prev_page_url: '/users?page=1&q=glenn' }
61+
expect(urlForPage(view, 4)).toBe('/users?page=4&q=glenn')
62+
})
63+
64+
test('falls back to first/last when prev+next are absent', () => {
65+
const view = { first_page_url: '/users?page=1', last_page_url: '/users?page=10' }
66+
// first wins because we OR through next → prev → first → last
67+
expect(urlForPage(view, 5)).toBe('/users?page=5')
68+
})
69+
70+
test('appends page= when template lacks one (rare manual-URL case)', () => {
71+
const view = { first_page_url: '/users?q=glenn' }
72+
expect(urlForPage(view, 3)).toBe('/users?q=glenn&page=3')
73+
})
74+
75+
test('appends page with ? when template has no query string', () => {
76+
const view = { first_page_url: '/users' }
77+
expect(urlForPage(view, 3)).toBe('/users?page=3')
78+
})
79+
80+
test('falls back to relative ?page=N when no templates at all', () => {
81+
expect(urlForPage({}, 7)).toBe('?page=7')
82+
})
83+
84+
test('handles & vs ? separators correctly', () => {
85+
// Template uses & for non-first param
86+
const view = { next_page_url: '/users?status=active&page=2' }
87+
expect(urlForPage(view, 5)).toBe('/users?status=active&page=5')
88+
})
89+
})
90+
91+
describe('paginatorVariant', () => {
92+
test('classifies full Paginator', () => {
93+
expect(paginatorVariant({
94+
data: [], current_page: 1, per_page: 10, total: 50, last_page: 5,
95+
from: 1, to: 10, has_more_pages: true,
96+
})).toBe('full')
97+
})
98+
99+
test('classifies SimplePaginator (no total/last_page)', () => {
100+
expect(paginatorVariant({
101+
data: [], current_page: 1, per_page: 10, has_more_pages: true,
102+
})).toBe('simple')
103+
})
104+
105+
test('classifies CursorPaginator (next_cursor key)', () => {
106+
expect(paginatorVariant({
107+
data: [], per_page: 10, next_cursor: 'abc', prev_cursor: null, has_more_pages: true,
108+
})).toBe('cursor')
109+
})
110+
111+
test('CursorPaginator wins even if other fields happen to be present', () => {
112+
// Defensive — duck-type detection priority matters
113+
expect(paginatorVariant({
114+
data: [], per_page: 10, next_cursor: 'abc', total: 100,
115+
})).toBe('cursor')
116+
})
117+
118+
test('non-objects → unknown', () => {
119+
expect(paginatorVariant(null)).toBe('unknown')
120+
expect(paginatorVariant(undefined)).toBe('unknown')
121+
expect(paginatorVariant('string')).toBe('unknown')
122+
expect(paginatorVariant([])).toBe('unknown') // arrays are typeof object but excluded
123+
})
124+
})

0 commit comments

Comments
 (0)