@usefy/use-pagination 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/mirunamu00/usefy/master/assets/logo.png" alt="usefy logo" width="120" />
3
+ </p>
4
+
5
+ <h1 align="center">@usefy/use-pagination</h1>
6
+
7
+ <p align="center">
8
+ <strong>A headless pagination state machine — current page, page count, a slice-ready range, and an ellipsis-aware pager model</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/@usefy/use-pagination"><img src="https://img.shields.io/npm/v/@usefy/use-pagination.svg?style=flat-square&color=007acc" alt="npm version" /></a>
13
+ <a href="https://www.npmjs.com/package/@usefy/use-pagination"><img src="https://img.shields.io/npm/dm/@usefy/use-pagination.svg?style=flat-square&color=007acc" alt="npm downloads" /></a>
14
+ <a href="https://bundlephobia.com/package/@usefy/use-pagination"><img src="https://img.shields.io/bundlephobia/minzip/@usefy/use-pagination?style=flat-square&color=007acc" alt="bundle size" /></a>
15
+ <a href="https://github.com/mirunamu00/usefy/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/@usefy/use-pagination.svg?style=flat-square&color=007acc" alt="license" /></a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="#installation">Installation</a> •
20
+ <a href="#quick-start">Quick Start</a> •
21
+ <a href="#api">API</a> •
22
+ <a href="#testing">Testing</a> •
23
+ <a href="#license">License</a>
24
+ </p>
25
+
26
+ <p align="center">
27
+ <a href="https://mirunamu00.github.io/usefy/?path=/docs/hooks-usepagination--docs" target="_blank" rel="noopener noreferrer">
28
+ <strong>📚 View Storybook Demo</strong>
29
+ </a>
30
+ </p>
31
+
32
+ ---
33
+
34
+ ## Overview
35
+
36
+ `usePagination` is part of the [@usefy](https://www.npmjs.com/org/usefy) ecosystem — a collection of production-ready, TypeScript-first, SSR-safe React hooks. It owns **pagination state** — nothing else — so you can render the pager however you like: a table footer, a numbered button row, a "load more" counter, an API cursor.
37
+
38
+ From `total` and `pageSize` it derives the page count, keeps the current page clamped in range, hands you a **slice-ready index window** (`range`) for your data array, and builds the **ellipsis-aware page-number model** (`items`) you need for a `1 … 24 25 26 … 50` pager — the same model as MUI/Mantine's `usePagination`. The current page works in both **controlled** and **uncontrolled** modes, composing [`@usefy/use-controllable-state`](https://www.npmjs.com/package/@usefy/use-controllable-state).
39
+
40
+ ## Features
41
+
42
+ - **Controlled & uncontrolled** — pass `page` + `onChange`, or let the hook own the page from `defaultPage`
43
+ - **Slice-ready `range`** — a 0-based `{ start, end }` window (end **exclusive**, clamped to `total`) so `data.slice(start, end)` just works
44
+ - **Ellipsis-aware `items`** — page numbers + `"ellipsis"` tokens driven by `siblingCount` / `boundaryCount`, each flagged `selected` for the current page
45
+ - **Always in range** — the page is clamped into `[1, pageCount]`; when the dataset shrinks the current page follows it, no dangling out-of-range page
46
+ - **No wasted renders** — `next`/`prev` at a bound and `setPage` to the current page are no-ops (no re-render, no `onChange`)
47
+ - **Stable controls** — `setPage`/`next`/`prev`/`first`/`last` keep their identity across renders, safe as effect deps
48
+ - **Robust guards** — non-positive `pageSize` is coerced to `1`, negative/`NaN` `total` to `0`, fractional pages floored
49
+ - **TypeScript-first** — full type inference and exported types
50
+ - **Tiny & tree-shakeable** — one tiny dependency, published as its own package
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ # npm
56
+ npm install @usefy/use-pagination
57
+
58
+ # yarn
59
+ yarn add @usefy/use-pagination
60
+
61
+ # pnpm
62
+ pnpm add @usefy/use-pagination
63
+ ```
64
+
65
+ Requires React 18 or 19 (`peerDependencies: "react": "^18.0.0 || ^19.0.0"`).
66
+
67
+ ## Quick Start
68
+
69
+ ```tsx
70
+ import { usePagination } from "@usefy/use-pagination";
71
+
72
+ function UsersTable({ users }: { users: User[] }) {
73
+ const { page, pageCount, range, items, setPage, next, prev, canNext, canPrev } =
74
+ usePagination({ total: users.length, pageSize: 10 });
75
+
76
+ const visible = users.slice(range.start, range.end);
77
+
78
+ return (
79
+ <>
80
+ <ul>
81
+ {visible.map((u) => (
82
+ <li key={u.id}>{u.name}</li>
83
+ ))}
84
+ </ul>
85
+
86
+ <nav>
87
+ <button onClick={prev} disabled={!canPrev}>‹ Prev</button>
88
+ {items.map((item, i) =>
89
+ item.type === "ellipsis" ? (
90
+ <span key={`gap-${i}`}>…</span>
91
+ ) : (
92
+ <button
93
+ key={item.page}
94
+ aria-current={item.selected ? "page" : undefined}
95
+ onClick={() => setPage(item.page!)}
96
+ >
97
+ {item.page}
98
+ </button>
99
+ )
100
+ )}
101
+ <button onClick={next} disabled={!canNext}>Next ›</button>
102
+ </nav>
103
+
104
+ <p>Page {page} of {pageCount}</p>
105
+ </>
106
+ );
107
+ }
108
+ ```
109
+
110
+ ## API
111
+
112
+ ```ts
113
+ const pagination = usePagination(options);
114
+ ```
115
+
116
+ ### Options — `UsePaginationOptions`
117
+
118
+ | Option | Type | Default | Description |
119
+ | --- | --- | --- | --- |
120
+ | `total` | `number` | — (required) | Total number of items across all pages. Values below `0` and non-finite values are treated as `0`; fractions are floored. |
121
+ | `pageSize` | `number` | `10` | Items per page. Clamped to a minimum of `1`. |
122
+ | `page` | `number` | `undefined` | **Controlled** current page (1-based). When provided, the returned `page` mirrors this prop (clamped) and the controls only call `onChange`. |
123
+ | `defaultPage` | `number` | `1` | Initial page in **uncontrolled** mode. Ignored while controlled. |
124
+ | `siblingCount` | `number` | `1` | Pages shown on each side of the current page in `items`. |
125
+ | `boundaryCount` | `number` | `1` | Pages shown at the start and end in `items`. |
126
+ | `onChange` | `(page: number) => void` | `undefined` | Called with the next page whenever the page changes. Its identity may change between renders freely. |
127
+
128
+ ### Returns — `UsePaginationReturn`
129
+
130
+ | Property | Type | Description |
131
+ | --- | --- | --- |
132
+ | `page` | `number` | Current page, 1-based, always within `[1, pageCount]`. |
133
+ | `pageCount` | `number` | Number of pages, always `>= 1` (an empty dataset still has one empty page). |
134
+ | `pageSize` | `number` | The effective page size (clamped to `>= 1`). |
135
+ | `setPage` | `(page: number) => void` | Go to a specific page. Argument is clamped and floored; going to the current page is a no-op. Stable identity. |
136
+ | `next` | `() => void` | Go to the next page (no-op on the last). Stable identity. |
137
+ | `prev` | `() => void` | Go to the previous page (no-op on the first). Stable identity. |
138
+ | `first` | `() => void` | Go to the first page. Stable identity. |
139
+ | `last` | `() => void` | Go to the last page. Stable identity. |
140
+ | `canNext` | `boolean` | `true` when `page < pageCount`. |
141
+ | `canPrev` | `boolean` | `true` when `page > 1`. |
142
+ | `range` | `PaginationRange` | 0-based `{ start, end }` window of the current page — see below. |
143
+ | `items` | `PaginationItem[]` | Ellipsis-aware pager model — see below. |
144
+
145
+ ### `range` — `PaginationRange`
146
+
147
+ The 0-based index window for slicing your data array:
148
+
149
+ - `start` — index of the first item on the current page (**inclusive**).
150
+ - `end` — index **one past** the last item (**exclusive**), clamped to `total`.
151
+
152
+ So `array.slice(range.start, range.end)` yields exactly the current page's items. For an empty dataset, `start === end === 0`.
153
+
154
+ ```ts
155
+ // total: 25, pageSize: 10, page: 3 → { start: 20, end: 25 }
156
+ data.slice(20, 25); // the last 5 items
157
+ ```
158
+
159
+ ### `items` — `PaginationItem[]`
160
+
161
+ The ordered model for rendering a pager, MUI/Mantine-style. Each entry is either a page button or an `"ellipsis"` gap:
162
+
163
+ ```ts
164
+ interface PaginationItem {
165
+ type: "page" | "ellipsis";
166
+ page: number | null; // 1-based page for "page", null for "ellipsis"
167
+ selected: boolean; // true only for the current page
168
+ }
169
+ ```
170
+
171
+ `siblingCount` (pages around the current page) and `boundaryCount` (pages pinned at each end) control how the middle collapses. When a gap would hide exactly one page, that page is shown instead of an ellipsis:
172
+
173
+ ```ts
174
+ // pageCount 50, page 25, siblingCount 1, boundaryCount 1:
175
+ // [1, "ellipsis", 24, 25, 26, "ellipsis", 50]
176
+ ```
177
+
178
+ The pure builder is also exported for non-React use:
179
+
180
+ ```ts
181
+ import { buildPaginationRange, getPageCount } from "@usefy/use-pagination";
182
+
183
+ getPageCount(95, 10); // 10
184
+ buildPaginationRange({ page: 1, pageCount: 10 }); // [1, 2, 3, 4, 5, "ellipsis", 10]
185
+ ```
186
+
187
+ ### Behavior notes
188
+
189
+ - **Clamping is derived.** The current page is clamped **presentationally** into `[1, pageCount]` — it is never written back into state, so shrinking `total` never fires a surprise `onChange`. In **controlled** mode this means a `page` prop that is out of range is displayed clamped but not corrected via `onChange`; pass a valid `page`. Any navigation interaction (`next`/`prev`/`setPage`) commits a fresh in-range value.
190
+ - **No-op skipping.** Moving to the page you're already on — including `next`/`prev` at a bound — does not re-render or call `onChange`.
191
+ - **SSR-safe.** Pure state and math; there is no `window`/`document` access, so it renders identically on the server and client.
192
+
193
+ ## Testing
194
+
195
+ 📊 <a href="https://mirunamu00.github.io/usefy/coverage/use-pagination/src/index.html" target="_blank" rel="noopener noreferrer"><strong>View Detailed Coverage Report</strong></a> (GitHub Pages) — **48 tests**, 100% statement coverage.
196
+
197
+ ## License
198
+
199
+ MIT © [mirunamu](https://github.com/mirunamu00)
200
+
201
+ This package is part of the [usefy](https://github.com/mirunamu00/usefy) monorepo.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Options for the {@link usePagination} hook.
3
+ */
4
+ interface UsePaginationOptions {
5
+ /**
6
+ * Total number of items across all pages. Values below `0` (and non-finite
7
+ * values) are treated as `0`. Fractional values are floored.
8
+ */
9
+ total: number;
10
+ /**
11
+ * Number of items per page.
12
+ *
13
+ * @defaultValue 10
14
+ * @remarks Clamped to a minimum of `1` — a page size of `0` or a negative
15
+ * value would make `pageCount` meaningless, so it is coerced to `1`.
16
+ */
17
+ pageSize?: number;
18
+ /**
19
+ * The **controlled** current page (1-based). When provided (not `undefined`),
20
+ * the hook runs in controlled mode: the returned `page` mirrors this prop
21
+ * (clamped into `[1, pageCount]`) and `setPage`/`next`/`prev`/… only call
22
+ * `onChange` — the parent owns the value.
23
+ *
24
+ * Pass `undefined` to run uncontrolled, seeded from {@link defaultPage}.
25
+ */
26
+ page?: number;
27
+ /**
28
+ * The initial current page (1-based) in **uncontrolled** mode. Ignored while
29
+ * controlled.
30
+ *
31
+ * @defaultValue 1
32
+ */
33
+ defaultPage?: number;
34
+ /**
35
+ * Number of always-visible page buttons on each side of the current page in
36
+ * the {@link UsePaginationReturn.items} model.
37
+ *
38
+ * @defaultValue 1
39
+ */
40
+ siblingCount?: number;
41
+ /**
42
+ * Number of always-visible page buttons at the start and end (the boundaries)
43
+ * in the {@link UsePaginationReturn.items} model.
44
+ *
45
+ * @defaultValue 1
46
+ */
47
+ boundaryCount?: number;
48
+ /**
49
+ * Called with the next page whenever the current page changes — via any of the
50
+ * navigation controls in uncontrolled mode, or when a control requests a
51
+ * *different* page in controlled mode. Its identity may change between renders
52
+ * without any effect on the hook (the latest callback is always used).
53
+ */
54
+ onChange?: (page: number) => void;
55
+ }
56
+ /**
57
+ * The kind of a {@link PaginationItem}: a real page button, or an `"ellipsis"`
58
+ * gap token used to collapse a long run of pages.
59
+ */
60
+ type PaginationItemType = "page" | "ellipsis";
61
+ /**
62
+ * A single entry in the ellipsis-aware page-number model
63
+ * ({@link UsePaginationReturn.items}) used to render a pager UI.
64
+ */
65
+ interface PaginationItem {
66
+ /** Whether this entry is a clickable page or an `"ellipsis"` gap. */
67
+ type: PaginationItemType;
68
+ /**
69
+ * The 1-based page number for a `"page"` entry, or `null` for an `"ellipsis"`.
70
+ */
71
+ page: number | null;
72
+ /** `true` when this is the current page (always `false` for `"ellipsis"`). */
73
+ selected: boolean;
74
+ }
75
+ /**
76
+ * The 0-based index window of the current page, ready to slice an array with
77
+ * `array.slice(range.start, range.end)`.
78
+ */
79
+ interface PaginationRange {
80
+ /** 0-based index of the first item on the current page (**inclusive**). */
81
+ readonly start: number;
82
+ /**
83
+ * 0-based index **one past** the last item on the current page
84
+ * (**exclusive**), clamped to `total`. `array.slice(start, end)` yields the
85
+ * current page's items. For an empty dataset, `start === end === 0`.
86
+ */
87
+ readonly end: number;
88
+ }
89
+ /**
90
+ * Return value of {@link usePagination}.
91
+ */
92
+ interface UsePaginationReturn {
93
+ /** The current page, 1-based, always within `[1, pageCount]`. */
94
+ page: number;
95
+ /**
96
+ * Total number of pages, always `>= 1` (an empty dataset still has one empty
97
+ * page). Equals `Math.max(1, Math.ceil(total / pageSize))`.
98
+ */
99
+ pageCount: number;
100
+ /** The effective page size (clamped to a minimum of `1`). */
101
+ pageSize: number;
102
+ /**
103
+ * Go to a specific page. The argument is clamped into `[1, pageCount]` and
104
+ * floored; going to the page you are already on is a no-op (no re-render, no
105
+ * `onChange`). Stable identity.
106
+ */
107
+ setPage: (page: number) => void;
108
+ /** Go to the next page (no-op on the last page). Stable identity. */
109
+ next: () => void;
110
+ /** Go to the previous page (no-op on the first page). Stable identity. */
111
+ prev: () => void;
112
+ /** Go to the first page. Stable identity. */
113
+ first: () => void;
114
+ /** Go to the last page. Stable identity. */
115
+ last: () => void;
116
+ /** `true` when there is a next page (`page < pageCount`). */
117
+ canNext: boolean;
118
+ /** `true` when there is a previous page (`page > 1`). */
119
+ canPrev: boolean;
120
+ /**
121
+ * The 0-based index window of the current page for slicing your data array.
122
+ * See {@link PaginationRange}.
123
+ */
124
+ range: PaginationRange;
125
+ /**
126
+ * The ellipsis-aware ordered list of page buttons and gap tokens to render a
127
+ * pager UI, driven by `siblingCount`/`boundaryCount`. Read-only — it is a
128
+ * memoized derived model, so treat it as immutable. See {@link PaginationItem}.
129
+ */
130
+ items: readonly PaginationItem[];
131
+ }
132
+
133
+ /**
134
+ * A headless pagination state machine — the current page (controlled or
135
+ * uncontrolled), the derived page count, a slice-ready index range, and an
136
+ * ellipsis-aware page-number model for rendering a pager UI. It owns *state*
137
+ * only; you render however you like.
138
+ *
139
+ * The current page is always kept within `[1, pageCount]`: it is clamped
140
+ * **derived** from `total`/`pageSize`, so when the dataset shrinks the returned
141
+ * `page` never points past the last page and the navigation controls stay
142
+ * correct (a plain re-clamp, no surprise `onChange`). All controls
143
+ * (`setPage`/`next`/`prev`/`first`/`last`) have stable identities and skip
144
+ * no-op moves (staying on the current page does not re-render or fire
145
+ * `onChange`).
146
+ *
147
+ * Works in both modes, mirroring `useControllableState`:
148
+ * - **Uncontrolled** (`page` omitted): the hook owns the page, seeded from
149
+ * `defaultPage`; `onChange` fires after each committed change.
150
+ * - **Controlled** (`page` provided): the returned `page` mirrors the prop
151
+ * (clamped) and the controls only call `onChange` — the parent owns the value.
152
+ *
153
+ * @param options - See {@link UsePaginationOptions}.
154
+ * @returns The pagination state and controls — see {@link UsePaginationReturn}.
155
+ *
156
+ * @example
157
+ * ```tsx
158
+ * function UsersTable({ users }: { users: User[] }) {
159
+ * const { page, pageCount, range, items, setPage, next, prev, canNext, canPrev } =
160
+ * usePagination({ total: users.length, pageSize: 10 });
161
+ *
162
+ * const visible = users.slice(range.start, range.end);
163
+ *
164
+ * return (
165
+ * <>
166
+ * <ul>{visible.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
167
+ * <nav>
168
+ * <button onClick={prev} disabled={!canPrev}>Prev</button>
169
+ * {items.map((item, i) =>
170
+ * item.type === "ellipsis" ? (
171
+ * <span key={`gap-${i}`}>…</span>
172
+ * ) : (
173
+ * <button
174
+ * key={item.page}
175
+ * aria-current={item.selected}
176
+ * onClick={() => setPage(item.page!)}
177
+ * >
178
+ * {item.page}
179
+ * </button>
180
+ * )
181
+ * )}
182
+ * <button onClick={next} disabled={!canNext}>Next</button>
183
+ * </nav>
184
+ * <p>Page {page} of {pageCount}</p>
185
+ * </>
186
+ * );
187
+ * }
188
+ * ```
189
+ */
190
+ declare function usePagination(options: UsePaginationOptions): UsePaginationReturn;
191
+
192
+ /**
193
+ * Compute the number of pages for a dataset. Always returns at least `1` — an
194
+ * empty dataset still has a single (empty) page — so it is safe to use directly
195
+ * as the upper clamp bound. `total` is treated as `>= 0` and `pageSize` as
196
+ * `>= 1` (both floored; non-finite inputs are normalized).
197
+ *
198
+ * @param total - Total number of items.
199
+ * @param pageSize - Items per page.
200
+ * @returns The page count, `>= 1`.
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * getPageCount(0, 10); // 1
205
+ * getPageCount(25, 10); // 3
206
+ * getPageCount(30, 10); // 3
207
+ * ```
208
+ */
209
+ declare function getPageCount(total: number, pageSize: number): number;
210
+ /**
211
+ * Clamp a requested page into `[1, pageCount]`, flooring fractions. Non-finite
212
+ * requests fall back to page `1`. `pageCount` is assumed to be `>= 1` (as
213
+ * returned by {@link getPageCount}).
214
+ *
215
+ * @param page - The requested 1-based page.
216
+ * @param pageCount - The number of pages (upper bound).
217
+ * @returns The clamped page, within `[1, pageCount]`.
218
+ */
219
+ declare function clampPage(page: number, pageCount: number): number;
220
+ /**
221
+ * Parameters for {@link buildPaginationRange}.
222
+ */
223
+ interface BuildPaginationRangeParams {
224
+ /** The current (clamped) 1-based page. */
225
+ page: number;
226
+ /** Total number of pages (`>= 1`). */
227
+ pageCount: number;
228
+ /** Pages shown on each side of the current page. @defaultValue 1 */
229
+ siblingCount?: number;
230
+ /** Pages shown at the start and end. @defaultValue 1 */
231
+ boundaryCount?: number;
232
+ }
233
+ /**
234
+ * Build the ordered list of page numbers with `"ellipsis"` gap tokens for a
235
+ * pager UI — the well-known MUI/Mantine `usePagination` model. Always shows the
236
+ * `boundaryCount` first/last pages and the `siblingCount` pages around the
237
+ * current page, collapsing anything in between into a single `"ellipsis"`. When
238
+ * a gap would hide exactly one page, that page is shown instead of an ellipsis.
239
+ *
240
+ * @param params - See {@link BuildPaginationRangeParams}.
241
+ * @returns An ordered array of 1-based page numbers and `"ellipsis"` tokens.
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * buildPaginationRange({ page: 1, pageCount: 10 });
246
+ * // [1, 2, 3, 4, 5, "ellipsis", 10]
247
+ * buildPaginationRange({ page: 6, pageCount: 10 });
248
+ * // [1, "ellipsis", 5, 6, 7, "ellipsis", 10]
249
+ * ```
250
+ */
251
+ declare function buildPaginationRange(params: BuildPaginationRangeParams): (number | "ellipsis")[];
252
+
253
+ export { type BuildPaginationRangeParams, type PaginationItem, type PaginationItemType, type PaginationRange, type UsePaginationOptions, type UsePaginationReturn, buildPaginationRange, clampPage, getPageCount, usePagination };
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Options for the {@link usePagination} hook.
3
+ */
4
+ interface UsePaginationOptions {
5
+ /**
6
+ * Total number of items across all pages. Values below `0` (and non-finite
7
+ * values) are treated as `0`. Fractional values are floored.
8
+ */
9
+ total: number;
10
+ /**
11
+ * Number of items per page.
12
+ *
13
+ * @defaultValue 10
14
+ * @remarks Clamped to a minimum of `1` — a page size of `0` or a negative
15
+ * value would make `pageCount` meaningless, so it is coerced to `1`.
16
+ */
17
+ pageSize?: number;
18
+ /**
19
+ * The **controlled** current page (1-based). When provided (not `undefined`),
20
+ * the hook runs in controlled mode: the returned `page` mirrors this prop
21
+ * (clamped into `[1, pageCount]`) and `setPage`/`next`/`prev`/… only call
22
+ * `onChange` — the parent owns the value.
23
+ *
24
+ * Pass `undefined` to run uncontrolled, seeded from {@link defaultPage}.
25
+ */
26
+ page?: number;
27
+ /**
28
+ * The initial current page (1-based) in **uncontrolled** mode. Ignored while
29
+ * controlled.
30
+ *
31
+ * @defaultValue 1
32
+ */
33
+ defaultPage?: number;
34
+ /**
35
+ * Number of always-visible page buttons on each side of the current page in
36
+ * the {@link UsePaginationReturn.items} model.
37
+ *
38
+ * @defaultValue 1
39
+ */
40
+ siblingCount?: number;
41
+ /**
42
+ * Number of always-visible page buttons at the start and end (the boundaries)
43
+ * in the {@link UsePaginationReturn.items} model.
44
+ *
45
+ * @defaultValue 1
46
+ */
47
+ boundaryCount?: number;
48
+ /**
49
+ * Called with the next page whenever the current page changes — via any of the
50
+ * navigation controls in uncontrolled mode, or when a control requests a
51
+ * *different* page in controlled mode. Its identity may change between renders
52
+ * without any effect on the hook (the latest callback is always used).
53
+ */
54
+ onChange?: (page: number) => void;
55
+ }
56
+ /**
57
+ * The kind of a {@link PaginationItem}: a real page button, or an `"ellipsis"`
58
+ * gap token used to collapse a long run of pages.
59
+ */
60
+ type PaginationItemType = "page" | "ellipsis";
61
+ /**
62
+ * A single entry in the ellipsis-aware page-number model
63
+ * ({@link UsePaginationReturn.items}) used to render a pager UI.
64
+ */
65
+ interface PaginationItem {
66
+ /** Whether this entry is a clickable page or an `"ellipsis"` gap. */
67
+ type: PaginationItemType;
68
+ /**
69
+ * The 1-based page number for a `"page"` entry, or `null` for an `"ellipsis"`.
70
+ */
71
+ page: number | null;
72
+ /** `true` when this is the current page (always `false` for `"ellipsis"`). */
73
+ selected: boolean;
74
+ }
75
+ /**
76
+ * The 0-based index window of the current page, ready to slice an array with
77
+ * `array.slice(range.start, range.end)`.
78
+ */
79
+ interface PaginationRange {
80
+ /** 0-based index of the first item on the current page (**inclusive**). */
81
+ readonly start: number;
82
+ /**
83
+ * 0-based index **one past** the last item on the current page
84
+ * (**exclusive**), clamped to `total`. `array.slice(start, end)` yields the
85
+ * current page's items. For an empty dataset, `start === end === 0`.
86
+ */
87
+ readonly end: number;
88
+ }
89
+ /**
90
+ * Return value of {@link usePagination}.
91
+ */
92
+ interface UsePaginationReturn {
93
+ /** The current page, 1-based, always within `[1, pageCount]`. */
94
+ page: number;
95
+ /**
96
+ * Total number of pages, always `>= 1` (an empty dataset still has one empty
97
+ * page). Equals `Math.max(1, Math.ceil(total / pageSize))`.
98
+ */
99
+ pageCount: number;
100
+ /** The effective page size (clamped to a minimum of `1`). */
101
+ pageSize: number;
102
+ /**
103
+ * Go to a specific page. The argument is clamped into `[1, pageCount]` and
104
+ * floored; going to the page you are already on is a no-op (no re-render, no
105
+ * `onChange`). Stable identity.
106
+ */
107
+ setPage: (page: number) => void;
108
+ /** Go to the next page (no-op on the last page). Stable identity. */
109
+ next: () => void;
110
+ /** Go to the previous page (no-op on the first page). Stable identity. */
111
+ prev: () => void;
112
+ /** Go to the first page. Stable identity. */
113
+ first: () => void;
114
+ /** Go to the last page. Stable identity. */
115
+ last: () => void;
116
+ /** `true` when there is a next page (`page < pageCount`). */
117
+ canNext: boolean;
118
+ /** `true` when there is a previous page (`page > 1`). */
119
+ canPrev: boolean;
120
+ /**
121
+ * The 0-based index window of the current page for slicing your data array.
122
+ * See {@link PaginationRange}.
123
+ */
124
+ range: PaginationRange;
125
+ /**
126
+ * The ellipsis-aware ordered list of page buttons and gap tokens to render a
127
+ * pager UI, driven by `siblingCount`/`boundaryCount`. Read-only — it is a
128
+ * memoized derived model, so treat it as immutable. See {@link PaginationItem}.
129
+ */
130
+ items: readonly PaginationItem[];
131
+ }
132
+
133
+ /**
134
+ * A headless pagination state machine — the current page (controlled or
135
+ * uncontrolled), the derived page count, a slice-ready index range, and an
136
+ * ellipsis-aware page-number model for rendering a pager UI. It owns *state*
137
+ * only; you render however you like.
138
+ *
139
+ * The current page is always kept within `[1, pageCount]`: it is clamped
140
+ * **derived** from `total`/`pageSize`, so when the dataset shrinks the returned
141
+ * `page` never points past the last page and the navigation controls stay
142
+ * correct (a plain re-clamp, no surprise `onChange`). All controls
143
+ * (`setPage`/`next`/`prev`/`first`/`last`) have stable identities and skip
144
+ * no-op moves (staying on the current page does not re-render or fire
145
+ * `onChange`).
146
+ *
147
+ * Works in both modes, mirroring `useControllableState`:
148
+ * - **Uncontrolled** (`page` omitted): the hook owns the page, seeded from
149
+ * `defaultPage`; `onChange` fires after each committed change.
150
+ * - **Controlled** (`page` provided): the returned `page` mirrors the prop
151
+ * (clamped) and the controls only call `onChange` — the parent owns the value.
152
+ *
153
+ * @param options - See {@link UsePaginationOptions}.
154
+ * @returns The pagination state and controls — see {@link UsePaginationReturn}.
155
+ *
156
+ * @example
157
+ * ```tsx
158
+ * function UsersTable({ users }: { users: User[] }) {
159
+ * const { page, pageCount, range, items, setPage, next, prev, canNext, canPrev } =
160
+ * usePagination({ total: users.length, pageSize: 10 });
161
+ *
162
+ * const visible = users.slice(range.start, range.end);
163
+ *
164
+ * return (
165
+ * <>
166
+ * <ul>{visible.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
167
+ * <nav>
168
+ * <button onClick={prev} disabled={!canPrev}>Prev</button>
169
+ * {items.map((item, i) =>
170
+ * item.type === "ellipsis" ? (
171
+ * <span key={`gap-${i}`}>…</span>
172
+ * ) : (
173
+ * <button
174
+ * key={item.page}
175
+ * aria-current={item.selected}
176
+ * onClick={() => setPage(item.page!)}
177
+ * >
178
+ * {item.page}
179
+ * </button>
180
+ * )
181
+ * )}
182
+ * <button onClick={next} disabled={!canNext}>Next</button>
183
+ * </nav>
184
+ * <p>Page {page} of {pageCount}</p>
185
+ * </>
186
+ * );
187
+ * }
188
+ * ```
189
+ */
190
+ declare function usePagination(options: UsePaginationOptions): UsePaginationReturn;
191
+
192
+ /**
193
+ * Compute the number of pages for a dataset. Always returns at least `1` — an
194
+ * empty dataset still has a single (empty) page — so it is safe to use directly
195
+ * as the upper clamp bound. `total` is treated as `>= 0` and `pageSize` as
196
+ * `>= 1` (both floored; non-finite inputs are normalized).
197
+ *
198
+ * @param total - Total number of items.
199
+ * @param pageSize - Items per page.
200
+ * @returns The page count, `>= 1`.
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * getPageCount(0, 10); // 1
205
+ * getPageCount(25, 10); // 3
206
+ * getPageCount(30, 10); // 3
207
+ * ```
208
+ */
209
+ declare function getPageCount(total: number, pageSize: number): number;
210
+ /**
211
+ * Clamp a requested page into `[1, pageCount]`, flooring fractions. Non-finite
212
+ * requests fall back to page `1`. `pageCount` is assumed to be `>= 1` (as
213
+ * returned by {@link getPageCount}).
214
+ *
215
+ * @param page - The requested 1-based page.
216
+ * @param pageCount - The number of pages (upper bound).
217
+ * @returns The clamped page, within `[1, pageCount]`.
218
+ */
219
+ declare function clampPage(page: number, pageCount: number): number;
220
+ /**
221
+ * Parameters for {@link buildPaginationRange}.
222
+ */
223
+ interface BuildPaginationRangeParams {
224
+ /** The current (clamped) 1-based page. */
225
+ page: number;
226
+ /** Total number of pages (`>= 1`). */
227
+ pageCount: number;
228
+ /** Pages shown on each side of the current page. @defaultValue 1 */
229
+ siblingCount?: number;
230
+ /** Pages shown at the start and end. @defaultValue 1 */
231
+ boundaryCount?: number;
232
+ }
233
+ /**
234
+ * Build the ordered list of page numbers with `"ellipsis"` gap tokens for a
235
+ * pager UI — the well-known MUI/Mantine `usePagination` model. Always shows the
236
+ * `boundaryCount` first/last pages and the `siblingCount` pages around the
237
+ * current page, collapsing anything in between into a single `"ellipsis"`. When
238
+ * a gap would hide exactly one page, that page is shown instead of an ellipsis.
239
+ *
240
+ * @param params - See {@link BuildPaginationRangeParams}.
241
+ * @returns An ordered array of 1-based page numbers and `"ellipsis"` tokens.
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * buildPaginationRange({ page: 1, pageCount: 10 });
246
+ * // [1, 2, 3, 4, 5, "ellipsis", 10]
247
+ * buildPaginationRange({ page: 6, pageCount: 10 });
248
+ * // [1, "ellipsis", 5, 6, 7, "ellipsis", 10]
249
+ * ```
250
+ */
251
+ declare function buildPaginationRange(params: BuildPaginationRangeParams): (number | "ellipsis")[];
252
+
253
+ export { type BuildPaginationRangeParams, type PaginationItem, type PaginationItemType, type PaginationRange, type UsePaginationOptions, type UsePaginationReturn, buildPaginationRange, clampPage, getPageCount, usePagination };
package/dist/index.js ADDED
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ buildPaginationRange: () => buildPaginationRange,
24
+ clampPage: () => clampPage,
25
+ getPageCount: () => getPageCount,
26
+ usePagination: () => usePagination
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/usePagination.ts
31
+ var import_react = require("react");
32
+ var import_use_controllable_state = require("@usefy/use-controllable-state");
33
+
34
+ // src/utils.ts
35
+ function toSafeInt(value, min, fallback) {
36
+ if (!Number.isFinite(value)) {
37
+ return fallback;
38
+ }
39
+ const floored = Math.floor(value);
40
+ return floored < min ? min : floored;
41
+ }
42
+ function getPageCount(total, pageSize) {
43
+ const safeTotal = toSafeInt(total, 0, 0);
44
+ const safeSize = toSafeInt(pageSize, 1, 1);
45
+ return Math.max(1, Math.ceil(safeTotal / safeSize));
46
+ }
47
+ function clampPage(page, pageCount) {
48
+ if (!Number.isFinite(page)) {
49
+ return 1;
50
+ }
51
+ const floored = Math.floor(page);
52
+ if (floored < 1) {
53
+ return 1;
54
+ }
55
+ return floored > pageCount ? pageCount : floored;
56
+ }
57
+ var inclusiveRange = (start, end) => start > end ? [] : Array.from({ length: end - start + 1 }, (_, index) => start + index);
58
+ function buildPaginationRange(params) {
59
+ const count = Math.max(1, Math.floor(params.pageCount));
60
+ const page = clampPage(params.page, count);
61
+ const siblingCount = Math.max(0, Math.floor(params.siblingCount ?? 1));
62
+ const boundaryCount = Math.max(0, Math.floor(params.boundaryCount ?? 1));
63
+ const startPages = inclusiveRange(1, Math.min(boundaryCount, count));
64
+ const endPages = inclusiveRange(
65
+ Math.max(count - boundaryCount + 1, boundaryCount + 1),
66
+ count
67
+ );
68
+ const siblingsStart = Math.max(
69
+ Math.min(page - siblingCount, count - boundaryCount - siblingCount * 2 - 1),
70
+ boundaryCount + 2
71
+ );
72
+ const siblingsEnd = Math.min(
73
+ Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),
74
+ endPages.length > 0 ? endPages[0] - 2 : count - 1
75
+ );
76
+ return [
77
+ ...startPages,
78
+ // Start gap: an ellipsis, or the single page it would have hidden.
79
+ ...siblingsStart > boundaryCount + 2 ? ["ellipsis"] : boundaryCount + 1 < count - boundaryCount ? [boundaryCount + 1] : [],
80
+ ...inclusiveRange(siblingsStart, siblingsEnd),
81
+ // End gap: an ellipsis, or the single page it would have hidden.
82
+ ...siblingsEnd < count - boundaryCount - 1 ? ["ellipsis"] : count - boundaryCount > boundaryCount ? [count - boundaryCount] : [],
83
+ ...endPages
84
+ ];
85
+ }
86
+
87
+ // src/usePagination.ts
88
+ var DEFAULT_PAGE_SIZE = 10;
89
+ var DEFAULT_SIBLING_COUNT = 1;
90
+ var DEFAULT_BOUNDARY_COUNT = 1;
91
+ function usePagination(options) {
92
+ const {
93
+ total: rawTotal,
94
+ pageSize: rawPageSize = DEFAULT_PAGE_SIZE,
95
+ page: controlledPage,
96
+ defaultPage = 1,
97
+ siblingCount = DEFAULT_SIBLING_COUNT,
98
+ boundaryCount = DEFAULT_BOUNDARY_COUNT,
99
+ onChange
100
+ } = options;
101
+ const total = toSafeInt(rawTotal, 0, 0);
102
+ const pageSize = toSafeInt(rawPageSize, 1, 1);
103
+ const pageCount = getPageCount(rawTotal, rawPageSize);
104
+ const [rawPage, setRawPage] = (0, import_use_controllable_state.useControllableState)({
105
+ value: controlledPage,
106
+ defaultValue: defaultPage,
107
+ onChange
108
+ });
109
+ const page = clampPage(rawPage, pageCount);
110
+ const stateRef = (0, import_react.useRef)({ page, pageCount });
111
+ stateRef.current = { page, pageCount };
112
+ const setRawPageRef = (0, import_react.useRef)(setRawPage);
113
+ setRawPageRef.current = setRawPage;
114
+ const setPage = (0, import_react.useCallback)((next2) => {
115
+ const { page: current, pageCount: count } = stateRef.current;
116
+ const target = clampPage(next2, count);
117
+ if (target === current) {
118
+ return;
119
+ }
120
+ setRawPageRef.current(target);
121
+ }, []);
122
+ const next = (0, import_react.useCallback)(() => {
123
+ setPage(stateRef.current.page + 1);
124
+ }, [setPage]);
125
+ const prev = (0, import_react.useCallback)(() => {
126
+ setPage(stateRef.current.page - 1);
127
+ }, [setPage]);
128
+ const first = (0, import_react.useCallback)(() => {
129
+ setPage(1);
130
+ }, [setPage]);
131
+ const last = (0, import_react.useCallback)(() => {
132
+ setPage(stateRef.current.pageCount);
133
+ }, [setPage]);
134
+ const canNext = page < pageCount;
135
+ const canPrev = page > 1;
136
+ const range = (0, import_react.useMemo)(() => {
137
+ const start = (page - 1) * pageSize;
138
+ const end = Math.min(start + pageSize, total);
139
+ return { start, end };
140
+ }, [page, pageSize, total]);
141
+ const items = (0, import_react.useMemo)(
142
+ () => buildPaginationRange({
143
+ page,
144
+ pageCount,
145
+ siblingCount,
146
+ boundaryCount
147
+ }).map(
148
+ (entry) => entry === "ellipsis" ? { type: "ellipsis", page: null, selected: false } : { type: "page", page: entry, selected: entry === page }
149
+ ),
150
+ [page, pageCount, siblingCount, boundaryCount]
151
+ );
152
+ return (0, import_react.useMemo)(
153
+ () => ({
154
+ page,
155
+ pageCount,
156
+ pageSize,
157
+ setPage,
158
+ next,
159
+ prev,
160
+ first,
161
+ last,
162
+ canNext,
163
+ canPrev,
164
+ range,
165
+ items
166
+ }),
167
+ [
168
+ page,
169
+ pageCount,
170
+ pageSize,
171
+ setPage,
172
+ next,
173
+ prev,
174
+ first,
175
+ last,
176
+ canNext,
177
+ canPrev,
178
+ range,
179
+ items
180
+ ]
181
+ );
182
+ }
183
+ // Annotate the CommonJS export names for ESM import in node:
184
+ 0 && (module.exports = {
185
+ buildPaginationRange,
186
+ clampPage,
187
+ getPageCount,
188
+ usePagination
189
+ });
190
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/usePagination.ts","../src/utils.ts"],"sourcesContent":["export { usePagination } from \"./usePagination\";\nexport type {\n UsePaginationOptions,\n UsePaginationReturn,\n PaginationItem,\n PaginationItemType,\n PaginationRange,\n} from \"./types\";\nexport {\n getPageCount,\n clampPage,\n buildPaginationRange,\n type BuildPaginationRangeParams,\n} from \"./utils\";\n","import { useCallback, useMemo, useRef } from \"react\";\nimport { useControllableState } from \"@usefy/use-controllable-state\";\nimport type {\n PaginationItem,\n PaginationRange,\n UsePaginationOptions,\n UsePaginationReturn,\n} from \"./types\";\nimport { buildPaginationRange, clampPage, getPageCount, toSafeInt } from \"./utils\";\n\nconst DEFAULT_PAGE_SIZE = 10;\nconst DEFAULT_SIBLING_COUNT = 1;\nconst DEFAULT_BOUNDARY_COUNT = 1;\n\n/**\n * A headless pagination state machine — the current page (controlled or\n * uncontrolled), the derived page count, a slice-ready index range, and an\n * ellipsis-aware page-number model for rendering a pager UI. It owns *state*\n * only; you render however you like.\n *\n * The current page is always kept within `[1, pageCount]`: it is clamped\n * **derived** from `total`/`pageSize`, so when the dataset shrinks the returned\n * `page` never points past the last page and the navigation controls stay\n * correct (a plain re-clamp, no surprise `onChange`). All controls\n * (`setPage`/`next`/`prev`/`first`/`last`) have stable identities and skip\n * no-op moves (staying on the current page does not re-render or fire\n * `onChange`).\n *\n * Works in both modes, mirroring `useControllableState`:\n * - **Uncontrolled** (`page` omitted): the hook owns the page, seeded from\n * `defaultPage`; `onChange` fires after each committed change.\n * - **Controlled** (`page` provided): the returned `page` mirrors the prop\n * (clamped) and the controls only call `onChange` — the parent owns the value.\n *\n * @param options - See {@link UsePaginationOptions}.\n * @returns The pagination state and controls — see {@link UsePaginationReturn}.\n *\n * @example\n * ```tsx\n * function UsersTable({ users }: { users: User[] }) {\n * const { page, pageCount, range, items, setPage, next, prev, canNext, canPrev } =\n * usePagination({ total: users.length, pageSize: 10 });\n *\n * const visible = users.slice(range.start, range.end);\n *\n * return (\n * <>\n * <ul>{visible.map((u) => <li key={u.id}>{u.name}</li>)}</ul>\n * <nav>\n * <button onClick={prev} disabled={!canPrev}>Prev</button>\n * {items.map((item, i) =>\n * item.type === \"ellipsis\" ? (\n * <span key={`gap-${i}`}>…</span>\n * ) : (\n * <button\n * key={item.page}\n * aria-current={item.selected}\n * onClick={() => setPage(item.page!)}\n * >\n * {item.page}\n * </button>\n * )\n * )}\n * <button onClick={next} disabled={!canNext}>Next</button>\n * </nav>\n * <p>Page {page} of {pageCount}</p>\n * </>\n * );\n * }\n * ```\n */\nexport function usePagination(\n options: UsePaginationOptions\n): UsePaginationReturn {\n const {\n total: rawTotal,\n pageSize: rawPageSize = DEFAULT_PAGE_SIZE,\n page: controlledPage,\n defaultPage = 1,\n siblingCount = DEFAULT_SIBLING_COUNT,\n boundaryCount = DEFAULT_BOUNDARY_COUNT,\n onChange,\n } = options;\n\n const total = toSafeInt(rawTotal, 0, 0);\n const pageSize = toSafeInt(rawPageSize, 1, 1);\n const pageCount = getPageCount(rawTotal, rawPageSize);\n\n // Raw (unclamped) page state, controllable. We always write *concrete*\n // clamped values through this setter (never updater functions), so the fact\n // that its internal value may drift out of range while the dataset shrinks\n // never leaks — the `page` below is always the clamped, derived value.\n const [rawPage, setRawPage] = useControllableState<number>({\n value: controlledPage,\n defaultValue: defaultPage,\n onChange,\n });\n\n const page = clampPage(rawPage, pageCount);\n\n // Mirror the latest derived values + setter into refs so the controls can be\n // fully identity-stable (empty dep arrays) while still reading fresh state.\n const stateRef = useRef({ page, pageCount });\n stateRef.current = { page, pageCount };\n\n const setRawPageRef = useRef(setRawPage);\n setRawPageRef.current = setRawPage;\n\n const setPage = useCallback((next: number) => {\n const { page: current, pageCount: count } = stateRef.current;\n const target = clampPage(next, count);\n if (target === current) {\n return; // no-op: staying put must not re-render or fire onChange\n }\n setRawPageRef.current(target);\n }, []);\n\n const next = useCallback(() => {\n setPage(stateRef.current.page + 1);\n }, [setPage]);\n\n const prev = useCallback(() => {\n setPage(stateRef.current.page - 1);\n }, [setPage]);\n\n const first = useCallback(() => {\n setPage(1);\n }, [setPage]);\n\n const last = useCallback(() => {\n setPage(stateRef.current.pageCount);\n }, [setPage]);\n\n const canNext = page < pageCount;\n const canPrev = page > 1;\n\n const range = useMemo<PaginationRange>(() => {\n const start = (page - 1) * pageSize;\n const end = Math.min(start + pageSize, total);\n return { start, end };\n }, [page, pageSize, total]);\n\n const items = useMemo<readonly PaginationItem[]>(\n () =>\n buildPaginationRange({\n page,\n pageCount,\n siblingCount,\n boundaryCount,\n }).map((entry) =>\n entry === \"ellipsis\"\n ? { type: \"ellipsis\", page: null, selected: false }\n : { type: \"page\", page: entry, selected: entry === page }\n ),\n [page, pageCount, siblingCount, boundaryCount]\n );\n\n return useMemo<UsePaginationReturn>(\n () => ({\n page,\n pageCount,\n pageSize,\n setPage,\n next,\n prev,\n first,\n last,\n canNext,\n canPrev,\n range,\n items,\n }),\n [\n page,\n pageCount,\n pageSize,\n setPage,\n next,\n prev,\n first,\n last,\n canNext,\n canPrev,\n range,\n items,\n ]\n );\n}\n","/**\n * Coerce a value to a safe integer, flooring fractions and enforcing a minimum.\n * Non-finite values (`NaN`, `Infinity`) fall back to `fallback`.\n *\n * @internal\n */\nexport function toSafeInt(value: number, min: number, fallback: number): number {\n if (!Number.isFinite(value)) {\n return fallback;\n }\n const floored = Math.floor(value);\n return floored < min ? min : floored;\n}\n\n/**\n * Compute the number of pages for a dataset. Always returns at least `1` — an\n * empty dataset still has a single (empty) page — so it is safe to use directly\n * as the upper clamp bound. `total` is treated as `>= 0` and `pageSize` as\n * `>= 1` (both floored; non-finite inputs are normalized).\n *\n * @param total - Total number of items.\n * @param pageSize - Items per page.\n * @returns The page count, `>= 1`.\n *\n * @example\n * ```ts\n * getPageCount(0, 10); // 1\n * getPageCount(25, 10); // 3\n * getPageCount(30, 10); // 3\n * ```\n */\nexport function getPageCount(total: number, pageSize: number): number {\n const safeTotal = toSafeInt(total, 0, 0);\n const safeSize = toSafeInt(pageSize, 1, 1);\n return Math.max(1, Math.ceil(safeTotal / safeSize));\n}\n\n/**\n * Clamp a requested page into `[1, pageCount]`, flooring fractions. Non-finite\n * requests fall back to page `1`. `pageCount` is assumed to be `>= 1` (as\n * returned by {@link getPageCount}).\n *\n * @param page - The requested 1-based page.\n * @param pageCount - The number of pages (upper bound).\n * @returns The clamped page, within `[1, pageCount]`.\n */\nexport function clampPage(page: number, pageCount: number): number {\n if (!Number.isFinite(page)) {\n return 1;\n }\n const floored = Math.floor(page);\n if (floored < 1) {\n return 1;\n }\n return floored > pageCount ? pageCount : floored;\n}\n\n/**\n * Parameters for {@link buildPaginationRange}.\n */\nexport interface BuildPaginationRangeParams {\n /** The current (clamped) 1-based page. */\n page: number;\n /** Total number of pages (`>= 1`). */\n pageCount: number;\n /** Pages shown on each side of the current page. @defaultValue 1 */\n siblingCount?: number;\n /** Pages shown at the start and end. @defaultValue 1 */\n boundaryCount?: number;\n}\n\nconst inclusiveRange = (start: number, end: number): number[] =>\n start > end\n ? []\n : Array.from({ length: end - start + 1 }, (_, index) => start + index);\n\n/**\n * Build the ordered list of page numbers with `\"ellipsis\"` gap tokens for a\n * pager UI — the well-known MUI/Mantine `usePagination` model. Always shows the\n * `boundaryCount` first/last pages and the `siblingCount` pages around the\n * current page, collapsing anything in between into a single `\"ellipsis\"`. When\n * a gap would hide exactly one page, that page is shown instead of an ellipsis.\n *\n * @param params - See {@link BuildPaginationRangeParams}.\n * @returns An ordered array of 1-based page numbers and `\"ellipsis\"` tokens.\n *\n * @example\n * ```ts\n * buildPaginationRange({ page: 1, pageCount: 10 });\n * // [1, 2, 3, 4, 5, \"ellipsis\", 10]\n * buildPaginationRange({ page: 6, pageCount: 10 });\n * // [1, \"ellipsis\", 5, 6, 7, \"ellipsis\", 10]\n * ```\n */\nexport function buildPaginationRange(\n params: BuildPaginationRangeParams\n): (number | \"ellipsis\")[] {\n const count = Math.max(1, Math.floor(params.pageCount));\n const page = clampPage(params.page, count);\n const siblingCount = Math.max(0, Math.floor(params.siblingCount ?? 1));\n const boundaryCount = Math.max(0, Math.floor(params.boundaryCount ?? 1));\n\n const startPages = inclusiveRange(1, Math.min(boundaryCount, count));\n const endPages = inclusiveRange(\n Math.max(count - boundaryCount + 1, boundaryCount + 1),\n count\n );\n\n const siblingsStart = Math.max(\n Math.min(page - siblingCount, count - boundaryCount - siblingCount * 2 - 1),\n boundaryCount + 2\n );\n const siblingsEnd = Math.min(\n Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),\n endPages.length > 0 ? endPages[0] - 2 : count - 1\n );\n\n return [\n ...startPages,\n // Start gap: an ellipsis, or the single page it would have hidden.\n ...(siblingsStart > boundaryCount + 2\n ? ([\"ellipsis\"] as const)\n : boundaryCount + 1 < count - boundaryCount\n ? [boundaryCount + 1]\n : []),\n ...inclusiveRange(siblingsStart, siblingsEnd),\n // End gap: an ellipsis, or the single page it would have hidden.\n ...(siblingsEnd < count - boundaryCount - 1\n ? ([\"ellipsis\"] as const)\n : count - boundaryCount > boundaryCount\n ? [count - boundaryCount]\n : []),\n ...endPages,\n ];\n}\n"],"mappings":"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","names":["next"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,160 @@
1
+ // src/usePagination.ts
2
+ import { useCallback, useMemo, useRef } from "react";
3
+ import { useControllableState } from "@usefy/use-controllable-state";
4
+
5
+ // src/utils.ts
6
+ function toSafeInt(value, min, fallback) {
7
+ if (!Number.isFinite(value)) {
8
+ return fallback;
9
+ }
10
+ const floored = Math.floor(value);
11
+ return floored < min ? min : floored;
12
+ }
13
+ function getPageCount(total, pageSize) {
14
+ const safeTotal = toSafeInt(total, 0, 0);
15
+ const safeSize = toSafeInt(pageSize, 1, 1);
16
+ return Math.max(1, Math.ceil(safeTotal / safeSize));
17
+ }
18
+ function clampPage(page, pageCount) {
19
+ if (!Number.isFinite(page)) {
20
+ return 1;
21
+ }
22
+ const floored = Math.floor(page);
23
+ if (floored < 1) {
24
+ return 1;
25
+ }
26
+ return floored > pageCount ? pageCount : floored;
27
+ }
28
+ var inclusiveRange = (start, end) => start > end ? [] : Array.from({ length: end - start + 1 }, (_, index) => start + index);
29
+ function buildPaginationRange(params) {
30
+ const count = Math.max(1, Math.floor(params.pageCount));
31
+ const page = clampPage(params.page, count);
32
+ const siblingCount = Math.max(0, Math.floor(params.siblingCount ?? 1));
33
+ const boundaryCount = Math.max(0, Math.floor(params.boundaryCount ?? 1));
34
+ const startPages = inclusiveRange(1, Math.min(boundaryCount, count));
35
+ const endPages = inclusiveRange(
36
+ Math.max(count - boundaryCount + 1, boundaryCount + 1),
37
+ count
38
+ );
39
+ const siblingsStart = Math.max(
40
+ Math.min(page - siblingCount, count - boundaryCount - siblingCount * 2 - 1),
41
+ boundaryCount + 2
42
+ );
43
+ const siblingsEnd = Math.min(
44
+ Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),
45
+ endPages.length > 0 ? endPages[0] - 2 : count - 1
46
+ );
47
+ return [
48
+ ...startPages,
49
+ // Start gap: an ellipsis, or the single page it would have hidden.
50
+ ...siblingsStart > boundaryCount + 2 ? ["ellipsis"] : boundaryCount + 1 < count - boundaryCount ? [boundaryCount + 1] : [],
51
+ ...inclusiveRange(siblingsStart, siblingsEnd),
52
+ // End gap: an ellipsis, or the single page it would have hidden.
53
+ ...siblingsEnd < count - boundaryCount - 1 ? ["ellipsis"] : count - boundaryCount > boundaryCount ? [count - boundaryCount] : [],
54
+ ...endPages
55
+ ];
56
+ }
57
+
58
+ // src/usePagination.ts
59
+ var DEFAULT_PAGE_SIZE = 10;
60
+ var DEFAULT_SIBLING_COUNT = 1;
61
+ var DEFAULT_BOUNDARY_COUNT = 1;
62
+ function usePagination(options) {
63
+ const {
64
+ total: rawTotal,
65
+ pageSize: rawPageSize = DEFAULT_PAGE_SIZE,
66
+ page: controlledPage,
67
+ defaultPage = 1,
68
+ siblingCount = DEFAULT_SIBLING_COUNT,
69
+ boundaryCount = DEFAULT_BOUNDARY_COUNT,
70
+ onChange
71
+ } = options;
72
+ const total = toSafeInt(rawTotal, 0, 0);
73
+ const pageSize = toSafeInt(rawPageSize, 1, 1);
74
+ const pageCount = getPageCount(rawTotal, rawPageSize);
75
+ const [rawPage, setRawPage] = useControllableState({
76
+ value: controlledPage,
77
+ defaultValue: defaultPage,
78
+ onChange
79
+ });
80
+ const page = clampPage(rawPage, pageCount);
81
+ const stateRef = useRef({ page, pageCount });
82
+ stateRef.current = { page, pageCount };
83
+ const setRawPageRef = useRef(setRawPage);
84
+ setRawPageRef.current = setRawPage;
85
+ const setPage = useCallback((next2) => {
86
+ const { page: current, pageCount: count } = stateRef.current;
87
+ const target = clampPage(next2, count);
88
+ if (target === current) {
89
+ return;
90
+ }
91
+ setRawPageRef.current(target);
92
+ }, []);
93
+ const next = useCallback(() => {
94
+ setPage(stateRef.current.page + 1);
95
+ }, [setPage]);
96
+ const prev = useCallback(() => {
97
+ setPage(stateRef.current.page - 1);
98
+ }, [setPage]);
99
+ const first = useCallback(() => {
100
+ setPage(1);
101
+ }, [setPage]);
102
+ const last = useCallback(() => {
103
+ setPage(stateRef.current.pageCount);
104
+ }, [setPage]);
105
+ const canNext = page < pageCount;
106
+ const canPrev = page > 1;
107
+ const range = useMemo(() => {
108
+ const start = (page - 1) * pageSize;
109
+ const end = Math.min(start + pageSize, total);
110
+ return { start, end };
111
+ }, [page, pageSize, total]);
112
+ const items = useMemo(
113
+ () => buildPaginationRange({
114
+ page,
115
+ pageCount,
116
+ siblingCount,
117
+ boundaryCount
118
+ }).map(
119
+ (entry) => entry === "ellipsis" ? { type: "ellipsis", page: null, selected: false } : { type: "page", page: entry, selected: entry === page }
120
+ ),
121
+ [page, pageCount, siblingCount, boundaryCount]
122
+ );
123
+ return useMemo(
124
+ () => ({
125
+ page,
126
+ pageCount,
127
+ pageSize,
128
+ setPage,
129
+ next,
130
+ prev,
131
+ first,
132
+ last,
133
+ canNext,
134
+ canPrev,
135
+ range,
136
+ items
137
+ }),
138
+ [
139
+ page,
140
+ pageCount,
141
+ pageSize,
142
+ setPage,
143
+ next,
144
+ prev,
145
+ first,
146
+ last,
147
+ canNext,
148
+ canPrev,
149
+ range,
150
+ items
151
+ ]
152
+ );
153
+ }
154
+ export {
155
+ buildPaginationRange,
156
+ clampPage,
157
+ getPageCount,
158
+ usePagination
159
+ };
160
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/usePagination.ts","../src/utils.ts"],"sourcesContent":["import { useCallback, useMemo, useRef } from \"react\";\nimport { useControllableState } from \"@usefy/use-controllable-state\";\nimport type {\n PaginationItem,\n PaginationRange,\n UsePaginationOptions,\n UsePaginationReturn,\n} from \"./types\";\nimport { buildPaginationRange, clampPage, getPageCount, toSafeInt } from \"./utils\";\n\nconst DEFAULT_PAGE_SIZE = 10;\nconst DEFAULT_SIBLING_COUNT = 1;\nconst DEFAULT_BOUNDARY_COUNT = 1;\n\n/**\n * A headless pagination state machine — the current page (controlled or\n * uncontrolled), the derived page count, a slice-ready index range, and an\n * ellipsis-aware page-number model for rendering a pager UI. It owns *state*\n * only; you render however you like.\n *\n * The current page is always kept within `[1, pageCount]`: it is clamped\n * **derived** from `total`/`pageSize`, so when the dataset shrinks the returned\n * `page` never points past the last page and the navigation controls stay\n * correct (a plain re-clamp, no surprise `onChange`). All controls\n * (`setPage`/`next`/`prev`/`first`/`last`) have stable identities and skip\n * no-op moves (staying on the current page does not re-render or fire\n * `onChange`).\n *\n * Works in both modes, mirroring `useControllableState`:\n * - **Uncontrolled** (`page` omitted): the hook owns the page, seeded from\n * `defaultPage`; `onChange` fires after each committed change.\n * - **Controlled** (`page` provided): the returned `page` mirrors the prop\n * (clamped) and the controls only call `onChange` — the parent owns the value.\n *\n * @param options - See {@link UsePaginationOptions}.\n * @returns The pagination state and controls — see {@link UsePaginationReturn}.\n *\n * @example\n * ```tsx\n * function UsersTable({ users }: { users: User[] }) {\n * const { page, pageCount, range, items, setPage, next, prev, canNext, canPrev } =\n * usePagination({ total: users.length, pageSize: 10 });\n *\n * const visible = users.slice(range.start, range.end);\n *\n * return (\n * <>\n * <ul>{visible.map((u) => <li key={u.id}>{u.name}</li>)}</ul>\n * <nav>\n * <button onClick={prev} disabled={!canPrev}>Prev</button>\n * {items.map((item, i) =>\n * item.type === \"ellipsis\" ? (\n * <span key={`gap-${i}`}>…</span>\n * ) : (\n * <button\n * key={item.page}\n * aria-current={item.selected}\n * onClick={() => setPage(item.page!)}\n * >\n * {item.page}\n * </button>\n * )\n * )}\n * <button onClick={next} disabled={!canNext}>Next</button>\n * </nav>\n * <p>Page {page} of {pageCount}</p>\n * </>\n * );\n * }\n * ```\n */\nexport function usePagination(\n options: UsePaginationOptions\n): UsePaginationReturn {\n const {\n total: rawTotal,\n pageSize: rawPageSize = DEFAULT_PAGE_SIZE,\n page: controlledPage,\n defaultPage = 1,\n siblingCount = DEFAULT_SIBLING_COUNT,\n boundaryCount = DEFAULT_BOUNDARY_COUNT,\n onChange,\n } = options;\n\n const total = toSafeInt(rawTotal, 0, 0);\n const pageSize = toSafeInt(rawPageSize, 1, 1);\n const pageCount = getPageCount(rawTotal, rawPageSize);\n\n // Raw (unclamped) page state, controllable. We always write *concrete*\n // clamped values through this setter (never updater functions), so the fact\n // that its internal value may drift out of range while the dataset shrinks\n // never leaks — the `page` below is always the clamped, derived value.\n const [rawPage, setRawPage] = useControllableState<number>({\n value: controlledPage,\n defaultValue: defaultPage,\n onChange,\n });\n\n const page = clampPage(rawPage, pageCount);\n\n // Mirror the latest derived values + setter into refs so the controls can be\n // fully identity-stable (empty dep arrays) while still reading fresh state.\n const stateRef = useRef({ page, pageCount });\n stateRef.current = { page, pageCount };\n\n const setRawPageRef = useRef(setRawPage);\n setRawPageRef.current = setRawPage;\n\n const setPage = useCallback((next: number) => {\n const { page: current, pageCount: count } = stateRef.current;\n const target = clampPage(next, count);\n if (target === current) {\n return; // no-op: staying put must not re-render or fire onChange\n }\n setRawPageRef.current(target);\n }, []);\n\n const next = useCallback(() => {\n setPage(stateRef.current.page + 1);\n }, [setPage]);\n\n const prev = useCallback(() => {\n setPage(stateRef.current.page - 1);\n }, [setPage]);\n\n const first = useCallback(() => {\n setPage(1);\n }, [setPage]);\n\n const last = useCallback(() => {\n setPage(stateRef.current.pageCount);\n }, [setPage]);\n\n const canNext = page < pageCount;\n const canPrev = page > 1;\n\n const range = useMemo<PaginationRange>(() => {\n const start = (page - 1) * pageSize;\n const end = Math.min(start + pageSize, total);\n return { start, end };\n }, [page, pageSize, total]);\n\n const items = useMemo<readonly PaginationItem[]>(\n () =>\n buildPaginationRange({\n page,\n pageCount,\n siblingCount,\n boundaryCount,\n }).map((entry) =>\n entry === \"ellipsis\"\n ? { type: \"ellipsis\", page: null, selected: false }\n : { type: \"page\", page: entry, selected: entry === page }\n ),\n [page, pageCount, siblingCount, boundaryCount]\n );\n\n return useMemo<UsePaginationReturn>(\n () => ({\n page,\n pageCount,\n pageSize,\n setPage,\n next,\n prev,\n first,\n last,\n canNext,\n canPrev,\n range,\n items,\n }),\n [\n page,\n pageCount,\n pageSize,\n setPage,\n next,\n prev,\n first,\n last,\n canNext,\n canPrev,\n range,\n items,\n ]\n );\n}\n","/**\n * Coerce a value to a safe integer, flooring fractions and enforcing a minimum.\n * Non-finite values (`NaN`, `Infinity`) fall back to `fallback`.\n *\n * @internal\n */\nexport function toSafeInt(value: number, min: number, fallback: number): number {\n if (!Number.isFinite(value)) {\n return fallback;\n }\n const floored = Math.floor(value);\n return floored < min ? min : floored;\n}\n\n/**\n * Compute the number of pages for a dataset. Always returns at least `1` — an\n * empty dataset still has a single (empty) page — so it is safe to use directly\n * as the upper clamp bound. `total` is treated as `>= 0` and `pageSize` as\n * `>= 1` (both floored; non-finite inputs are normalized).\n *\n * @param total - Total number of items.\n * @param pageSize - Items per page.\n * @returns The page count, `>= 1`.\n *\n * @example\n * ```ts\n * getPageCount(0, 10); // 1\n * getPageCount(25, 10); // 3\n * getPageCount(30, 10); // 3\n * ```\n */\nexport function getPageCount(total: number, pageSize: number): number {\n const safeTotal = toSafeInt(total, 0, 0);\n const safeSize = toSafeInt(pageSize, 1, 1);\n return Math.max(1, Math.ceil(safeTotal / safeSize));\n}\n\n/**\n * Clamp a requested page into `[1, pageCount]`, flooring fractions. Non-finite\n * requests fall back to page `1`. `pageCount` is assumed to be `>= 1` (as\n * returned by {@link getPageCount}).\n *\n * @param page - The requested 1-based page.\n * @param pageCount - The number of pages (upper bound).\n * @returns The clamped page, within `[1, pageCount]`.\n */\nexport function clampPage(page: number, pageCount: number): number {\n if (!Number.isFinite(page)) {\n return 1;\n }\n const floored = Math.floor(page);\n if (floored < 1) {\n return 1;\n }\n return floored > pageCount ? pageCount : floored;\n}\n\n/**\n * Parameters for {@link buildPaginationRange}.\n */\nexport interface BuildPaginationRangeParams {\n /** The current (clamped) 1-based page. */\n page: number;\n /** Total number of pages (`>= 1`). */\n pageCount: number;\n /** Pages shown on each side of the current page. @defaultValue 1 */\n siblingCount?: number;\n /** Pages shown at the start and end. @defaultValue 1 */\n boundaryCount?: number;\n}\n\nconst inclusiveRange = (start: number, end: number): number[] =>\n start > end\n ? []\n : Array.from({ length: end - start + 1 }, (_, index) => start + index);\n\n/**\n * Build the ordered list of page numbers with `\"ellipsis\"` gap tokens for a\n * pager UI — the well-known MUI/Mantine `usePagination` model. Always shows the\n * `boundaryCount` first/last pages and the `siblingCount` pages around the\n * current page, collapsing anything in between into a single `\"ellipsis\"`. When\n * a gap would hide exactly one page, that page is shown instead of an ellipsis.\n *\n * @param params - See {@link BuildPaginationRangeParams}.\n * @returns An ordered array of 1-based page numbers and `\"ellipsis\"` tokens.\n *\n * @example\n * ```ts\n * buildPaginationRange({ page: 1, pageCount: 10 });\n * // [1, 2, 3, 4, 5, \"ellipsis\", 10]\n * buildPaginationRange({ page: 6, pageCount: 10 });\n * // [1, \"ellipsis\", 5, 6, 7, \"ellipsis\", 10]\n * ```\n */\nexport function buildPaginationRange(\n params: BuildPaginationRangeParams\n): (number | \"ellipsis\")[] {\n const count = Math.max(1, Math.floor(params.pageCount));\n const page = clampPage(params.page, count);\n const siblingCount = Math.max(0, Math.floor(params.siblingCount ?? 1));\n const boundaryCount = Math.max(0, Math.floor(params.boundaryCount ?? 1));\n\n const startPages = inclusiveRange(1, Math.min(boundaryCount, count));\n const endPages = inclusiveRange(\n Math.max(count - boundaryCount + 1, boundaryCount + 1),\n count\n );\n\n const siblingsStart = Math.max(\n Math.min(page - siblingCount, count - boundaryCount - siblingCount * 2 - 1),\n boundaryCount + 2\n );\n const siblingsEnd = Math.min(\n Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),\n endPages.length > 0 ? endPages[0] - 2 : count - 1\n );\n\n return [\n ...startPages,\n // Start gap: an ellipsis, or the single page it would have hidden.\n ...(siblingsStart > boundaryCount + 2\n ? ([\"ellipsis\"] as const)\n : boundaryCount + 1 < count - boundaryCount\n ? [boundaryCount + 1]\n : []),\n ...inclusiveRange(siblingsStart, siblingsEnd),\n // End gap: an ellipsis, or the single page it would have hidden.\n ...(siblingsEnd < count - boundaryCount - 1\n ? ([\"ellipsis\"] as const)\n : count - boundaryCount > boundaryCount\n ? [count - boundaryCount]\n : []),\n ...endPages,\n ];\n}\n"],"mappings":"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","names":["next"]}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@usefy/use-pagination",
3
+ "version": "0.20.0",
4
+ "description": "A React hook for pagination state — controlled/uncontrolled current page, page count, range, and an ellipsis-aware page-number model",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "sideEffects": false,
19
+ "dependencies": {
20
+ "@usefy/use-controllable-state": "0.20.0"
21
+ },
22
+ "peerDependencies": {
23
+ "react": "^18.0.0 || ^19.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@testing-library/jest-dom": "^6.9.1",
27
+ "@testing-library/react": "^16.3.1",
28
+ "@testing-library/user-event": "^14.6.1",
29
+ "@types/react": "^19.0.0",
30
+ "jsdom": "^27.3.0",
31
+ "react": "^19.0.0",
32
+ "rimraf": "^6.0.1",
33
+ "tsup": "^8.0.0",
34
+ "typescript": "^5.0.0",
35
+ "vitest": "^4.0.16"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/mirunamu00/usefy.git",
43
+ "directory": "packages/hooks/use-pagination"
44
+ },
45
+ "license": "MIT",
46
+ "keywords": [
47
+ "react",
48
+ "hooks",
49
+ "pagination",
50
+ "paginate",
51
+ "pager",
52
+ "page",
53
+ "controllable",
54
+ "ellipsis",
55
+ "usePagination"
56
+ ],
57
+ "scripts": {
58
+ "build": "tsup",
59
+ "dev": "tsup --watch",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "typecheck": "tsc --noEmit",
63
+ "clean": "rimraf dist"
64
+ }
65
+ }