@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.
- package/CHANGELOG.md +123 -0
- package/dist/index.esm.js +564 -2
- package/dist/src/components/Pagination/Pagination.d.ts +32 -0
- package/dist/src/components/Pagination/Pagination.d.ts.map +1 -0
- package/dist/src/components/Pagination/pagination.helpers.d.ts +23 -0
- package/dist/src/components/Pagination/pagination.helpers.d.ts.map +1 -0
- package/dist/src/components/Pagination/pagination.types.d.ts +120 -0
- package/dist/src/components/Pagination/pagination.types.d.ts.map +1 -0
- package/dist/src/components/Pagination/pagination.variants.d.ts +150 -0
- package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -0
- package/dist/src/components/Skeleton/Skeleton.d.ts +23 -0
- package/dist/src/components/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/src/components/Skeleton/skeleton.types.d.ts +56 -0
- package/dist/src/components/Skeleton/skeleton.types.d.ts.map +1 -0
- package/dist/src/components/Skeleton/skeleton.variants.d.ts +110 -0
- package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -0
- package/dist/src/index.d.ts +7 -1
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Pagination/Pagination.test.tsx +314 -0
- package/src/components/Pagination/Pagination.tsx +275 -0
- package/src/components/Pagination/pagination.helpers.ts +102 -0
- package/src/components/Pagination/pagination.types.ts +117 -0
- package/src/components/Pagination/pagination.variants.ts +111 -0
- package/src/components/Skeleton/Skeleton.test.tsx +127 -0
- package/src/components/Skeleton/Skeleton.tsx +49 -0
- package/src/components/Skeleton/skeleton.types.ts +59 -0
- package/src/components/Skeleton/skeleton.variants.ts +86 -0
- 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
|
+
}
|