tekivex-ui 2.2.0 → 2.5.1

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 (129) hide show
  1. package/dist/TkxForm-BWK4LqY3.js +1461 -0
  2. package/dist/TkxForm-ljQjX7KD.cjs +15 -0
  3. package/dist/charts.cjs +1 -0
  4. package/dist/charts.js +480 -0
  5. package/dist/headless.cjs +1 -0
  6. package/dist/headless.js +258 -0
  7. package/dist/index-BINBzXuY.cjs +2 -0
  8. package/dist/index-eT_U4qB2.js +512 -0
  9. package/dist/index.cjs +63 -14
  10. package/dist/index.d.ts +33 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +11396 -6336
  13. package/dist/src/charts/TkxAreaChart.d.ts +29 -0
  14. package/dist/src/charts/TkxAreaChart.d.ts.map +1 -0
  15. package/dist/src/charts/TkxBarChart.d.ts +28 -0
  16. package/dist/src/charts/TkxBarChart.d.ts.map +1 -0
  17. package/dist/src/charts/TkxDonutChart.d.ts +23 -0
  18. package/dist/src/charts/TkxDonutChart.d.ts.map +1 -0
  19. package/dist/src/charts/TkxLineChart.d.ts +35 -0
  20. package/dist/src/charts/TkxLineChart.d.ts.map +1 -0
  21. package/dist/src/charts/TkxPieChart.d.ts +19 -0
  22. package/dist/src/charts/TkxPieChart.d.ts.map +1 -0
  23. package/dist/src/charts/TkxRadarChart.d.ts +20 -0
  24. package/dist/src/charts/TkxRadarChart.d.ts.map +1 -0
  25. package/dist/src/charts/TkxScatterChart.d.ts +28 -0
  26. package/dist/src/charts/TkxScatterChart.d.ts.map +1 -0
  27. package/dist/src/charts/index.d.ts +15 -0
  28. package/dist/src/charts/index.d.ts.map +1 -0
  29. package/dist/src/charts/shared.d.ts +33 -0
  30. package/dist/src/charts/shared.d.ts.map +1 -0
  31. package/dist/src/components/TkxAffix.d.ts +10 -0
  32. package/dist/src/components/TkxAffix.d.ts.map +1 -0
  33. package/dist/src/components/TkxAnchor.d.ts +14 -0
  34. package/dist/src/components/TkxAnchor.d.ts.map +1 -0
  35. package/dist/src/components/TkxCascader.d.ts +16 -0
  36. package/dist/src/components/TkxCascader.d.ts.map +1 -0
  37. package/dist/src/components/TkxDataGrid.d.ts +19 -1
  38. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  39. package/dist/src/components/TkxForm.d.ts +33 -3
  40. package/dist/src/components/TkxForm.d.ts.map +1 -1
  41. package/dist/src/components/TkxImage.d.ts.map +1 -1
  42. package/dist/src/components/TkxList.d.ts +24 -0
  43. package/dist/src/components/TkxList.d.ts.map +1 -0
  44. package/dist/src/components/TkxMentions.d.ts +15 -0
  45. package/dist/src/components/TkxMentions.d.ts.map +1 -0
  46. package/dist/src/components/TkxQRCode.d.ts +11 -0
  47. package/dist/src/components/TkxQRCode.d.ts.map +1 -0
  48. package/dist/src/components/TkxResult.d.ts +11 -0
  49. package/dist/src/components/TkxResult.d.ts.map +1 -0
  50. package/dist/src/components/TkxSegmented.d.ts +16 -0
  51. package/dist/src/components/TkxSegmented.d.ts.map +1 -0
  52. package/dist/src/components/TkxSelect.d.ts +5 -1
  53. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  54. package/dist/src/components/TkxTour.d.ts +15 -0
  55. package/dist/src/components/TkxTour.d.ts.map +1 -0
  56. package/dist/src/components/TkxTypography.d.ts +1 -1
  57. package/dist/src/components/TkxTypography.d.ts.map +1 -1
  58. package/dist/src/components/TkxWatermark.d.ts +12 -0
  59. package/dist/src/components/TkxWatermark.d.ts.map +1 -0
  60. package/dist/src/components/index.d.ts +10 -0
  61. package/dist/src/components/index.d.ts.map +1 -1
  62. package/dist/src/engine/tkx.d.ts +38 -0
  63. package/dist/src/engine/tkx.d.ts.map +1 -1
  64. package/dist/src/headless/index.d.ts +18 -0
  65. package/dist/src/headless/index.d.ts.map +1 -0
  66. package/dist/src/headless/useControllable.d.ts +20 -0
  67. package/dist/src/headless/useControllable.d.ts.map +1 -0
  68. package/dist/src/headless/useDebounce.d.ts +14 -0
  69. package/dist/src/headless/useDebounce.d.ts.map +1 -0
  70. package/dist/src/headless/useDisclosure.d.ts +20 -0
  71. package/dist/src/headless/useDisclosure.d.ts.map +1 -0
  72. package/dist/src/headless/useFormState.d.ts +44 -0
  73. package/dist/src/headless/useFormState.d.ts.map +1 -0
  74. package/dist/src/headless/useIntersectionObserver.d.ts +23 -0
  75. package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -0
  76. package/dist/src/headless/useListSelection.d.ts +29 -0
  77. package/dist/src/headless/useListSelection.d.ts.map +1 -0
  78. package/dist/src/headless/useLocalStorage.d.ts +9 -0
  79. package/dist/src/headless/useLocalStorage.d.ts.map +1 -0
  80. package/dist/src/headless/useMediaQuery.d.ts +21 -0
  81. package/dist/src/headless/useMediaQuery.d.ts.map +1 -0
  82. package/dist/src/headless/useRovingTabIndex.d.ts +33 -0
  83. package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -0
  84. package/dist/src/headless/useThrottle.d.ts +9 -0
  85. package/dist/src/headless/useThrottle.d.ts.map +1 -0
  86. package/dist/src/i18n/index.d.ts +47 -2
  87. package/dist/src/i18n/index.d.ts.map +1 -1
  88. package/dist/src/themes/index.d.ts.map +1 -1
  89. package/package.json +61 -7
  90. package/src/charts/TkxAreaChart.tsx +119 -0
  91. package/src/charts/TkxBarChart.tsx +115 -0
  92. package/src/charts/TkxDonutChart.tsx +145 -0
  93. package/src/charts/TkxLineChart.tsx +135 -0
  94. package/src/charts/TkxPieChart.tsx +82 -0
  95. package/src/charts/TkxRadarChart.tsx +90 -0
  96. package/src/charts/TkxScatterChart.tsx +102 -0
  97. package/src/charts/index.ts +20 -0
  98. package/src/charts/shared.ts +45 -0
  99. package/src/components/TkxAffix.tsx +138 -0
  100. package/src/components/TkxAnchor.tsx +193 -0
  101. package/src/components/TkxCascader.tsx +276 -0
  102. package/src/components/TkxDataGrid.tsx +557 -230
  103. package/src/components/TkxForm.tsx +67 -15
  104. package/src/components/TkxImage.tsx +1 -1
  105. package/src/components/TkxList.tsx +242 -0
  106. package/src/components/TkxMentions.tsx +210 -0
  107. package/src/components/TkxQRCode.tsx +174 -0
  108. package/src/components/TkxResult.tsx +128 -0
  109. package/src/components/TkxSegmented.tsx +169 -0
  110. package/src/components/TkxSelect.tsx +209 -6
  111. package/src/components/TkxTour.tsx +239 -0
  112. package/src/components/TkxTypography.tsx +4 -2
  113. package/src/components/TkxWatermark.tsx +143 -0
  114. package/src/components/index.ts +10 -0
  115. package/src/engine/tkx.ts +122 -3
  116. package/src/headless/index.ts +44 -0
  117. package/src/headless/useControllable.ts +40 -0
  118. package/src/headless/useDebounce.ts +35 -0
  119. package/src/headless/useDisclosure.ts +28 -0
  120. package/src/headless/useFormState.ts +111 -0
  121. package/src/headless/useIntersectionObserver.ts +51 -0
  122. package/src/headless/useListSelection.ts +101 -0
  123. package/src/headless/useLocalStorage.ts +46 -0
  124. package/src/headless/useMediaQuery.ts +37 -0
  125. package/src/headless/useRovingTabIndex.ts +84 -0
  126. package/src/headless/useThrottle.ts +46 -0
  127. package/src/i18n/I18nProvider.tsx +1 -1
  128. package/src/i18n/index.ts +678 -38
  129. package/src/themes/index.ts +31 -4
