@dashforge/tw 0.4.0-beta → 0.5.0-beta

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/dist/index.esm.js +564 -2
  3. package/dist/src/components/Pagination/Pagination.d.ts +32 -0
  4. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -0
  5. package/dist/src/components/Pagination/pagination.helpers.d.ts +23 -0
  6. package/dist/src/components/Pagination/pagination.helpers.d.ts.map +1 -0
  7. package/dist/src/components/Pagination/pagination.types.d.ts +120 -0
  8. package/dist/src/components/Pagination/pagination.types.d.ts.map +1 -0
  9. package/dist/src/components/Pagination/pagination.variants.d.ts +150 -0
  10. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -0
  11. package/dist/src/components/Skeleton/Skeleton.d.ts +23 -0
  12. package/dist/src/components/Skeleton/Skeleton.d.ts.map +1 -0
  13. package/dist/src/components/Skeleton/skeleton.types.d.ts +56 -0
  14. package/dist/src/components/Skeleton/skeleton.types.d.ts.map +1 -0
  15. package/dist/src/components/Skeleton/skeleton.variants.d.ts +110 -0
  16. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -0
  17. package/dist/src/index.d.ts +7 -1
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/package.json +1 -1
  20. package/src/components/Pagination/Pagination.test.tsx +314 -0
  21. package/src/components/Pagination/Pagination.tsx +275 -0
  22. package/src/components/Pagination/pagination.helpers.ts +102 -0
  23. package/src/components/Pagination/pagination.types.ts +117 -0
  24. package/src/components/Pagination/pagination.variants.ts +111 -0
  25. package/src/components/Skeleton/Skeleton.test.tsx +127 -0
  26. package/src/components/Skeleton/Skeleton.tsx +49 -0
  27. package/src/components/Skeleton/skeleton.types.ts +59 -0
  28. package/src/components/Skeleton/skeleton.variants.ts +86 -0
  29. package/src/index.ts +18 -1
