@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/PARITY.md +483 -0
  3. package/PERFORMANCE.md +169 -0
  4. package/dist/index.esm.js +1055 -2
  5. package/dist/src/components/Accordion/Accordion.d.ts +21 -0
  6. package/dist/src/components/Accordion/Accordion.d.ts.map +1 -0
  7. package/dist/src/components/Accordion/accordion.types.d.ts +74 -0
  8. package/dist/src/components/Accordion/accordion.types.d.ts.map +1 -0
  9. package/dist/src/components/Accordion/accordion.variants.d.ts +58 -0
  10. package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -0
  11. package/dist/src/components/Dialog/Dialog.d.ts +17 -0
  12. package/dist/src/components/Dialog/Dialog.d.ts.map +1 -0
  13. package/dist/src/components/Dialog/dialog.types.d.ts +75 -0
  14. package/dist/src/components/Dialog/dialog.types.d.ts.map +1 -0
  15. package/dist/src/components/Dialog/dialog.variants.d.ts +80 -0
  16. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -0
  17. package/dist/src/components/Pagination/Pagination.d.ts +32 -0
  18. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -0
  19. package/dist/src/components/Pagination/pagination.helpers.d.ts +23 -0
  20. package/dist/src/components/Pagination/pagination.helpers.d.ts.map +1 -0
  21. package/dist/src/components/Pagination/pagination.types.d.ts +120 -0
  22. package/dist/src/components/Pagination/pagination.types.d.ts.map +1 -0
  23. package/dist/src/components/Pagination/pagination.variants.d.ts +150 -0
  24. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -0
  25. package/dist/src/components/Popover/Popover.d.ts +15 -0
  26. package/dist/src/components/Popover/Popover.d.ts.map +1 -0
  27. package/dist/src/components/Popover/popover.types.d.ts +45 -0
  28. package/dist/src/components/Popover/popover.types.d.ts.map +1 -0
  29. package/dist/src/components/Popover/popover.variants.d.ts +34 -0
  30. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -0
  31. package/dist/src/components/Skeleton/Skeleton.d.ts +23 -0
  32. package/dist/src/components/Skeleton/Skeleton.d.ts.map +1 -0
  33. package/dist/src/components/Skeleton/skeleton.types.d.ts +56 -0
  34. package/dist/src/components/Skeleton/skeleton.types.d.ts.map +1 -0
  35. package/dist/src/components/Skeleton/skeleton.variants.d.ts +110 -0
  36. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -0
  37. package/dist/src/components/Tabs/Tabs.d.ts +15 -0
  38. package/dist/src/components/Tabs/Tabs.d.ts.map +1 -0
  39. package/dist/src/components/Tabs/tabs.types.d.ts +55 -0
  40. package/dist/src/components/Tabs/tabs.types.d.ts.map +1 -0
  41. package/dist/src/components/Tabs/tabs.variants.d.ts +82 -0
  42. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -0
  43. package/dist/src/components/Tooltip/Tooltip.d.ts +29 -0
  44. package/dist/src/components/Tooltip/Tooltip.d.ts.map +1 -0
  45. package/dist/src/components/Tooltip/tooltip.types.d.ts +47 -0
  46. package/dist/src/components/Tooltip/tooltip.types.d.ts.map +1 -0
  47. package/dist/src/components/Tooltip/tooltip.variants.d.ts +34 -0
  48. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -0
  49. package/dist/src/index.d.ts +22 -1
  50. package/dist/src/index.d.ts.map +1 -1
  51. package/package.json +8 -3
  52. package/src/components/Accordion/Accordion.test.tsx +95 -0
  53. package/src/components/Accordion/Accordion.tsx +97 -0
  54. package/src/components/Accordion/accordion.types.ts +66 -0
  55. package/src/components/Accordion/accordion.variants.ts +30 -0
  56. package/src/components/Dialog/Dialog.test.tsx +96 -0
  57. package/src/components/Dialog/Dialog.tsx +93 -0
  58. package/src/components/Dialog/dialog.types.ts +62 -0
  59. package/src/components/Dialog/dialog.variants.ts +62 -0
  60. package/src/components/Pagination/Pagination.test.tsx +314 -0
  61. package/src/components/Pagination/Pagination.tsx +275 -0
  62. package/src/components/Pagination/pagination.helpers.ts +102 -0
  63. package/src/components/Pagination/pagination.types.ts +117 -0
  64. package/src/components/Pagination/pagination.variants.ts +111 -0
  65. package/src/components/Popover/Popover.test.tsx +72 -0
  66. package/src/components/Popover/Popover.tsx +55 -0
  67. package/src/components/Popover/popover.types.ts +42 -0
  68. package/src/components/Popover/popover.variants.ts +18 -0
  69. package/src/components/Skeleton/Skeleton.test.tsx +127 -0
  70. package/src/components/Skeleton/Skeleton.tsx +49 -0
  71. package/src/components/Skeleton/skeleton.types.ts +59 -0
  72. package/src/components/Skeleton/skeleton.variants.ts +86 -0
  73. package/src/components/Tabs/Tabs.test.tsx +74 -0
  74. package/src/components/Tabs/Tabs.tsx +66 -0
  75. package/src/components/Tabs/tabs.types.ts +49 -0
  76. package/src/components/Tabs/tabs.variants.ts +58 -0
  77. package/src/components/Tooltip/Tooltip.test.tsx +73 -0
  78. package/src/components/Tooltip/Tooltip.tsx +70 -0
  79. package/src/components/Tooltip/tooltip.types.ts +44 -0
  80. package/src/components/Tooltip/tooltip.variants.ts +18 -0
  81. 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
+ });