@dashforge/tw 0.3.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 +246 -0
- package/PARITY.md +483 -0
- package/PERFORMANCE.md +169 -0
- package/dist/index.esm.js +1055 -2
- package/dist/src/components/Accordion/Accordion.d.ts +21 -0
- package/dist/src/components/Accordion/Accordion.d.ts.map +1 -0
- package/dist/src/components/Accordion/accordion.types.d.ts +74 -0
- package/dist/src/components/Accordion/accordion.types.d.ts.map +1 -0
- package/dist/src/components/Accordion/accordion.variants.d.ts +58 -0
- package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -0
- package/dist/src/components/Dialog/Dialog.d.ts +17 -0
- package/dist/src/components/Dialog/Dialog.d.ts.map +1 -0
- package/dist/src/components/Dialog/dialog.types.d.ts +75 -0
- package/dist/src/components/Dialog/dialog.types.d.ts.map +1 -0
- package/dist/src/components/Dialog/dialog.variants.d.ts +80 -0
- package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -0
- 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/Popover/Popover.d.ts +15 -0
- package/dist/src/components/Popover/Popover.d.ts.map +1 -0
- package/dist/src/components/Popover/popover.types.d.ts +45 -0
- package/dist/src/components/Popover/popover.types.d.ts.map +1 -0
- package/dist/src/components/Popover/popover.variants.d.ts +34 -0
- package/dist/src/components/Popover/popover.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/components/Tabs/Tabs.d.ts +15 -0
- package/dist/src/components/Tabs/Tabs.d.ts.map +1 -0
- package/dist/src/components/Tabs/tabs.types.d.ts +55 -0
- package/dist/src/components/Tabs/tabs.types.d.ts.map +1 -0
- package/dist/src/components/Tabs/tabs.variants.d.ts +82 -0
- package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -0
- package/dist/src/components/Tooltip/Tooltip.d.ts +29 -0
- package/dist/src/components/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/src/components/Tooltip/tooltip.types.d.ts +47 -0
- package/dist/src/components/Tooltip/tooltip.types.d.ts.map +1 -0
- package/dist/src/components/Tooltip/tooltip.variants.d.ts +34 -0
- package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -0
- package/dist/src/index.d.ts +22 -1
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +8 -3
- package/src/components/Accordion/Accordion.test.tsx +95 -0
- package/src/components/Accordion/Accordion.tsx +97 -0
- package/src/components/Accordion/accordion.types.ts +66 -0
- package/src/components/Accordion/accordion.variants.ts +30 -0
- package/src/components/Dialog/Dialog.test.tsx +96 -0
- package/src/components/Dialog/Dialog.tsx +93 -0
- package/src/components/Dialog/dialog.types.ts +62 -0
- package/src/components/Dialog/dialog.variants.ts +62 -0
- 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/Popover/Popover.test.tsx +72 -0
- package/src/components/Popover/Popover.tsx +55 -0
- package/src/components/Popover/popover.types.ts +42 -0
- package/src/components/Popover/popover.variants.ts +18 -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/components/Tabs/Tabs.test.tsx +74 -0
- package/src/components/Tabs/Tabs.tsx +66 -0
- package/src/components/Tabs/tabs.types.ts +49 -0
- package/src/components/Tabs/tabs.variants.ts +58 -0
- package/src/components/Tooltip/Tooltip.test.tsx +73 -0
- package/src/components/Tooltip/Tooltip.tsx +70 -0
- package/src/components/Tooltip/tooltip.types.ts +44 -0
- package/src/components/Tooltip/tooltip.variants.ts +18 -0
- package/src/index.ts +57 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { PaginationVariants } from './pagination.variants.js';
|
|
2
|
+
|
|
3
|
+
export interface PaginationSlotProps {
|
|
4
|
+
root?: { className?: string };
|
|
5
|
+
summary?: { className?: string };
|
|
6
|
+
list?: { className?: string };
|
|
7
|
+
pageButton?: { className?: string };
|
|
8
|
+
activeButton?: { className?: string };
|
|
9
|
+
navButton?: { className?: string };
|
|
10
|
+
ellipsis?: { className?: string };
|
|
11
|
+
pageSizeSelector?: { className?: string };
|
|
12
|
+
jumpInput?: { className?: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* I18n strings for `<Pagination>`. Default = English. Override the
|
|
17
|
+
* subset you need to translate; the rest fall back to defaults.
|
|
18
|
+
*/
|
|
19
|
+
export interface PaginationLabels {
|
|
20
|
+
first?: string;
|
|
21
|
+
prev?: string;
|
|
22
|
+
next?: string;
|
|
23
|
+
last?: string;
|
|
24
|
+
page?: string;
|
|
25
|
+
of?: string;
|
|
26
|
+
showing?: string;
|
|
27
|
+
perPage?: string;
|
|
28
|
+
goToPage?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Props for `<Pagination>`.
|
|
33
|
+
*
|
|
34
|
+
* Controlled component: the consumer owns `page` + `pageSize`. The
|
|
35
|
+
* component emits `onPageChange` / `onPageSizeChange` and recomputes
|
|
36
|
+
* the visible page range from `totalCount`.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const [page, setPage] = useState(1);
|
|
41
|
+
* const [pageSize, setPageSize] = useState(20);
|
|
42
|
+
*
|
|
43
|
+
* <Pagination
|
|
44
|
+
* page={page}
|
|
45
|
+
* pageSize={pageSize}
|
|
46
|
+
* totalCount={1437}
|
|
47
|
+
* onPageChange={setPage}
|
|
48
|
+
* onPageSizeChange={setPageSize}
|
|
49
|
+
* pageSizeOptions={[10, 20, 50, 100]}
|
|
50
|
+
* />
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export interface PaginationProps extends Pick<PaginationVariants, 'variant' | 'size'> {
|
|
54
|
+
/** Current page, **1-indexed**. */
|
|
55
|
+
page: number;
|
|
56
|
+
|
|
57
|
+
/** Items per page. */
|
|
58
|
+
pageSize: number;
|
|
59
|
+
|
|
60
|
+
/** Total items in the underlying data set (NOT total pages — we compute that). */
|
|
61
|
+
totalCount: number;
|
|
62
|
+
|
|
63
|
+
/** Fired when the user picks a different page. */
|
|
64
|
+
onPageChange: (page: number) => void;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fired when the user picks a different page size. Omit (or pass
|
|
68
|
+
* undefined) to hide the page-size selector.
|
|
69
|
+
*/
|
|
70
|
+
onPageSizeChange?: (pageSize: number) => void;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Options shown in the page-size selector.
|
|
74
|
+
* @default [10, 20, 50, 100]
|
|
75
|
+
*/
|
|
76
|
+
pageSizeOptions?: number[];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How many page buttons to show on each side of the current page.
|
|
80
|
+
* @default 1
|
|
81
|
+
*/
|
|
82
|
+
siblingCount?: number;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How many page buttons to always show at the start/end of the
|
|
86
|
+
* sequence (acts as "boundary"). @default 1
|
|
87
|
+
*/
|
|
88
|
+
boundaryCount?: number;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Show first/last buttons (`«` / `»`). @default true
|
|
92
|
+
*/
|
|
93
|
+
showFirstLast?: boolean;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Show the direct page-jump input (variant=`default` only).
|
|
97
|
+
* @default true
|
|
98
|
+
*/
|
|
99
|
+
showJumpInput?: boolean;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Disable all interactive elements (kept rendered but inert).
|
|
103
|
+
*/
|
|
104
|
+
disabled?: boolean;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* I18n strings. Defaults are English; pass the subset you need
|
|
108
|
+
* to translate.
|
|
109
|
+
*/
|
|
110
|
+
labels?: PaginationLabels;
|
|
111
|
+
|
|
112
|
+
/** Root-level Tailwind override. */
|
|
113
|
+
sx?: string;
|
|
114
|
+
|
|
115
|
+
/** Per-slot overrides. */
|
|
116
|
+
slotProps?: PaginationSlotProps;
|
|
117
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<Pagination>` variant recipe.
|
|
5
|
+
*
|
|
6
|
+
* Slots:
|
|
7
|
+
* - `root` — outer `<nav>` container
|
|
8
|
+
* - `summary` — "Showing X-Y of Z" text
|
|
9
|
+
* - `list` — `<ul>` of page buttons
|
|
10
|
+
* - `pageButton` — individual page number button
|
|
11
|
+
* - `activeButton` — modifier applied to the current page
|
|
12
|
+
* - `navButton` — first/prev/next/last buttons
|
|
13
|
+
* - `ellipsis` — `…` rendered when range gap
|
|
14
|
+
* - `pageSizeSelector` — native `<select>` for page size
|
|
15
|
+
* - `jumpInput` — direct page jump `<input>`
|
|
16
|
+
*
|
|
17
|
+
* Variant axes:
|
|
18
|
+
* - `variant` — `default` (full kit) | `compact` (number buttons +
|
|
19
|
+
* prev/next only) | `minimal` ("X / Y pages" + prev/next only).
|
|
20
|
+
* - `size` — `sm` | `md` (default) | `lg`. Drives padding + text
|
|
21
|
+
* size on the buttons.
|
|
22
|
+
*/
|
|
23
|
+
export const paginationVariants = tv({
|
|
24
|
+
slots: {
|
|
25
|
+
root: 'flex flex-wrap items-center gap-3',
|
|
26
|
+
summary: 'text-neutral-600 dark:text-neutral-400',
|
|
27
|
+
list: 'inline-flex items-center gap-1',
|
|
28
|
+
pageButton: [
|
|
29
|
+
'inline-flex items-center justify-center rounded-md',
|
|
30
|
+
'border border-neutral-300 dark:border-neutral-700',
|
|
31
|
+
'bg-white dark:bg-neutral-900',
|
|
32
|
+
'text-neutral-700 dark:text-neutral-300',
|
|
33
|
+
'hover:bg-neutral-50 dark:hover:bg-neutral-800',
|
|
34
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
35
|
+
'transition-colors motion-reduce:transition-none',
|
|
36
|
+
'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none',
|
|
37
|
+
],
|
|
38
|
+
activeButton: [
|
|
39
|
+
'bg-primary-500 text-white border-primary-500',
|
|
40
|
+
'hover:bg-primary-600 dark:hover:bg-primary-400',
|
|
41
|
+
],
|
|
42
|
+
navButton: [
|
|
43
|
+
'inline-flex items-center justify-center rounded-md',
|
|
44
|
+
'border border-neutral-300 dark:border-neutral-700',
|
|
45
|
+
'bg-white dark:bg-neutral-900',
|
|
46
|
+
'text-neutral-700 dark:text-neutral-300',
|
|
47
|
+
'hover:bg-neutral-50 dark:hover:bg-neutral-800',
|
|
48
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
49
|
+
'transition-colors motion-reduce:transition-none',
|
|
50
|
+
'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none',
|
|
51
|
+
],
|
|
52
|
+
ellipsis: 'inline-flex items-center justify-center text-neutral-500 px-1',
|
|
53
|
+
pageSizeSelector: [
|
|
54
|
+
'inline-flex items-center gap-2',
|
|
55
|
+
'text-neutral-700 dark:text-neutral-300',
|
|
56
|
+
],
|
|
57
|
+
jumpInput: [
|
|
58
|
+
'rounded-md border border-neutral-300 dark:border-neutral-700',
|
|
59
|
+
'bg-white dark:bg-neutral-900',
|
|
60
|
+
'text-neutral-900 dark:text-neutral-50',
|
|
61
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
62
|
+
'transition-colors motion-reduce:transition-none',
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
variants: {
|
|
66
|
+
// Note: variant-driven visibility is enforced via conditional JSX
|
|
67
|
+
// rendering (in `Pagination.tsx`), NOT via `hidden` Tailwind class.
|
|
68
|
+
// Reason: `display: none` keeps the elements in the DOM (and in the
|
|
69
|
+
// a11y tree depending on how it's applied), which leaks into
|
|
70
|
+
// testing-library `queryByText` and feels semantically wrong for
|
|
71
|
+
// "this UI piece doesn't exist in this variant". So compact and
|
|
72
|
+
// minimal slots stay empty here.
|
|
73
|
+
variant: {
|
|
74
|
+
default: {},
|
|
75
|
+
compact: {},
|
|
76
|
+
minimal: {},
|
|
77
|
+
},
|
|
78
|
+
size: {
|
|
79
|
+
sm: {
|
|
80
|
+
summary: 'text-xs',
|
|
81
|
+
pageButton: 'h-7 min-w-7 px-2 text-xs',
|
|
82
|
+
navButton: 'h-7 min-w-7 px-2 text-xs',
|
|
83
|
+
ellipsis: 'h-7 min-w-5 text-xs',
|
|
84
|
+
pageSizeSelector: 'text-xs',
|
|
85
|
+
jumpInput: 'h-7 px-2 text-xs w-14',
|
|
86
|
+
},
|
|
87
|
+
md: {
|
|
88
|
+
summary: 'text-sm',
|
|
89
|
+
pageButton: 'h-9 min-w-9 px-3 text-sm',
|
|
90
|
+
navButton: 'h-9 min-w-9 px-3 text-sm',
|
|
91
|
+
ellipsis: 'h-9 min-w-6 text-sm',
|
|
92
|
+
pageSizeSelector: 'text-sm',
|
|
93
|
+
jumpInput: 'h-9 px-2 text-sm w-16',
|
|
94
|
+
},
|
|
95
|
+
lg: {
|
|
96
|
+
summary: 'text-base',
|
|
97
|
+
pageButton: 'h-10 min-w-10 px-4 text-base',
|
|
98
|
+
navButton: 'h-10 min-w-10 px-4 text-base',
|
|
99
|
+
ellipsis: 'h-10 min-w-7 text-base',
|
|
100
|
+
pageSizeSelector: 'text-base',
|
|
101
|
+
jumpInput: 'h-10 px-3 text-base w-20',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
defaultVariants: {
|
|
106
|
+
variant: 'default',
|
|
107
|
+
size: 'md',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export type PaginationVariants = VariantProps<typeof paginationVariants>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
import { describe, it, expect, afterEach, beforeAll } from 'vitest';
|
|
4
|
+
import { render, screen, cleanup } from '@testing-library/react';
|
|
5
|
+
import { Popover } from './Popover.js';
|
|
6
|
+
|
|
7
|
+
void React;
|
|
8
|
+
afterEach(() => cleanup());
|
|
9
|
+
|
|
10
|
+
// jsdom doesn't implement ResizeObserver — Radix Floating UI requires it.
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
13
|
+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
14
|
+
observe() {}
|
|
15
|
+
unobserve() {}
|
|
16
|
+
disconnect() {}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('Popover — rendering', () => {
|
|
22
|
+
it('renders the trigger always', () => {
|
|
23
|
+
render(
|
|
24
|
+
<Popover content={<p>Panel</p>}>
|
|
25
|
+
<button>Open panel</button>
|
|
26
|
+
</Popover>
|
|
27
|
+
);
|
|
28
|
+
expect(screen.queryByRole('button', { name: 'Open panel' })).not.toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders content when controlled open=true', () => {
|
|
32
|
+
render(
|
|
33
|
+
<Popover content={<p>Panel content</p>} open onOpenChange={() => {}}>
|
|
34
|
+
<button>Open panel</button>
|
|
35
|
+
</Popover>
|
|
36
|
+
);
|
|
37
|
+
expect(screen.queryByText('Panel content')).not.toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does not render content when open=false', () => {
|
|
41
|
+
render(
|
|
42
|
+
<Popover content={<p>Panel content</p>} open={false} onOpenChange={() => {}}>
|
|
43
|
+
<button>Open panel</button>
|
|
44
|
+
</Popover>
|
|
45
|
+
);
|
|
46
|
+
expect(screen.queryByText('Panel content')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Popover — placement', () => {
|
|
51
|
+
it.each(['top', 'right', 'bottom', 'left'] as const)(
|
|
52
|
+
'renders side=%s without crashing',
|
|
53
|
+
(side) => {
|
|
54
|
+
render(
|
|
55
|
+
<Popover content={<p>Panel</p>} open side={side} onOpenChange={() => {}}>
|
|
56
|
+
<button>T</button>
|
|
57
|
+
</Popover>
|
|
58
|
+
);
|
|
59
|
+
expect(screen.queryByText('Panel')).not.toBeNull();
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
it('applies width style', () => {
|
|
64
|
+
render(
|
|
65
|
+
<Popover content={<p>Panel</p>} open width="300px" onOpenChange={() => {}}>
|
|
66
|
+
<button>T</button>
|
|
67
|
+
</Popover>
|
|
68
|
+
);
|
|
69
|
+
const content = screen.getByText('Panel').closest('[role="dialog"]') as HTMLElement | null;
|
|
70
|
+
expect(content?.style.width).toBe('300px');
|
|
71
|
+
});
|
|
72
|
+
});
|