@@ -0,0 +1,314 @@
1
+ // @vitest-environment jsdom
2
+ import * as React from 'react';
3
+ import { describe, it, expect, afterEach } from 'vitest';
4
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
5
+ import { Pagination } from './Pagination.js';
6
+ import { computePageRange } from './pagination.helpers.js';
7
+
8
+ void React;
9
+ afterEach(() => cleanup());
10
+
11
+ describe('computePageRange — helper', () => {
12
+ it('returns empty for totalPages=0', () => {
13
+ expect(computePageRange(1, 0, 1, 1)).toEqual([]);
14
+ });
15
+
16
+ it('returns [1] for totalPages=1', () => {
17
+ expect(computePageRange(1, 1, 1, 1)).toEqual([1]);
18
+ });
19
+
20
+ it('lists all pages when totalPages <= window size', () => {
21
+ expect(computePageRange(1, 5, 1, 1)).toEqual([1, 2, 3, 4, 5]);
22
+ });
23
+
24
+ it('inserts ellipsis on the right when current is near start', () => {
25
+ const r = computePageRange(2, 20, 1, 1);
26
+ expect(r).toContain(1);
27
+ expect(r).toContain(2);
28
+ expect(r).toContain(3);
29
+ expect(r).toContain('ellipsis');
30
+ expect(r).toContain(20);
31
+ expect(r).not.toContain(10);
32
+ });
33
+
34
+ it('inserts ellipsis on both sides when current is in the middle', () => {
35
+ const r = computePageRange(10, 20, 1, 1);
36
+ expect(r.filter((x) => x === 'ellipsis').length).toBeGreaterThanOrEqual(1);
37
+ expect(r).toContain(1);
38
+ expect(r).toContain(9);
39
+ expect(r).toContain(10);
40
+ expect(r).toContain(11);
41
+ expect(r).toContain(20);
42
+ });
43
+
44
+ it('inserts ellipsis on the left when current is near end', () => {
45
+ const r = computePageRange(19, 20, 1, 1);
46
+ expect(r).toContain(1);
47
+ expect(r).toContain('ellipsis');
48
+ expect(r).toContain(18);
49
+ expect(r).toContain(19);
50
+ expect(r).toContain(20);
51
+ });
52
+
53
+ it('respects siblingCount=2', () => {
54
+ const r = computePageRange(10, 20, 2, 1);
55
+ expect(r).toContain(8);
56
+ expect(r).toContain(9);
57
+ expect(r).toContain(10);
58
+ expect(r).toContain(11);
59
+ expect(r).toContain(12);
60
+ });
61
+
62
+ it('respects boundaryCount=2', () => {
63
+ const r = computePageRange(10, 20, 1, 2);
64
+ expect(r).toContain(1);
65
+ expect(r).toContain(2);
66
+ expect(r).toContain(19);
67
+ expect(r).toContain(20);
68
+ });
69
+ });
70
+
71
+ describe('Pagination — rendering (default variant)', () => {
72
+ it('renders nav landmark with aria-label', () => {
73
+ render(<Pagination page={1} pageSize={10} totalCount={50} onPageChange={() => {}} />);
74
+ expect(screen.getByRole('navigation', { name: 'Pagination' })).not.toBeNull();
75
+ });
76
+
77
+ it('renders the "Showing X-Y of Z" summary', () => {
78
+ render(<Pagination page={1} pageSize={10} totalCount={50} onPageChange={() => {}} />);
79
+ expect(screen.getByText(/Showing 1-10 of 50/)).not.toBeNull();
80
+ });
81
+
82
+ it('renders correct summary on last page (partial fill)', () => {
83
+ render(<Pagination page={5} pageSize={10} totalCount={47} onPageChange={() => {}} />);
84
+ expect(screen.getByText(/Showing 41-47 of 47/)).not.toBeNull();
85
+ });
86
+
87
+ it('marks active page with aria-current=page', () => {
88
+ render(<Pagination page={3} pageSize={10} totalCount={50} onPageChange={() => {}} />);
89
+ const active = screen.getByRole('button', { name: 'Page 3' });
90
+ expect(active.getAttribute('aria-current')).toBe('page');
91
+ });
92
+
93
+ it('disables Prev + First on page 1', () => {
94
+ render(<Pagination page={1} pageSize={10} totalCount={50} onPageChange={() => {}} />);
95
+ expect((screen.getByRole('button', { name: 'Previous' }) as HTMLButtonElement).disabled).toBe(true);
96
+ expect((screen.getByRole('button', { name: 'First' }) as HTMLButtonElement).disabled).toBe(true);
97
+ });
98
+
99
+ it('disables Next + Last on last page', () => {
100
+ render(<Pagination page={5} pageSize={10} totalCount={50} onPageChange={() => {}} />);
101
+ expect((screen.getByRole('button', { name: 'Next' }) as HTMLButtonElement).disabled).toBe(true);
102
+ expect((screen.getByRole('button', { name: 'Last' }) as HTMLButtonElement).disabled).toBe(true);
103
+ });
104
+ });
105
+
106
+ describe('Pagination — interactions', () => {
107
+ it('emits onPageChange when a page button is clicked', () => {
108
+ let last = 0;
109
+ render(
110
+ <Pagination
111
+ page={1}
112
+ pageSize={10}
113
+ totalCount={50}
114
+ onPageChange={(p) => (last = p)}
115
+ />,
116
+ );
117
+ fireEvent.click(screen.getByRole('button', { name: 'Page 3' }));
118
+ expect(last).toBe(3);
119
+ });
120
+
121
+ it('Prev decrements page', () => {
122
+ let last = 0;
123
+ render(
124
+ <Pagination
125
+ page={3}
126
+ pageSize={10}
127
+ totalCount={50}
128
+ onPageChange={(p) => (last = p)}
129
+ />,
130
+ );
131
+ fireEvent.click(screen.getByRole('button', { name: 'Previous' }));
132
+ expect(last).toBe(2);
133
+ });
134
+
135
+ it('Next increments page', () => {
136
+ let last = 0;
137
+ render(
138
+ <Pagination
139
+ page={3}
140
+ pageSize={10}
141
+ totalCount={50}
142
+ onPageChange={(p) => (last = p)}
143
+ />,
144
+ );
145
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
146
+ expect(last).toBe(4);
147
+ });
148
+
149
+ it('First/Last jump to extremes', () => {
150
+ let last = 0;
151
+ const { rerender } = render(
152
+ <Pagination
153
+ page={3}
154
+ pageSize={10}
155
+ totalCount={50}
156
+ onPageChange={(p) => (last = p)}
157
+ />,
158
+ );
159
+ fireEvent.click(screen.getByRole('button', { name: 'First' }));
160
+ expect(last).toBe(1);
161
+ rerender(
162
+ <Pagination
163
+ page={3}
164
+ pageSize={10}
165
+ totalCount={50}
166
+ onPageChange={(p) => (last = p)}
167
+ />,
168
+ );
169
+ fireEvent.click(screen.getByRole('button', { name: 'Last' }));
170
+ expect(last).toBe(5);
171
+ });
172
+
173
+ it('emits onPageSizeChange when selector changes', () => {
174
+ let lastSize = 0;
175
+ render(
176
+ <Pagination
177
+ page={1}
178
+ pageSize={10}
179
+ totalCount={100}
180
+ onPageChange={() => {}}
181
+ onPageSizeChange={(s) => (lastSize = s)}
182
+ />,
183
+ );
184
+ const selector = screen.getByLabelText('per page') as HTMLSelectElement;
185
+ fireEvent.change(selector, { target: { value: '50' } });
186
+ expect(lastSize).toBe(50);
187
+ });
188
+
189
+ it('hides page-size selector if onPageSizeChange not provided', () => {
190
+ render(<Pagination page={1} pageSize={10} totalCount={50} onPageChange={() => {}} />);
191
+ expect(screen.queryByLabelText('per page')).toBeNull();
192
+ });
193
+ });
194
+
195
+ describe('Pagination — variants', () => {
196
+ it('compact variant hides summary + selector', () => {
197
+ render(
198
+ <Pagination
199
+ variant="compact"
200
+ page={1}
201
+ pageSize={10}
202
+ totalCount={50}
203
+ onPageChange={() => {}}
204
+ onPageSizeChange={() => {}}
205
+ />,
206
+ );
207
+ expect(screen.queryByText(/Showing/)).toBeNull();
208
+ expect(screen.queryByLabelText('per page')).toBeNull();
209
+ });
210
+
211
+ it('minimal variant shows "Page X of Y" + only Prev/Next', () => {
212
+ render(
213
+ <Pagination
214
+ variant="minimal"
215
+ page={3}
216
+ pageSize={10}
217
+ totalCount={50}
218
+ onPageChange={() => {}}
219
+ />,
220
+ );
221
+ expect(screen.getByText(/Page 3 of 5/)).not.toBeNull();
222
+ // Page-number buttons should NOT exist
223
+ expect(screen.queryByRole('button', { name: 'Page 1' })).toBeNull();
224
+ // Prev / Next remain
225
+ expect(screen.queryByRole('button', { name: 'Previous' })).not.toBeNull();
226
+ expect(screen.queryByRole('button', { name: 'Next' })).not.toBeNull();
227
+ });
228
+ });
229
+
230
+ describe('Pagination — i18n', () => {
231
+ it('honors custom labels', () => {
232
+ render(
233
+ <Pagination
234
+ page={1}
235
+ pageSize={10}
236
+ totalCount={50}
237
+ onPageChange={() => {}}
238
+ labels={{ showing: 'Mostro', of: 'di', prev: 'Indietro', next: 'Avanti' }}
239
+ />,
240
+ );
241
+ expect(screen.getByText(/Mostro 1-10 di 50/)).not.toBeNull();
242
+ expect(screen.queryByRole('button', { name: 'Indietro' })).not.toBeNull();
243
+ expect(screen.queryByRole('button', { name: 'Avanti' })).not.toBeNull();
244
+ });
245
+ });
246
+
247
+ describe('Pagination — disabled', () => {
248
+ it('disables all buttons when disabled=true', () => {
249
+ render(
250
+ <Pagination
251
+ page={3}
252
+ pageSize={10}
253
+ totalCount={50}
254
+ onPageChange={() => {}}
255
+ disabled
256
+ />,
257
+ );
258
+ expect((screen.getByRole('button', { name: 'Previous' }) as HTMLButtonElement).disabled).toBe(true);
259
+ expect((screen.getByRole('button', { name: 'Next' }) as HTMLButtonElement).disabled).toBe(true);
260
+ expect((screen.getByRole('button', { name: 'Page 2' }) as HTMLButtonElement).disabled).toBe(true);
261
+ });
262
+ });
263
+
264
+ describe('Pagination — sx + slotProps', () => {
265
+ it('applies sx to root', () => {
266
+ const { container } = render(
267
+ <Pagination
268
+ page={1}
269
+ pageSize={10}
270
+ totalCount={50}
271
+ onPageChange={() => {}}
272
+ sx="my-custom-root"
273
+ />,
274
+ );
275
+ expect(container.querySelector('nav')!.className).toContain('my-custom-root');
276
+ });
277
+
278
+ it('applies slotProps.pageButton.className', () => {
279
+ render(
280
+ <Pagination
281
+ page={1}
282
+ pageSize={10}
283
+ totalCount={50}
284
+ onPageChange={() => {}}
285
+ slotProps={{ pageButton: { className: 'slot-page' } }}
286
+ />,
287
+ );
288
+ expect(screen.getByRole('button', { name: 'Page 2' }).className).toContain('slot-page');
289
+ });
290
+ });
291
+
292
+ describe('Pagination — edge cases', () => {
293
+ it('renders correctly with totalCount=0', () => {
294
+ render(<Pagination page={1} pageSize={10} totalCount={0} onPageChange={() => {}} />);
295
+ expect(screen.getByText(/Showing 0-0 of 0/)).not.toBeNull();
296
+ expect((screen.getByRole('button', { name: 'Previous' }) as HTMLButtonElement).disabled).toBe(true);
297
+ expect((screen.getByRole('button', { name: 'Next' }) as HTMLButtonElement).disabled).toBe(true);
298
+ });
299
+
300
+ it('clamps page > totalPages internally without crashing', () => {
301
+ let last = 0;
302
+ render(
303
+ <Pagination
304
+ page={99}
305
+ pageSize={10}
306
+ totalCount={50}
307
+ onPageChange={(p) => (last = p)}
308
+ />,
309
+ );
310
+ // Component computes safePage=5 (last valid). User clicks Prev → goes to 4.
311
+ fireEvent.click(screen.getByRole('button', { name: 'Previous' }));
312
+ expect(last).toBe(4);
313
+ });
314
+ });
@@ -0,0 +1,275 @@
1
+ import { useState, useCallback, type ChangeEvent, type KeyboardEvent } from 'react';
2
+ import { cn } from '../../utils/cn.js';
3
+ import { paginationVariants } from './pagination.variants.js';
4
+ import { computePageRange } from './pagination.helpers.js';
5
+ import type { PaginationProps, PaginationLabels } from './pagination.types.js';
6
+
7
+ const DEFAULT_LABELS: Required<PaginationLabels> = {
8
+ first: 'First',
9
+ prev: 'Previous',
10
+ next: 'Next',
11
+ last: 'Last',
12
+ page: 'Page',
13
+ of: 'of',
14
+ showing: 'Showing',
15
+ perPage: 'per page',
16
+ goToPage: 'Go to page',
17
+ };
18
+
19
+ const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
20
+
21
+ /**
22
+ * Dashforge TW `<Pagination>` — controlled pagination primitive.
23
+ *
24
+ * Three variants:
25
+ * - `default` — page-size selector + summary + page numbers + nav buttons + jump input
26
+ * - `compact` — page numbers + nav buttons only (no summary, no selector, no jump)
27
+ * - `minimal` — "Page X of Y" + prev/next only
28
+ *
29
+ * Always controlled (no internal state besides the jump-input draft).
30
+ *
31
+ * A11Y:
32
+ * - `<nav aria-label="Pagination">` root
33
+ * - `aria-current="page"` on the active page button
34
+ * - `aria-label` on every interactive element
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * const [page, setPage] = useState(1);
39
+ * const [pageSize, setPageSize] = useState(20);
40
+ *
41
+ * <Pagination
42
+ * page={page}
43
+ * pageSize={pageSize}
44
+ * totalCount={1437}
45
+ * onPageChange={setPage}
46
+ * onPageSizeChange={setPageSize}
47
+ * />
48
+ * ```
49
+ */
50
+ export function Pagination(props: PaginationProps) {
51
+ const {
52
+ page,
53
+ pageSize,
54
+ totalCount,
55
+ onPageChange,
56
+ onPageSizeChange,
57
+ pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
58
+ siblingCount = 1,
59
+ boundaryCount = 1,
60
+ showFirstLast = true,
61
+ showJumpInput = true,
62
+ disabled = false,
63
+ labels: labelsProp,
64
+ variant = 'default',
65
+ size = 'md',
66
+ sx,
67
+ slotProps,
68
+ } = props;
69
+
70
+ const labels = { ...DEFAULT_LABELS, ...labelsProp };
71
+ const v = paginationVariants({ variant, size });
72
+
73
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
74
+ const safePage = Math.min(Math.max(1, page), totalPages);
75
+
76
+ const pages = computePageRange(safePage, totalPages, siblingCount, boundaryCount);
77
+
78
+ // Summary range
79
+ const firstItem = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
80
+ const lastItem = Math.min(safePage * pageSize, totalCount);
81
+
82
+ // Jump input local state (commit on Enter or blur)
83
+ const [jumpDraft, setJumpDraft] = useState('');
84
+
85
+ const goTo = useCallback(
86
+ (nextPage: number) => {
87
+ if (disabled) return;
88
+ const clamped = Math.min(Math.max(1, nextPage), totalPages);
89
+ if (clamped !== safePage) onPageChange(clamped);
90
+ },
91
+ [disabled, totalPages, safePage, onPageChange],
92
+ );
93
+
94
+ const commitJump = useCallback(() => {
95
+ const parsed = Number(jumpDraft);
96
+ if (!Number.isFinite(parsed) || parsed < 1) {
97
+ setJumpDraft('');
98
+ return;
99
+ }
100
+ goTo(Math.floor(parsed));
101
+ setJumpDraft('');
102
+ }, [jumpDraft, goTo]);
103
+
104
+ const onJumpKeyDown = useCallback(
105
+ (e: KeyboardEvent<HTMLInputElement>) => {
106
+ if (e.key === 'Enter') {
107
+ e.preventDefault();
108
+ commitJump();
109
+ }
110
+ },
111
+ [commitJump],
112
+ );
113
+
114
+ const handlePageSize = useCallback(
115
+ (e: ChangeEvent<HTMLSelectElement>) => {
116
+ if (!onPageSizeChange) return;
117
+ const next = Number(e.target.value);
118
+ if (Number.isFinite(next) && next > 0) onPageSizeChange(next);
119
+ },
120
+ [onPageSizeChange],
121
+ );
122
+
123
+ const isMinimal = variant === 'minimal';
124
+
125
+ return (
126
+ <nav
127
+ aria-label="Pagination"
128
+ className={cn(v.root(), sx, slotProps?.root?.className)}
129
+ >
130
+ {/* Summary — only in `default` variant. `compact` skips it entirely.
131
+ `minimal` renders a different "Page X of Y" summary below. */}
132
+ {variant === 'default' && (
133
+ <span className={cn(v.summary(), slotProps?.summary?.className)}>
134
+ {labels.showing} {firstItem}-{lastItem} {labels.of} {totalCount}
135
+ </span>
136
+ )}
137
+
138
+ {/* Minimal-mode summary (text-only "Page X of Y") */}
139
+ {isMinimal && (
140
+ <span className={cn(v.summary(), 'text-sm', slotProps?.summary?.className)}>
141
+ {labels.page} {safePage} {labels.of} {totalPages}
142
+ </span>
143
+ )}
144
+
145
+ {/* Nav buttons + page list */}
146
+ <ul className={cn(v.list(), slotProps?.list?.className)}>
147
+ {showFirstLast && !isMinimal && (
148
+ <li>
149
+ <button
150
+ type="button"
151
+ aria-label={labels.first}
152
+ disabled={disabled || safePage === 1}
153
+ onClick={() => goTo(1)}
154
+ className={cn(v.navButton(), slotProps?.navButton?.className)}
155
+ >
156
+ «
157
+ </button>
158
+ </li>
159
+ )}
160
+ <li>
161
+ <button
162
+ type="button"
163
+ aria-label={labels.prev}
164
+ disabled={disabled || safePage === 1}
165
+ onClick={() => goTo(safePage - 1)}
166
+ className={cn(v.navButton(), slotProps?.navButton?.className)}
167
+ >
168
+ ‹
169
+ </button>
170
+ </li>
171
+ {/* Page-number buttons hidden in minimal mode */}
172
+ {!isMinimal &&
173
+ pages.map((entry, idx) =>
174
+ entry === 'ellipsis' ? (
175
+ <li key={`e-${idx}`}>
176
+ <span
177
+ aria-hidden="true"
178
+ className={cn(v.ellipsis(), slotProps?.ellipsis?.className)}
179
+ >
180
+ …
181
+ </span>
182
+ </li>
183
+ ) : (
184
+ <li key={entry}>
185
+ <button
186
+ type="button"
187
+ aria-label={`${labels.page} ${entry}`}
188
+ aria-current={entry === safePage ? 'page' : undefined}
189
+ disabled={disabled}
190
+ onClick={() => goTo(entry)}
191
+ className={cn(
192
+ v.pageButton(),
193
+ entry === safePage && v.activeButton(),
194
+ slotProps?.pageButton?.className,
195
+ entry === safePage && slotProps?.activeButton?.className,
196
+ )}
197
+ >
198
+ {entry}
199
+ </button>
200
+ </li>
201
+ ),
202
+ )}
203
+ <li>
204
+ <button
205
+ type="button"
206
+ aria-label={labels.next}
207
+ disabled={disabled || safePage === totalPages}
208
+ onClick={() => goTo(safePage + 1)}
209
+ className={cn(v.navButton(), slotProps?.navButton?.className)}
210
+ >
211
+ ›
212
+ </button>
213
+ </li>
214
+ {showFirstLast && !isMinimal && (
215
+ <li>
216
+ <button
217
+ type="button"
218
+ aria-label={labels.last}
219
+ disabled={disabled || safePage === totalPages}
220
+ onClick={() => goTo(totalPages)}
221
+ className={cn(v.navButton(), slotProps?.navButton?.className)}
222
+ >
223
+ »
224
+ </button>
225
+ </li>
226
+ )}
227
+ </ul>
228
+
229
+ {/* Page-size selector + jump input — only in default variant */}
230
+ {variant === 'default' && onPageSizeChange && (
231
+ <label
232
+ className={cn(v.pageSizeSelector(), slotProps?.pageSizeSelector?.className)}
233
+ >
234
+ <span className="sr-only">{labels.perPage}</span>
235
+ <select
236
+ value={pageSize}
237
+ onChange={handlePageSize}
238
+ disabled={disabled}
239
+ aria-label={labels.perPage}
240
+ className={cn(
241
+ 'rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1',
242
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
243
+ )}
244
+ >
245
+ {pageSizeOptions.map((opt) => (
246
+ <option key={opt} value={opt}>
247
+ {opt}
248
+ </option>
249
+ ))}
250
+ </select>
251
+ <span>{labels.perPage}</span>
252
+ </label>
253
+ )}
254
+
255
+ {variant === 'default' && showJumpInput && (
256
+ <label className="inline-flex items-center gap-2">
257
+ <span className="sr-only">{labels.goToPage}</span>
258
+ <input
259
+ type="number"
260
+ min={1}
261
+ max={totalPages}
262
+ value={jumpDraft}
263
+ placeholder={labels.goToPage}
264
+ disabled={disabled}
265
+ onChange={(e) => setJumpDraft(e.target.value)}
266
+ onKeyDown={onJumpKeyDown}
267
+ onBlur={commitJump}
268
+ aria-label={labels.goToPage}
269
+ className={cn(v.jumpInput(), slotProps?.jumpInput?.className)}
270
+ />
271
+ </label>
272
+ )}
273
+ </nav>
274
+ );
275
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Compute the array of page indices (or `'ellipsis'` sentinels) to
3
+ * render for a `<Pagination>`.
4
+ *
5
+ * Algorithm (standard "siblings + boundaries + ellipsis fill"):
6
+ * 1. Always show `boundaryCount` pages at the start and `boundaryCount`
7
+ * at the end.
8
+ * 2. Show `siblingCount` pages on each side of the current page.
9
+ * 3. Fill gaps wider than 1 with `'ellipsis'`.
10
+ * 4. If a gap is exactly 1 page wide, include that page directly
11
+ * (no ellipsis for a single hidden number).
12
+ *
13
+ * Output is 1-indexed page numbers OR the literal string `'ellipsis'`.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * range(1, 10, 1, 1) // [1, 'ellipsis', 5, 'ellipsis', 10] when current=5
18
+ * range(1, 5, 1, 1) // [1, 2, 3, 4, 5]
19
+ * range(1, 100, 1, 1) // edges varying with current
20
+ * ```
21
+ */
22
+ export function computePageRange(
23
+ currentPage: number,
24
+ totalPages: number,
25
+ siblingCount: number,
26
+ boundaryCount: number,
27
+ ): (number | 'ellipsis')[] {
28
+ if (totalPages <= 0) return [];
29
+ if (totalPages === 1) return [1];
30
+
31
+ // Budget: if `totalPages` fits within boundaries + siblings + current
32
+ // (plus the 2 hypothetical ellipsis slots), don't bother with ellipses
33
+ // at all — list every page. Avoids the "[1, 2, …, 5]" footgun where
34
+ // the user expected to see 3 and 4 explicitly.
35
+ const minPagesForEllipsis = 2 * boundaryCount + 2 * siblingCount + 3;
36
+ if (totalPages <= minPagesForEllipsis) {
37
+ return range(1, totalPages);
38
+ }
39
+
40
+ const startBoundary = range(1, Math.min(boundaryCount, totalPages));
41
+ const endBoundary = range(
42
+ Math.max(totalPages - boundaryCount + 1, boundaryCount + 1),
43
+ totalPages,
44
+ );
45
+
46
+ const siblingsStart = Math.max(currentPage - siblingCount, boundaryCount + 2);
47
+ const siblingsEnd = Math.min(
48
+ currentPage + siblingCount,
49
+ totalPages - boundaryCount - 1,
50
+ );
51
+ const siblings = range(siblingsStart, siblingsEnd);
52
+
53
+ const showStartEllipsis = siblingsStart > boundaryCount + 2;
54
+ const showEndEllipsis = siblingsEnd < totalPages - boundaryCount - 1;
55
+
56
+ const out: (number | 'ellipsis')[] = [...startBoundary];
57
+
58
+ if (showStartEllipsis) {
59
+ out.push('ellipsis');
60
+ } else if (siblingsStart === boundaryCount + 2) {
61
+ // Single hidden page → fill it directly instead of an ellipsis.
62
+ out.push(boundaryCount + 1);
63
+ }
64
+
65
+ out.push(...siblings);
66
+
67
+ if (showEndEllipsis) {
68
+ out.push('ellipsis');
69
+ } else if (siblingsEnd === totalPages - boundaryCount - 1) {
70
+ out.push(totalPages - boundaryCount);
71
+ }
72
+
73
+ out.push(...endBoundary);
74
+
75
+ // Dedupe while preserving order (overlapping ranges produce duplicates
76
+ // when totalPages is small). Two distinct rules:
77
+ // 1. Number entries: keep only the first occurrence.
78
+ // 2. Ellipsis entries: collapse adjacent ellipses to one.
79
+ const seen = new Set<number>();
80
+ const deduped: (number | 'ellipsis')[] = [];
81
+ let prevWasEllipsis = false;
82
+ for (const entry of out) {
83
+ if (entry === 'ellipsis') {
84
+ if (!prevWasEllipsis) {
85
+ deduped.push('ellipsis');
86
+ prevWasEllipsis = true;
87
+ }
88
+ continue;
89
+ }
90
+ if (!seen.has(entry)) {
91
+ seen.add(entry);
92
+ deduped.push(entry);
93
+ prevWasEllipsis = false;
94
+ }
95
+ }
96
+ return deduped;
97
+ }
98
+
99
+ function range(start: number, end: number): number[] {
100
+ if (end < start) return [];
101
+ return Array.from({ length: end - start + 1 }, (_, i) => start + i);
102
+ }