@@ -4,9 +4,11 @@ import {
4
4
  useRef,
5
5
  useMemo,
6
6
  useId,
7
+ useEffect,
7
8
  type ReactNode,
8
9
  type CSSProperties,
9
10
  type MouseEvent as ReactMouseEvent,
11
+ type ChangeEvent,
10
12
  } from 'react';
11
13
  import { useTheme } from '../themes';
12
14
  import { sanitizeString } from '../engine/security';
@@ -36,7 +38,9 @@ export interface TkxDataGridProps<T = any> {
36
38
  selectable?: boolean;
37
39
  selectedRows?: string[];
38
40
  onSelectionChange?: (ids: string[]) => void;
41
+ /** Enable column sorting. When true, clicks sort data client-side unless onSort is also provided. */
39
42
  sortable?: boolean;
43
+ /** External sort handler. When provided, raw data is NOT sorted internally — you must pass pre-sorted data. */
40
44
  onSort?: (key: string, direction: 'asc' | 'desc') => void;
41
45
  loading?: boolean;
42
46
  emptyMessage?: string;
@@ -46,6 +50,22 @@ export interface TkxDataGridProps<T = any> {
46
50
  compact?: boolean;
47
51
  maxHeight?: number | string;
48
52
  onRowClick?: (row: T) => void;
53
+ /** Enable virtual scrolling. Defaults to auto (enabled when data ≥ 50 rows and maxHeight is set). */
54
+ virtualScroll?: boolean;
55
+ /** Row height in pixels for virtual scrolling calculations. Default: 40 */
56
+ rowHeight?: number;
57
+ /** Show column filter inputs below the header row. */
58
+ showFilters?: boolean;
59
+ /** Controlled filter values keyed by column key. */
60
+ filterValues?: Record<string, string>;
61
+ /** Called when a column filter changes. */
62
+ onFilterChange?: (key: string, value: string) => void;
63
+ /** Rows per page for built-in pagination. Set to 0 to disable. Default: 0 (disabled). */
64
+ pageSize?: number;
65
+ /** Show a CSV export button in the toolbar. */
66
+ showExport?: boolean;
67
+ /** File name for CSV export (without .csv extension). Default: "export". */
68
+ exportFileName?: string;
49
69
  }
50
70
 
51
71
  // ── Sort icon ─────────────────────────────────────────────────────────────────
@@ -76,18 +96,9 @@ function SortIcon({ direction }: { direction: 'asc' | 'desc' | null }) {
76
96
  function CheckboxIcon({ checked, indeterminate }: { checked: boolean; indeterminate?: boolean }) {
77
97
  const theme = useTheme();
78
98
  return (
79
- <svg
80
- width="16"
81
- height="16"
82
- viewBox="0 0 16 16"
83
- aria-hidden="true"
84
- >
99
+ <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
85
100
  <rect
86
- x="1"
87
- y="1"
88
- width="14"
89
- height="14"
90
- rx="2"
101
+ x="1" y="1" width="14" height="14" rx="2"
91
102
  fill={checked || indeterminate ? theme.primary : 'transparent'}
92
103
  stroke={checked || indeterminate ? theme.primary : theme.border}
93
104
  strokeWidth="1.5"
@@ -96,7 +107,8 @@ function CheckboxIcon({ checked, indeterminate }: { checked: boolean; indetermin
96
107
  <line x1="4" y1="8" x2="12" y2="8" stroke={theme.bg} strokeWidth="2" />
97
108
  )}
98
109
  {checked && !indeterminate && (
99
- <path d="M4.5 8L7 10.5L11.5 5.5" fill="none" stroke={theme.bg} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
110
+ <path d="M4.5 8L7 10.5L11.5 5.5" fill="none" stroke={theme.bg} strokeWidth="1.8"
111
+ strokeLinecap="round" strokeLinejoin="round" />
100
112
  )}
101
113
  </svg>
102
114
  );
@@ -113,21 +125,33 @@ function getCellValue<T>(row: T, key: string): unknown {
113
125
  return (row as Record<string, unknown>)[key];
114
126
  }
115
127
 
128
+ function compareValues(a: unknown, b: unknown): number {
129
+ if (a == null && b == null) return 0;
130
+ if (a == null) return 1;
131
+ if (b == null) return -1;
132
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
133
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
134
+ }
135
+
136
+ function escapeCSV(value: unknown): string {
137
+ const str = String(value ?? '');
138
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
139
+ return `"${str.replace(/"/g, '""')}"`;
140
+ }
141
+ return str;
142
+ }
143
+
116
144
  // ── Loading skeleton ────────────────────────────────────────────────────────
117
145
 
118
146
  function LoadingOverlay({ colCount }: { colCount: number }) {
119
147
  const theme = useTheme();
120
- const rows = Array.from({ length: 5 }, (_, i) => i);
121
148
  return (
122
149
  <tbody>
123
- {rows.map((r) => (
150
+ {Array.from({ length: 5 }, (_, r) => (
124
151
  <tr key={r}>
125
152
  {Array.from({ length: colCount }, (_, c) => (
126
- <td
127
- key={c}
128
- className={tkx('px-3 py-2')}
129
- style={{ borderBottom: `1px solid ${theme.border}` }}
130
- >
153
+ <td key={c} className={tkx('px-3 py-2')}
154
+ style={{ borderBottom: `1px solid ${theme.border}` }}>
131
155
  <TkxSkeleton height={16} />
132
156
  </td>
133
157
  ))}
@@ -137,6 +161,172 @@ function LoadingOverlay({ colCount }: { colCount: number }) {
137
161
  );
138
162
  }
139
163
 
164
+ // ── Toolbar ──────────────────────────────────────────────────────────────────
165
+
166
+ function GridToolbar({
167
+ label,
168
+ showExport,
169
+ onExport,
170
+ totalRows,
171
+ selectedCount,
172
+ theme,
173
+ }: {
174
+ label: string;
175
+ showExport: boolean;
176
+ onExport: () => void;
177
+ totalRows: number;
178
+ selectedCount: number;
179
+ theme: ReturnType<typeof useTheme>;
180
+ }) {
181
+ if (!showExport && selectedCount === 0) return null;
182
+ return (
183
+ <div
184
+ style={{
185
+ display: 'flex',
186
+ alignItems: 'center',
187
+ justifyContent: 'space-between',
188
+ padding: '8px 12px',
189
+ borderBottom: `1px solid ${theme.border}`,
190
+ backgroundColor: theme.surface,
191
+ gap: 8,
192
+ }}
193
+ >
194
+ <span style={{ fontSize: 13, color: theme.textMuted }}>
195
+ {selectedCount > 0
196
+ ? `${selectedCount} of ${totalRows} row${totalRows !== 1 ? 's' : ''} selected`
197
+ : `${totalRows} row${totalRows !== 1 ? 's' : ''}`}
198
+ </span>
199
+ {showExport && (
200
+ <button
201
+ onClick={onExport}
202
+ aria-label={`Export ${label} as CSV`}
203
+ style={{
204
+ display: 'inline-flex',
205
+ alignItems: 'center',
206
+ gap: 6,
207
+ padding: '5px 12px',
208
+ fontSize: 13,
209
+ fontWeight: 500,
210
+ borderRadius: 6,
211
+ border: `1px solid ${theme.border}`,
212
+ backgroundColor: 'transparent',
213
+ color: theme.text,
214
+ cursor: 'pointer',
215
+ }}
216
+ >
217
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
218
+ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
219
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
220
+ <polyline points="7 10 12 15 17 10" />
221
+ <line x1="12" y1="15" x2="12" y2="3" />
222
+ </svg>
223
+ Export CSV
224
+ </button>
225
+ )}
226
+ </div>
227
+ );
228
+ }
229
+
230
+ // ── Pagination Bar ────────────────────────────────────────────────────────────
231
+
232
+ function PaginationBar({
233
+ page,
234
+ totalPages,
235
+ pageSize,
236
+ totalRows,
237
+ onPageChange,
238
+ theme,
239
+ }: {
240
+ page: number;
241
+ totalPages: number;
242
+ pageSize: number;
243
+ totalRows: number;
244
+ onPageChange: (p: number) => void;
245
+ theme: ReturnType<typeof useTheme>;
246
+ }) {
247
+ const start = (page - 1) * pageSize + 1;
248
+ const end = Math.min(page * pageSize, totalRows);
249
+ const btnBase: CSSProperties = {
250
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
251
+ width: 32, height: 32, borderRadius: 6, border: `1px solid ${theme.border}`,
252
+ backgroundColor: 'transparent', color: theme.text, cursor: 'pointer',
253
+ fontSize: 13, fontWeight: 500,
254
+ };
255
+ return (
256
+ <div
257
+ style={{
258
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
259
+ padding: '8px 12px', borderTop: `1px solid ${theme.border}`,
260
+ backgroundColor: theme.surface, gap: 8, flexWrap: 'wrap',
261
+ }}
262
+ role="navigation"
263
+ aria-label="Pagination"
264
+ >
265
+ <span style={{ fontSize: 13, color: theme.textMuted }}>
266
+ {totalRows === 0 ? '0 rows' : `${start}–${end} of ${totalRows}`}
267
+ </span>
268
+ <div style={{ display: 'flex', gap: 4 }}>
269
+ <button
270
+ onClick={() => onPageChange(1)}
271
+ disabled={page === 1}
272
+ aria-label="First page"
273
+ style={{ ...btnBase, opacity: page === 1 ? 0.4 : 1 }}
274
+ >
275
+ «
276
+ </button>
277
+ <button
278
+ onClick={() => onPageChange(page - 1)}
279
+ disabled={page === 1}
280
+ aria-label="Previous page"
281
+ style={{ ...btnBase, opacity: page === 1 ? 0.4 : 1 }}
282
+ >
283
+
284
+ </button>
285
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
286
+ let p = i + 1;
287
+ if (totalPages > 5) {
288
+ const half = Math.floor(5 / 2);
289
+ const start2 = Math.max(1, Math.min(page - half, totalPages - 4));
290
+ p = start2 + i;
291
+ }
292
+ return (
293
+ <button
294
+ key={p}
295
+ onClick={() => onPageChange(p)}
296
+ aria-label={`Page ${p}`}
297
+ aria-current={p === page ? 'page' : undefined}
298
+ style={{
299
+ ...btnBase,
300
+ backgroundColor: p === page ? theme.primary : 'transparent',
301
+ color: p === page ? theme.bg : theme.text,
302
+ borderColor: p === page ? theme.primary : theme.border,
303
+ }}
304
+ >
305
+ {p}
306
+ </button>
307
+ );
308
+ })}
309
+ <button
310
+ onClick={() => onPageChange(page + 1)}
311
+ disabled={page === totalPages}
312
+ aria-label="Next page"
313
+ style={{ ...btnBase, opacity: page === totalPages ? 0.4 : 1 }}
314
+ >
315
+
316
+ </button>
317
+ <button
318
+ onClick={() => onPageChange(totalPages)}
319
+ disabled={page === totalPages}
320
+ aria-label="Last page"
321
+ style={{ ...btnBase, opacity: page === totalPages ? 0.4 : 1 }}
322
+ >
323
+ »
324
+ </button>
325
+ </div>
326
+ </div>
327
+ );
328
+ }
329
+
140
330
  // ── Component ─────────────────────────────────────────────────────────────────
141
331
 
142
332
  export function TkxDataGrid<T = any>({
@@ -156,22 +346,26 @@ export function TkxDataGrid<T = any>({
156
346
  compact = false,
157
347
  maxHeight,
158
348
  onRowClick,
349
+ virtualScroll,
350
+ rowHeight = 40,
351
+ showFilters = false,
352
+ filterValues: externalFilterValues,
353
+ onFilterChange,
354
+ pageSize = 0,
355
+ showExport = false,
356
+ exportFileName = 'export',
159
357
  }: TkxDataGridProps<T>) {
160
358
  const theme = useTheme();
161
359
  const reduced = useReducedMotion();
162
360
  const gridId = useId();
163
361
 
164
- // ── Sort state ──────────────────────────────────────────────────────────
165
-
362
+ // ── Internal sort state ─────────────────────────────────────────────────
166
363
  const [sortKey, setSortKey] = useState<string | null>(null);
167
364
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
168
365
 
169
366
  const handleSort = useCallback(
170
367
  (key: string) => {
171
- let nextDir: 'asc' | 'desc' = 'asc';
172
- if (sortKey === key) {
173
- nextDir = sortDir === 'asc' ? 'desc' : 'asc';
174
- }
368
+ const nextDir: 'asc' | 'desc' = sortKey === key && sortDir === 'asc' ? 'desc' : 'asc';
175
369
  setSortKey(key);
176
370
  setSortDir(nextDir);
177
371
  onSort?.(key, nextDir);
@@ -179,42 +373,113 @@ export function TkxDataGrid<T = any>({
179
373
  [sortKey, sortDir, onSort],
180
374
  );
181
375
 
182
- // ── Selection logic ─────────────────────────────────────────────────────
376
+ // ── Client-side sort (when no external onSort handler) ──────────────────
377
+ const sortedData = useMemo<T[]>(() => {
378
+ // If consumer handles sorting externally, trust the data order
379
+ if (onSort || !sortKey) return data;
380
+ return [...data].sort((a, b) => {
381
+ const aVal = getCellValue(a, sortKey);
382
+ const bVal = getCellValue(b, sortKey);
383
+ const cmp = compareValues(aVal, bVal);
384
+ return sortDir === 'asc' ? cmp : -cmp;
385
+ });
386
+ }, [data, sortKey, sortDir, onSort]);
387
+
388
+ // ── Internal filter state ───────────────────────────────────────────────
389
+ const [internalFilters, setInternalFilters] = useState<Record<string, string>>({});
390
+ const filters = externalFilterValues ?? internalFilters;
391
+
392
+ const handleFilterChange = useCallback(
393
+ (key: string, value: string) => {
394
+ if (onFilterChange) {
395
+ onFilterChange(key, value);
396
+ } else {
397
+ setInternalFilters(prev => ({ ...prev, [key]: value }));
398
+ }
399
+ },
400
+ [onFilterChange],
401
+ );
183
402
 
403
+ // ── Client-side filter ──────────────────────────────────────────────────
404
+ const filteredData = useMemo<T[]>(() => {
405
+ const activeFilters = Object.entries(filters).filter(([, v]) => v.trim() !== '');
406
+ if (activeFilters.length === 0) return sortedData;
407
+ return sortedData.filter(row =>
408
+ activeFilters.every(([key, filterVal]) => {
409
+ const cell = getCellValue(row, key);
410
+ return String(cell ?? '').toLowerCase().includes(filterVal.toLowerCase());
411
+ }),
412
+ );
413
+ }, [sortedData, filters]);
414
+
415
+ // ── Pagination ──────────────────────────────────────────────────────────
416
+ const [page, setPage] = useState(1);
417
+ const isPaginated = pageSize > 0;
418
+ const totalRows = filteredData.length;
419
+ const totalPages = isPaginated ? Math.max(1, Math.ceil(totalRows / pageSize)) : 1;
420
+
421
+ // Reset to page 1 when filters/sort change
422
+ useEffect(() => { setPage(1); }, [filters, sortKey, sortDir]);
423
+
424
+ const pagedData = useMemo<T[]>(() => {
425
+ if (!isPaginated) return filteredData;
426
+ return filteredData.slice((page - 1) * pageSize, page * pageSize);
427
+ }, [filteredData, isPaginated, page, pageSize]);
428
+
429
+ // ── Virtual scroll ─────────────────────────────────────────────────────
430
+ const OVERSCAN = 10;
431
+ const isVirtual =
432
+ virtualScroll !== undefined
433
+ ? virtualScroll
434
+ : !isPaginated && maxHeight !== undefined && pagedData.length >= 50;
435
+
436
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
437
+ const [scrollTop, setScrollTop] = useState(0);
438
+ const [containerHeight, setContainerHeight] = useState(0);
439
+
440
+ const handleScroll = useCallback(() => {
441
+ if (scrollContainerRef.current) setScrollTop(scrollContainerRef.current.scrollTop);
442
+ }, []);
443
+
444
+ useEffect(() => {
445
+ if (!isVirtual || !scrollContainerRef.current) return;
446
+ const el = scrollContainerRef.current;
447
+ setContainerHeight(el.clientHeight);
448
+ const ro = new ResizeObserver(() => setContainerHeight(el.clientHeight));
449
+ ro.observe(el);
450
+ return () => ro.disconnect();
451
+ }, [isVirtual]);
452
+
453
+ const totalHeight = pagedData.length * rowHeight;
454
+ const startIndex = isVirtual
455
+ ? Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN)
456
+ : 0;
457
+ const endIndex = isVirtual
458
+ ? Math.min(pagedData.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + OVERSCAN)
459
+ : pagedData.length;
460
+ const visibleData = isVirtual ? pagedData.slice(startIndex, endIndex) : pagedData;
461
+ const offsetY = startIndex * rowHeight;
462
+
463
+ // ── Selection logic ─────────────────────────────────────────────────────
184
464
  const selectedSet = useMemo(() => new Set(selectedRows), [selectedRows]);
185
- const allIds = useMemo(
186
- () => data.map((row) => getRowId(row, rowKey)),
187
- [data, rowKey],
188
- );
189
- const allSelected = allIds.length > 0 && allIds.every((id) => selectedSet.has(id));
190
- const someSelected = allIds.some((id) => selectedSet.has(id));
465
+ const allIds = useMemo(() => filteredData.map(row => getRowId(row, rowKey)), [filteredData, rowKey]);
466
+ const allSelected = allIds.length > 0 && allIds.every(id => selectedSet.has(id));
467
+ const someSelected = allIds.some(id => selectedSet.has(id));
191
468
 
192
469
  const toggleAll = useCallback(() => {
193
- if (allSelected) {
194
- onSelectionChange?.([]);
195
- } else {
196
- onSelectionChange?.(allIds);
197
- }
470
+ onSelectionChange?.(allSelected ? [] : allIds);
198
471
  }, [allSelected, allIds, onSelectionChange]);
199
472
 
200
- const toggleRow = useCallback(
201
- (id: string) => {
202
- const next = selectedSet.has(id)
203
- ? selectedRows.filter((r) => r !== id)
204
- : [...selectedRows, id];
205
- onSelectionChange?.(next);
206
- },
207
- [selectedSet, selectedRows, onSelectionChange],
208
- );
473
+ const toggleRow = useCallback((id: string) => {
474
+ const next = selectedSet.has(id)
475
+ ? selectedRows.filter(r => r !== id)
476
+ : [...selectedRows, id];
477
+ onSelectionChange?.(next);
478
+ }, [selectedSet, selectedRows, onSelectionChange]);
209
479
 
210
480
  // ── Column resize ─────────────────────────────────────────────────────
211
-
212
481
  const [colWidths, setColWidths] = useState<Record<string, number>>({});
213
- const resizeRef = useRef<{
214
- key: string;
215
- startX: number;
216
- startWidth: number;
217
- } | null>(null);
482
+ const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
218
483
 
219
484
  const handleResizeStart = useCallback(
220
485
  (key: string, e: ReactMouseEvent) => {
@@ -227,60 +492,82 @@ export function TkxDataGrid<T = any>({
227
492
 
228
493
  const handleMouseMove = (ev: globalThis.MouseEvent) => {
229
494
  if (!resizeRef.current) return;
230
- const diff = ev.clientX - resizeRef.current.startX;
231
- const newWidth = Math.max(50, resizeRef.current.startWidth + diff);
232
- setColWidths((prev) => ({ ...prev, [resizeRef.current!.key]: newWidth }));
495
+ const newWidth = Math.max(50, resizeRef.current.startWidth + ev.clientX - resizeRef.current.startX);
496
+ setColWidths(prev => ({ ...prev, [resizeRef.current!.key]: newWidth }));
233
497
  };
234
-
235
498
  const handleMouseUp = () => {
236
499
  resizeRef.current = null;
237
500
  document.removeEventListener('mousemove', handleMouseMove);
238
501
  document.removeEventListener('mouseup', handleMouseUp);
239
502
  };
240
-
241
503
  document.addEventListener('mousemove', handleMouseMove);
242
504
  document.addEventListener('mouseup', handleMouseUp);
243
505
  },
244
506
  [colWidths],
245
507
  );
246
508
 
247
- // ── Cell sizing ─────────────────────────────────────────────────────────
509
+ // ── CSV Export ──────────────────────────────────────────────────────────
510
+ const handleExport = useCallback(() => {
511
+ const header = columns.map(c => escapeCSV(c.header)).join(',');
512
+ const rows = filteredData.map(row =>
513
+ columns.map(col => {
514
+ const raw = getCellValue(row, col.key);
515
+ // Skip custom renderers for CSV — export raw values
516
+ return escapeCSV(raw);
517
+ }).join(','),
518
+ );
519
+ const csv = [header, ...rows].join('\n');
520
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
521
+ const url = URL.createObjectURL(blob);
522
+ const a = document.createElement('a');
523
+ a.href = url;
524
+ a.download = `${exportFileName}.csv`;
525
+ a.click();
526
+ URL.revokeObjectURL(url);
527
+ }, [columns, filteredData, exportFileName]);
248
528
 
529
+ // ── Cell sizing ─────────────────────────────────────────────────────────
249
530
  const py = compact ? '4px' : '8px';
250
531
  const px = compact ? '8px' : '12px';
251
532
 
252
533
  const getColStyle = (col: DataGridColumn<T>): CSSProperties => {
253
534
  const w = colWidths[col.key] || col.width;
254
535
  return {
255
- width: w ? (typeof w === 'number' ? w : w) : undefined,
256
- minWidth: w ? (typeof w === 'number' ? w : w) : 80,
536
+ width: w != null ? (typeof w === 'number' ? w : w) : undefined,
537
+ minWidth: w != null ? (typeof w === 'number' ? w : w) : 80,
257
538
  textAlign: col.align ?? 'left',
258
539
  padding: `${py} ${px}`,
259
540
  };
260
541
  };
261
542
 
262
- // ── Render helpers ────────────────────────────────────────────────────
263
-
264
- const borderStyle = bordered
265
- ? `1px solid ${theme.border}`
266
- : 'none';
267
-
543
+ const borderRight = bordered ? `1px solid ${theme.border}` : 'none';
268
544
  const safeEmpty = sanitizeString(emptyMessage);
269
-
270
545
  const totalCols = columns.length + (selectable ? 1 : 0);
271
546
 
547
+ const hasFilterableColumns = columns.some(c => c.filterable);
548
+
272
549
  return (
273
550
  <div
274
551
  role="grid"
275
552
  aria-label="Data grid"
553
+ aria-rowcount={totalRows}
276
554
  id={gridId}
277
555
  className={tkx('font-sans rounded-lg overflow-hidden')}
278
- style={{
279
- border: `1px solid ${theme.border}`,
280
- backgroundColor: theme.bg,
281
- }}
556
+ style={{ border: `1px solid ${theme.border}`, backgroundColor: theme.bg }}
282
557
  >
558
+ {/* ── Toolbar ───────────────────────────────────────────────────── */}
559
+ <GridToolbar
560
+ label="data grid"
561
+ showExport={showExport}
562
+ onExport={handleExport}
563
+ totalRows={totalRows}
564
+ selectedCount={selectedRows.length}
565
+ theme={theme}
566
+ />
567
+
283
568
  <div
569
+ ref={scrollContainerRef}
570
+ onScroll={isVirtual ? handleScroll : undefined}
284
571
  style={{
285
572
  maxHeight: maxHeight ?? 'none',
286
573
  overflowX: 'auto',
@@ -289,52 +576,43 @@ export function TkxDataGrid<T = any>({
289
576
  >
290
577
  <table
291
578
  className={tkx('w-full')}
292
- style={{
293
- borderCollapse: 'separate',
294
- borderSpacing: 0,
295
- tableLayout: 'auto',
296
- }}
579
+ style={{ borderCollapse: 'separate', borderSpacing: 0, tableLayout: 'auto' }}
297
580
  >
298
581
  {/* ── Header ─────────────────────────────────────────────── */}
299
582
  <thead>
300
- <tr>
301
- {/* Selection header */}
583
+ <tr role="row">
584
+ {/* Select-all */}
302
585
  {selectable && (
303
586
  <th
304
587
  scope="col"
305
- className={tkx('shrink-0')}
588
+ role="columnheader"
306
589
  style={{
307
590
  position: stickyHeader ? 'sticky' : 'static',
308
- top: 0,
309
- zIndex: 2,
591
+ top: 0, zIndex: 2,
310
592
  backgroundColor: theme.surface,
311
593
  borderBottom: `2px solid ${theme.border}`,
312
- borderRight: bordered ? borderStyle : 'none',
594
+ borderRight,
313
595
  padding: `${py} ${px}`,
314
596
  width: 40,
315
597
  textAlign: 'center',
316
598
  }}
317
599
  >
318
- <button
319
- onClick={toggleAll}
320
- aria-label={allSelected ? 'Deselect all rows' : 'Select all rows'}
321
- className={tkx(
322
- 'bg-transparent border-none cursor-pointer p-0',
323
- 'flex items-center justify-center',
324
- 'focus-visible:focus-ring',
325
- )}
326
- style={{ margin: '0 auto' }}
327
- >
328
- <CheckboxIcon
600
+ <label style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', margin: '0 auto', width: 16 }}>
601
+ <input
602
+ type="checkbox"
329
603
  checked={allSelected}
330
- indeterminate={someSelected && !allSelected}
604
+ ref={el => { if (el) el.indeterminate = someSelected && !allSelected; }}
605
+ onChange={toggleAll}
606
+ aria-label={allSelected ? 'Deselect all rows' : 'Select all rows'}
607
+ style={{ position: 'absolute', opacity: 0, width: 16, height: 16, cursor: 'pointer', margin: 0 }}
331
608
  />
332
- </button>
609
+ <CheckboxIcon checked={allSelected} indeterminate={someSelected && !allSelected} />
610
+ </label>
333
611
  </th>
334
612
  )}
335
613
 
336
614
  {/* Column headers */}
337
- {columns.map((col) => {
615
+ {columns.map(col => {
338
616
  const isSortable = (col.sortable ?? sortable) && col.sortable !== false;
339
617
  const isSorted = sortKey === col.key;
340
618
  const safeHeader = sanitizeString(col.header);
@@ -343,47 +621,31 @@ export function TkxDataGrid<T = any>({
343
621
  <th
344
622
  key={col.key}
345
623
  scope="col"
346
- aria-sort={
347
- isSorted
348
- ? sortDir === 'asc'
349
- ? 'ascending'
350
- : 'descending'
351
- : undefined
352
- }
624
+ role="columnheader"
625
+ aria-sort={isSorted ? (sortDir === 'asc' ? 'ascending' : 'descending') : (isSortable ? 'none' : undefined)}
353
626
  className={tkx('text-xs font-semibold uppercase tracking-wider')}
354
627
  style={{
355
628
  ...getColStyle(col),
356
629
  position: stickyHeader ? 'sticky' : 'static',
357
- top: 0,
358
- zIndex: 2,
630
+ top: 0, zIndex: 2,
359
631
  backgroundColor: theme.surface,
360
632
  color: theme.textMuted,
361
633
  borderBottom: `2px solid ${theme.border}`,
362
- borderRight: bordered ? borderStyle : 'none',
634
+ borderRight,
363
635
  cursor: isSortable ? 'pointer' : 'default',
364
636
  userSelect: 'none',
365
637
  whiteSpace: 'nowrap',
366
638
  transition: reduced ? 'none' : 'background-color 150ms ease',
367
639
  }}
368
640
  onClick={isSortable ? () => handleSort(col.key) : undefined}
369
- onKeyDown={
370
- isSortable
371
- ? (e) => {
372
- if (e.key === 'Enter' || e.key === ' ') {
373
- e.preventDefault();
374
- handleSort(col.key);
375
- }
376
- }
377
- : undefined
378
- }
641
+ onKeyDown={isSortable ? e => {
642
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSort(col.key); }
643
+ } : undefined}
379
644
  tabIndex={isSortable ? 0 : undefined}
380
- role={isSortable ? 'columnheader button' : 'columnheader'}
381
645
  >
382
- <div className={tkx('flex items-center gap-1')}>
646
+ <div className={tkx('flex items-center gap-1')} style={{ position: 'relative' }}>
383
647
  {col.renderHeader ? col.renderHeader(col) : safeHeader}
384
- {isSortable && (
385
- <SortIcon direction={isSorted ? sortDir : null} />
386
- )}
648
+ {isSortable && <SortIcon direction={isSorted ? sortDir : null} />}
387
649
 
388
650
  {/* Resize handle */}
389
651
  {col.resizable && (
@@ -393,14 +655,10 @@ export function TkxDataGrid<T = any>({
393
655
  aria-label={`Resize column ${safeHeader}`}
394
656
  className={tkx('cursor-col-resize shrink-0')}
395
657
  style={{
396
- position: 'absolute',
397
- right: 0,
398
- top: 0,
399
- bottom: 0,
400
- width: 4,
401
- backgroundColor: 'transparent',
658
+ position: 'absolute', right: 0, top: 0, bottom: 0,
659
+ width: 4, backgroundColor: 'transparent',
402
660
  }}
403
- onMouseDown={(e) => handleResizeStart(col.key, e)}
661
+ onMouseDown={e => handleResizeStart(col.key, e)}
404
662
  />
405
663
  )}
406
664
  </div>
@@ -408,6 +666,58 @@ export function TkxDataGrid<T = any>({
408
666
  );
409
667
  })}
410
668
  </tr>
669
+
670
+ {/* ── Filter row ──────────────────────────────────────── */}
671
+ {(showFilters || hasFilterableColumns) && (
672
+ <tr role="row" aria-label="Column filters">
673
+ {selectable && (
674
+ <th
675
+ scope="col"
676
+ style={{
677
+ backgroundColor: theme.surfaceAlt,
678
+ borderBottom: `1px solid ${theme.border}`,
679
+ borderRight,
680
+ padding: `4px ${px}`,
681
+ width: 40,
682
+ }}
683
+ />
684
+ )}
685
+ {columns.map(col => (
686
+ <th
687
+ key={col.key}
688
+ scope="col"
689
+ style={{
690
+ backgroundColor: theme.surfaceAlt,
691
+ borderBottom: `1px solid ${theme.border}`,
692
+ borderRight,
693
+ padding: `4px ${px}`,
694
+ }}
695
+ >
696
+ {(col.filterable || showFilters) && (
697
+ <input
698
+ type="text"
699
+ value={filters[col.key] ?? ''}
700
+ onChange={(e: ChangeEvent<HTMLInputElement>) =>
701
+ handleFilterChange(col.key, e.target.value)
702
+ }
703
+ placeholder={`Filter ${col.header}…`}
704
+ aria-label={`Filter by ${col.header}`}
705
+ style={{
706
+ width: '100%',
707
+ padding: '3px 8px',
708
+ fontSize: 12,
709
+ borderRadius: 4,
710
+ border: `1px solid ${theme.border}`,
711
+ backgroundColor: theme.bg,
712
+ color: theme.text,
713
+ outline: 'none',
714
+ }}
715
+ />
716
+ )}
717
+ </th>
718
+ ))}
719
+ </tr>
720
+ )}
411
721
  </thead>
412
722
 
413
723
  {/* ── Loading ───────────────────────────────────────────── */}
@@ -416,125 +726,142 @@ export function TkxDataGrid<T = any>({
416
726
  {/* ── Body ──────────────────────────────────────────────── */}
417
727
  {!loading && (
418
728
  <tbody>
419
- {data.length === 0 ? (
729
+ {pagedData.length === 0 ? (
420
730
  <tr>
421
- <td
422
- colSpan={totalCols}
423
- className={tkx('text-center py-10')}
424
- style={{ color: theme.textMuted }}
425
- >
731
+ <td colSpan={totalCols} className={tkx('text-center py-10')}
732
+ style={{ color: theme.textMuted }}>
426
733
  {safeEmpty}
427
734
  </td>
428
735
  </tr>
429
736
  ) : (
430
- data.map((row, rowIndex) => {
431
- const id = getRowId(row, rowKey);
432
- const isSelected = selectedSet.has(id);
433
- const isStriped = striped && rowIndex % 2 === 1;
434
-
435
- let rowBg = 'transparent';
436
- if (isSelected) rowBg = `${theme.primary}15`;
437
- else if (isStriped) rowBg = theme.surfaceAlt;
438
-
439
- return (
440
- <tr
441
- key={id}
442
- role="row"
443
- aria-selected={selectable ? isSelected : undefined}
444
- onClick={onRowClick ? () => onRowClick(row) : undefined}
445
- className={tkx(onRowClick ? 'cursor-pointer' : '')}
446
- style={{
447
- backgroundColor: rowBg,
448
- transition: reduced
449
- ? 'none'
450
- : 'background-color 120ms ease',
451
- }}
452
- onMouseEnter={
453
- onRowClick
454
- ? (e) => {
455
- (e.currentTarget as HTMLElement).style.backgroundColor =
456
- isSelected
457
- ? `${theme.primary}22`
458
- : `${theme.surfaceAlt}`;
459
- }
460
- : undefined
461
- }
462
- onMouseLeave={
463
- onRowClick
464
- ? (e) => {
465
- (e.currentTarget as HTMLElement).style.backgroundColor = rowBg;
466
- }
467
- : undefined
468
- }
469
- >
470
- {/* Selection checkbox */}
471
- {selectable && (
472
- <td
473
- style={{
474
- borderBottom: `1px solid ${theme.border}`,
475
- borderRight: bordered ? borderStyle : 'none',
476
- padding: `${py} ${px}`,
477
- textAlign: 'center',
478
- width: 40,
479
- }}
480
- >
481
- <button
482
- onClick={(e) => {
483
- e.stopPropagation();
484
- toggleRow(id);
485
- }}
486
- aria-label={
487
- isSelected
488
- ? `Deselect row ${id}`
489
- : `Select row ${id}`
490
- }
491
- className={tkx(
492
- 'bg-transparent border-none cursor-pointer p-0',
493
- 'flex items-center justify-center',
494
- 'focus-visible:focus-ring',
495
- )}
496
- style={{ margin: '0 auto' }}
497
- >
498
- <CheckboxIcon checked={isSelected} />
499
- </button>
500
- </td>
501
- )}
502
-
503
- {/* Data cells */}
504
- {columns.map((col) => {
505
- const value = getCellValue(row, col.key);
506
- const cellContent = col.renderCell
507
- ? col.renderCell(value, row)
508
- : typeof value === 'string'
509
- ? sanitizeString(value)
510
- : String(value ?? '');
511
-
512
- return (
737
+ <>
738
+ {/* Top spacer for virtual scrolling */}
739
+ {isVirtual && offsetY > 0 && (
740
+ <tr aria-hidden="true">
741
+ <td colSpan={totalCols} style={{ height: offsetY, padding: 0, border: 'none' }} />
742
+ </tr>
743
+ )}
744
+
745
+ {visibleData.map((row, i) => {
746
+ const rowIndex = startIndex + i;
747
+ const id = getRowId(row, rowKey);
748
+ const isSelected = selectedSet.has(id);
749
+ const isStriped = striped && rowIndex % 2 === 1;
750
+
751
+ let rowBg = 'transparent';
752
+ if (isSelected) rowBg = `${theme.primary}22`;
753
+ else if (isStriped) rowBg = theme.surfaceAlt;
754
+
755
+ return (
756
+ <tr
757
+ key={id}
758
+ role="row"
759
+ aria-rowindex={rowIndex + 1}
760
+ aria-selected={selectable ? isSelected : undefined}
761
+ onClick={onRowClick ? () => onRowClick(row) : undefined}
762
+ onKeyDown={onRowClick ? e => {
763
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(row); }
764
+ } : undefined}
765
+ tabIndex={onRowClick ? 0 : undefined}
766
+ className={tkx(onRowClick ? 'cursor-pointer' : '')}
767
+ style={{
768
+ backgroundColor: rowBg,
769
+ transition: reduced ? 'none' : 'background-color 120ms ease',
770
+ ...(isVirtual ? { height: rowHeight, boxSizing: 'border-box' } : {}),
771
+ }}
772
+ onMouseEnter={onRowClick ? e => {
773
+ (e.currentTarget as HTMLElement).style.backgroundColor =
774
+ isSelected ? `${theme.primary}30` : theme.surfaceAlt;
775
+ } : undefined}
776
+ onMouseLeave={onRowClick ? e => {
777
+ (e.currentTarget as HTMLElement).style.backgroundColor = rowBg;
778
+ } : undefined}
779
+ >
780
+ {/* Selection checkbox */}
781
+ {selectable && (
513
782
  <td
514
- key={col.key}
515
- className={tkx('text-sm')}
783
+ role="gridcell"
516
784
  style={{
517
- ...getColStyle(col),
518
785
  borderBottom: `1px solid ${theme.border}`,
519
- borderRight: bordered ? borderStyle : 'none',
520
- color: theme.text,
521
- overflow: 'hidden',
522
- textOverflow: 'ellipsis',
523
- whiteSpace: 'nowrap',
786
+ borderRight,
787
+ padding: `${py} ${px}`,
788
+ textAlign: 'center',
789
+ width: 40,
524
790
  }}
525
791
  >
526
- {cellContent}
792
+ <label
793
+ style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', margin: '0 auto', width: 16 }}
794
+ onClick={e => e.stopPropagation()}
795
+ >
796
+ <input
797
+ type="checkbox"
798
+ checked={isSelected}
799
+ onChange={() => toggleRow(id)}
800
+ aria-label={isSelected ? `Deselect row ${id}` : `Select row ${id}`}
801
+ style={{ position: 'absolute', opacity: 0, width: 16, height: 16, cursor: 'pointer', margin: 0 }}
802
+ />
803
+ <CheckboxIcon checked={isSelected} />
804
+ </label>
527
805
  </td>
528
- );
529
- })}
806
+ )}
807
+
808
+ {/* Data cells */}
809
+ {columns.map(col => {
810
+ const value = getCellValue(row, col.key);
811
+ const cellContent = col.renderCell
812
+ ? col.renderCell(value, row)
813
+ : typeof value === 'string'
814
+ ? sanitizeString(value)
815
+ : String(value ?? '');
816
+
817
+ return (
818
+ <td
819
+ key={col.key}
820
+ role="gridcell"
821
+ className={tkx('text-sm')}
822
+ style={{
823
+ ...getColStyle(col),
824
+ borderBottom: `1px solid ${theme.border}`,
825
+ borderRight,
826
+ color: theme.text,
827
+ overflow: 'hidden',
828
+ textOverflow: 'ellipsis',
829
+ whiteSpace: 'nowrap',
830
+ }}
831
+ >
832
+ {cellContent}
833
+ </td>
834
+ );
835
+ })}
836
+ </tr>
837
+ );
838
+ })}
839
+
840
+ {/* Bottom spacer for virtual scrolling */}
841
+ {isVirtual && endIndex < pagedData.length && (
842
+ <tr aria-hidden="true">
843
+ <td colSpan={totalCols}
844
+ style={{ height: (pagedData.length - endIndex) * rowHeight, padding: 0, border: 'none' }} />
530
845
  </tr>
531
- );
532
- })
846
+ )}
847
+ </>
533
848
  )}
534
849
  </tbody>
535
850
  )}
536
851
  </table>
537
852
  </div>
853
+
854
+ {/* ── Pagination ────────────────────────────────────────────────── */}
855
+ {isPaginated && !loading && (
856
+ <PaginationBar
857
+ page={page}
858
+ totalPages={totalPages}
859
+ pageSize={pageSize}
860
+ totalRows={totalRows}
861
+ onPageChange={setPage}
862
+ theme={theme}
863
+ />
864
+ )}
538
865
  </div>
539
866
  );
540
867
  }