tekivex-ui 2.3.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 (92) 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 +20 -32
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +7942 -8903
  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/TkxDataGrid.d.ts +16 -2
  32. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  33. package/dist/src/components/TkxForm.d.ts +33 -3
  34. package/dist/src/components/TkxForm.d.ts.map +1 -1
  35. package/dist/src/components/TkxImage.d.ts.map +1 -1
  36. package/dist/src/components/TkxTypography.d.ts +1 -1
  37. package/dist/src/components/TkxTypography.d.ts.map +1 -1
  38. package/dist/src/engine/tkx.d.ts.map +1 -1
  39. package/dist/src/headless/index.d.ts +18 -0
  40. package/dist/src/headless/index.d.ts.map +1 -0
  41. package/dist/src/headless/useControllable.d.ts +20 -0
  42. package/dist/src/headless/useControllable.d.ts.map +1 -0
  43. package/dist/src/headless/useDebounce.d.ts +14 -0
  44. package/dist/src/headless/useDebounce.d.ts.map +1 -0
  45. package/dist/src/headless/useDisclosure.d.ts +20 -0
  46. package/dist/src/headless/useDisclosure.d.ts.map +1 -0
  47. package/dist/src/headless/useFormState.d.ts +44 -0
  48. package/dist/src/headless/useFormState.d.ts.map +1 -0
  49. package/dist/src/headless/useIntersectionObserver.d.ts +23 -0
  50. package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -0
  51. package/dist/src/headless/useListSelection.d.ts +29 -0
  52. package/dist/src/headless/useListSelection.d.ts.map +1 -0
  53. package/dist/src/headless/useLocalStorage.d.ts +9 -0
  54. package/dist/src/headless/useLocalStorage.d.ts.map +1 -0
  55. package/dist/src/headless/useMediaQuery.d.ts +21 -0
  56. package/dist/src/headless/useMediaQuery.d.ts.map +1 -0
  57. package/dist/src/headless/useRovingTabIndex.d.ts +33 -0
  58. package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -0
  59. package/dist/src/headless/useThrottle.d.ts +9 -0
  60. package/dist/src/headless/useThrottle.d.ts.map +1 -0
  61. package/dist/src/i18n/index.d.ts +47 -2
  62. package/dist/src/i18n/index.d.ts.map +1 -1
  63. package/dist/src/themes/index.d.ts.map +1 -1
  64. package/package.json +61 -7
  65. package/src/charts/TkxAreaChart.tsx +119 -0
  66. package/src/charts/TkxBarChart.tsx +115 -0
  67. package/src/charts/TkxDonutChart.tsx +145 -0
  68. package/src/charts/TkxLineChart.tsx +135 -0
  69. package/src/charts/TkxPieChart.tsx +82 -0
  70. package/src/charts/TkxRadarChart.tsx +90 -0
  71. package/src/charts/TkxScatterChart.tsx +102 -0
  72. package/src/charts/index.ts +20 -0
  73. package/src/charts/shared.ts +45 -0
  74. package/src/components/TkxDataGrid.tsx +532 -268
  75. package/src/components/TkxForm.tsx +67 -15
  76. package/src/components/TkxImage.tsx +1 -1
  77. package/src/components/TkxTypography.tsx +4 -2
  78. package/src/engine/tkx.ts +60 -3
  79. package/src/headless/index.ts +44 -0
  80. package/src/headless/useControllable.ts +40 -0
  81. package/src/headless/useDebounce.ts +35 -0
  82. package/src/headless/useDisclosure.ts +28 -0
  83. package/src/headless/useFormState.ts +111 -0
  84. package/src/headless/useIntersectionObserver.ts +51 -0
  85. package/src/headless/useListSelection.ts +101 -0
  86. package/src/headless/useLocalStorage.ts +46 -0
  87. package/src/headless/useMediaQuery.ts +37 -0
  88. package/src/headless/useRovingTabIndex.ts +84 -0
  89. package/src/headless/useThrottle.ts +46 -0
  90. package/src/i18n/I18nProvider.tsx +1 -1
  91. package/src/i18n/index.ts +678 -38
  92. package/src/themes/index.ts +31 -4
@@ -8,6 +8,7 @@ import {
8
8
  type ReactNode,
9
9
  type CSSProperties,
10
10
  type MouseEvent as ReactMouseEvent,
11
+ type ChangeEvent,
11
12
  } from 'react';
12
13
  import { useTheme } from '../themes';
13
14
  import { sanitizeString } from '../engine/security';
@@ -37,7 +38,9 @@ export interface TkxDataGridProps<T = any> {
37
38
  selectable?: boolean;
38
39
  selectedRows?: string[];
39
40
  onSelectionChange?: (ids: string[]) => void;
41
+ /** Enable column sorting. When true, clicks sort data client-side unless onSort is also provided. */
40
42
  sortable?: boolean;
43
+ /** External sort handler. When provided, raw data is NOT sorted internally — you must pass pre-sorted data. */
41
44
  onSort?: (key: string, direction: 'asc' | 'desc') => void;
42
45
  loading?: boolean;
43
46
  emptyMessage?: string;
@@ -47,10 +50,22 @@ export interface TkxDataGridProps<T = any> {
47
50
  compact?: boolean;
48
51
  maxHeight?: number | string;
49
52
  onRowClick?: (row: T) => void;
50
- /** Enable virtual scrolling. Defaults to auto (enabled when data has 50+ rows and maxHeight is set). */
53
+ /** Enable virtual scrolling. Defaults to auto (enabled when data 50 rows and maxHeight is set). */
51
54
  virtualScroll?: boolean;
52
55
  /** Row height in pixels for virtual scrolling calculations. Default: 40 */
53
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;
54
69
  }
55
70
 
56
71
  // ── Sort icon ─────────────────────────────────────────────────────────────────
@@ -81,18 +96,9 @@ function SortIcon({ direction }: { direction: 'asc' | 'desc' | null }) {
81
96
  function CheckboxIcon({ checked, indeterminate }: { checked: boolean; indeterminate?: boolean }) {
82
97
  const theme = useTheme();
83
98
  return (
84
- <svg
85
- width="16"
86
- height="16"
87
- viewBox="0 0 16 16"
88
- aria-hidden="true"
89
- >
99
+ <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
90
100
  <rect
91
- x="1"
92
- y="1"
93
- width="14"
94
- height="14"
95
- rx="2"
101
+ x="1" y="1" width="14" height="14" rx="2"
96
102
  fill={checked || indeterminate ? theme.primary : 'transparent'}
97
103
  stroke={checked || indeterminate ? theme.primary : theme.border}
98
104
  strokeWidth="1.5"
@@ -101,7 +107,8 @@ function CheckboxIcon({ checked, indeterminate }: { checked: boolean; indetermin
101
107
  <line x1="4" y1="8" x2="12" y2="8" stroke={theme.bg} strokeWidth="2" />
102
108
  )}
103
109
  {checked && !indeterminate && (
104
- <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" />
105
112
  )}
106
113
  </svg>
107
114
  );
@@ -118,21 +125,33 @@ function getCellValue<T>(row: T, key: string): unknown {
118
125
  return (row as Record<string, unknown>)[key];
119
126
  }
120
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
+
121
144
  // ── Loading skeleton ────────────────────────────────────────────────────────
122
145
 
123
146
  function LoadingOverlay({ colCount }: { colCount: number }) {
124
147
  const theme = useTheme();
125
- const rows = Array.from({ length: 5 }, (_, i) => i);
126
148
  return (
127
149
  <tbody>
128
- {rows.map((r) => (
150
+ {Array.from({ length: 5 }, (_, r) => (
129
151
  <tr key={r}>
130
152
  {Array.from({ length: colCount }, (_, c) => (
131
- <td
132
- key={c}
133
- className={tkx('px-3 py-2')}
134
- style={{ borderBottom: `1px solid ${theme.border}` }}
135
- >
153
+ <td key={c} className={tkx('px-3 py-2')}
154
+ style={{ borderBottom: `1px solid ${theme.border}` }}>
136
155
  <TkxSkeleton height={16} />
137
156
  </td>
138
157
  ))}
@@ -142,6 +161,172 @@ function LoadingOverlay({ colCount }: { colCount: number }) {
142
161
  );
143
162
  }
144
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
+
145
330
  // ── Component ─────────────────────────────────────────────────────────────────
146
331
 
147
332
  export function TkxDataGrid<T = any>({
@@ -163,103 +348,138 @@ export function TkxDataGrid<T = any>({
163
348
  onRowClick,
164
349
  virtualScroll,
165
350
  rowHeight = 40,
351
+ showFilters = false,
352
+ filterValues: externalFilterValues,
353
+ onFilterChange,
354
+ pageSize = 0,
355
+ showExport = false,
356
+ exportFileName = 'export',
166
357
  }: TkxDataGridProps<T>) {
167
358
  const theme = useTheme();
168
359
  const reduced = useReducedMotion();
169
360
  const gridId = useId();
170
361
 
171
- // ── Virtual scroll ────────────────────────────────────────────────────
362
+ // ── Internal sort state ─────────────────────────────────────────────────
363
+ const [sortKey, setSortKey] = useState<string | null>(null);
364
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
172
365
 
366
+ const handleSort = useCallback(
367
+ (key: string) => {
368
+ const nextDir: 'asc' | 'desc' = sortKey === key && sortDir === 'asc' ? 'desc' : 'asc';
369
+ setSortKey(key);
370
+ setSortDir(nextDir);
371
+ onSort?.(key, nextDir);
372
+ },
373
+ [sortKey, sortDir, onSort],
374
+ );
375
+
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
+ );
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 ─────────────────────────────────────────────────────
173
430
  const OVERSCAN = 10;
174
431
  const isVirtual =
175
432
  virtualScroll !== undefined
176
433
  ? virtualScroll
177
- : maxHeight !== undefined && data.length >= 50;
434
+ : !isPaginated && maxHeight !== undefined && pagedData.length >= 50;
178
435
 
179
436
  const scrollContainerRef = useRef<HTMLDivElement>(null);
180
437
  const [scrollTop, setScrollTop] = useState(0);
181
438
  const [containerHeight, setContainerHeight] = useState(0);
182
439
 
183
440
  const handleScroll = useCallback(() => {
184
- if (!scrollContainerRef.current) return;
185
- setScrollTop(scrollContainerRef.current.scrollTop);
441
+ if (scrollContainerRef.current) setScrollTop(scrollContainerRef.current.scrollTop);
186
442
  }, []);
187
443
 
188
444
  useEffect(() => {
189
445
  if (!isVirtual || !scrollContainerRef.current) return;
190
446
  const el = scrollContainerRef.current;
191
447
  setContainerHeight(el.clientHeight);
192
- const ro = new ResizeObserver(() => {
193
- setContainerHeight(el.clientHeight);
194
- });
448
+ const ro = new ResizeObserver(() => setContainerHeight(el.clientHeight));
195
449
  ro.observe(el);
196
450
  return () => ro.disconnect();
197
451
  }, [isVirtual]);
198
452
 
199
- const totalHeight = data.length * rowHeight;
453
+ const totalHeight = pagedData.length * rowHeight;
200
454
  const startIndex = isVirtual
201
455
  ? Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN)
202
456
  : 0;
203
457
  const endIndex = isVirtual
204
- ? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + OVERSCAN)
205
- : data.length;
206
- const visibleData = isVirtual ? data.slice(startIndex, endIndex) : data;
458
+ ? Math.min(pagedData.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + OVERSCAN)
459
+ : pagedData.length;
460
+ const visibleData = isVirtual ? pagedData.slice(startIndex, endIndex) : pagedData;
207
461
  const offsetY = startIndex * rowHeight;
208
462
 
209
- // ── Sort state ──────────────────────────────────────────────────────────
210
-
211
- const [sortKey, setSortKey] = useState<string | null>(null);
212
- const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
213
-
214
- const handleSort = useCallback(
215
- (key: string) => {
216
- let nextDir: 'asc' | 'desc' = 'asc';
217
- if (sortKey === key) {
218
- nextDir = sortDir === 'asc' ? 'desc' : 'asc';
219
- }
220
- setSortKey(key);
221
- setSortDir(nextDir);
222
- onSort?.(key, nextDir);
223
- },
224
- [sortKey, sortDir, onSort],
225
- );
226
-
227
463
  // ── Selection logic ─────────────────────────────────────────────────────
228
-
229
464
  const selectedSet = useMemo(() => new Set(selectedRows), [selectedRows]);
230
- const allIds = useMemo(
231
- () => data.map((row) => getRowId(row, rowKey)),
232
- [data, rowKey],
233
- );
234
- const allSelected = allIds.length > 0 && allIds.every((id) => selectedSet.has(id));
235
- 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));
236
468
 
237
469
  const toggleAll = useCallback(() => {
238
- if (allSelected) {
239
- onSelectionChange?.([]);
240
- } else {
241
- onSelectionChange?.(allIds);
242
- }
470
+ onSelectionChange?.(allSelected ? [] : allIds);
243
471
  }, [allSelected, allIds, onSelectionChange]);
244
472
 
245
- const toggleRow = useCallback(
246
- (id: string) => {
247
- const next = selectedSet.has(id)
248
- ? selectedRows.filter((r) => r !== id)
249
- : [...selectedRows, id];
250
- onSelectionChange?.(next);
251
- },
252
- [selectedSet, selectedRows, onSelectionChange],
253
- );
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]);
254
479
 
255
480
  // ── Column resize ─────────────────────────────────────────────────────
256
-
257
481
  const [colWidths, setColWidths] = useState<Record<string, number>>({});
258
- const resizeRef = useRef<{
259
- key: string;
260
- startX: number;
261
- startWidth: number;
262
- } | null>(null);
482
+ const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
263
483
 
264
484
  const handleResizeStart = useCallback(
265
485
  (key: string, e: ReactMouseEvent) => {
@@ -272,59 +492,79 @@ export function TkxDataGrid<T = any>({
272
492
 
273
493
  const handleMouseMove = (ev: globalThis.MouseEvent) => {
274
494
  if (!resizeRef.current) return;
275
- const diff = ev.clientX - resizeRef.current.startX;
276
- const newWidth = Math.max(50, resizeRef.current.startWidth + diff);
277
- 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 }));
278
497
  };
279
-
280
498
  const handleMouseUp = () => {
281
499
  resizeRef.current = null;
282
500
  document.removeEventListener('mousemove', handleMouseMove);
283
501
  document.removeEventListener('mouseup', handleMouseUp);
284
502
  };
285
-
286
503
  document.addEventListener('mousemove', handleMouseMove);
287
504
  document.addEventListener('mouseup', handleMouseUp);
288
505
  },
289
506
  [colWidths],
290
507
  );
291
508
 
292
- // ── 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]);
293
528
 
529
+ // ── Cell sizing ─────────────────────────────────────────────────────────
294
530
  const py = compact ? '4px' : '8px';
295
531
  const px = compact ? '8px' : '12px';
296
532
 
297
533
  const getColStyle = (col: DataGridColumn<T>): CSSProperties => {
298
534
  const w = colWidths[col.key] || col.width;
299
535
  return {
300
- width: w ? (typeof w === 'number' ? w : w) : undefined,
301
- 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,
302
538
  textAlign: col.align ?? 'left',
303
539
  padding: `${py} ${px}`,
304
540
  };
305
541
  };
306
542
 
307
- // ── Render helpers ────────────────────────────────────────────────────
308
-
309
- const borderStyle = bordered
310
- ? `1px solid ${theme.border}`
311
- : 'none';
312
-
543
+ const borderRight = bordered ? `1px solid ${theme.border}` : 'none';
313
544
  const safeEmpty = sanitizeString(emptyMessage);
314
-
315
545
  const totalCols = columns.length + (selectable ? 1 : 0);
316
546
 
547
+ const hasFilterableColumns = columns.some(c => c.filterable);
548
+
317
549
  return (
318
550
  <div
319
551
  role="grid"
320
552
  aria-label="Data grid"
553
+ aria-rowcount={totalRows}
321
554
  id={gridId}
322
555
  className={tkx('font-sans rounded-lg overflow-hidden')}
323
- style={{
324
- border: `1px solid ${theme.border}`,
325
- backgroundColor: theme.bg,
326
- }}
556
+ style={{ border: `1px solid ${theme.border}`, backgroundColor: theme.bg }}
327
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
+
328
568
  <div
329
569
  ref={scrollContainerRef}
330
570
  onScroll={isVirtual ? handleScroll : undefined}
@@ -336,52 +576,43 @@ export function TkxDataGrid<T = any>({
336
576
  >
337
577
  <table
338
578
  className={tkx('w-full')}
339
- style={{
340
- borderCollapse: 'separate',
341
- borderSpacing: 0,
342
- tableLayout: 'auto',
343
- }}
579
+ style={{ borderCollapse: 'separate', borderSpacing: 0, tableLayout: 'auto' }}
344
580
  >
345
581
  {/* ── Header ─────────────────────────────────────────────── */}
346
582
  <thead>
347
- <tr>
348
- {/* Selection header */}
583
+ <tr role="row">
584
+ {/* Select-all */}
349
585
  {selectable && (
350
586
  <th
351
587
  scope="col"
352
- className={tkx('shrink-0')}
588
+ role="columnheader"
353
589
  style={{
354
590
  position: stickyHeader ? 'sticky' : 'static',
355
- top: 0,
356
- zIndex: 2,
591
+ top: 0, zIndex: 2,
357
592
  backgroundColor: theme.surface,
358
593
  borderBottom: `2px solid ${theme.border}`,
359
- borderRight: bordered ? borderStyle : 'none',
594
+ borderRight,
360
595
  padding: `${py} ${px}`,
361
596
  width: 40,
362
597
  textAlign: 'center',
363
598
  }}
364
599
  >
365
- <button
366
- onClick={toggleAll}
367
- aria-label={allSelected ? 'Deselect all rows' : 'Select all rows'}
368
- className={tkx(
369
- 'bg-transparent border-none cursor-pointer p-0',
370
- 'flex items-center justify-center',
371
- 'focus-visible:focus-ring',
372
- )}
373
- style={{ margin: '0 auto' }}
374
- >
375
- <CheckboxIcon
600
+ <label style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', margin: '0 auto', width: 16 }}>
601
+ <input
602
+ type="checkbox"
376
603
  checked={allSelected}
377
- 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 }}
378
608
  />
379
- </button>
609
+ <CheckboxIcon checked={allSelected} indeterminate={someSelected && !allSelected} />
610
+ </label>
380
611
  </th>
381
612
  )}
382
613
 
383
614
  {/* Column headers */}
384
- {columns.map((col) => {
615
+ {columns.map(col => {
385
616
  const isSortable = (col.sortable ?? sortable) && col.sortable !== false;
386
617
  const isSorted = sortKey === col.key;
387
618
  const safeHeader = sanitizeString(col.header);
@@ -390,47 +621,31 @@ export function TkxDataGrid<T = any>({
390
621
  <th
391
622
  key={col.key}
392
623
  scope="col"
393
- aria-sort={
394
- isSorted
395
- ? sortDir === 'asc'
396
- ? 'ascending'
397
- : 'descending'
398
- : undefined
399
- }
624
+ role="columnheader"
625
+ aria-sort={isSorted ? (sortDir === 'asc' ? 'ascending' : 'descending') : (isSortable ? 'none' : undefined)}
400
626
  className={tkx('text-xs font-semibold uppercase tracking-wider')}
401
627
  style={{
402
628
  ...getColStyle(col),
403
629
  position: stickyHeader ? 'sticky' : 'static',
404
- top: 0,
405
- zIndex: 2,
630
+ top: 0, zIndex: 2,
406
631
  backgroundColor: theme.surface,
407
632
  color: theme.textMuted,
408
633
  borderBottom: `2px solid ${theme.border}`,
409
- borderRight: bordered ? borderStyle : 'none',
634
+ borderRight,
410
635
  cursor: isSortable ? 'pointer' : 'default',
411
636
  userSelect: 'none',
412
637
  whiteSpace: 'nowrap',
413
638
  transition: reduced ? 'none' : 'background-color 150ms ease',
414
639
  }}
415
640
  onClick={isSortable ? () => handleSort(col.key) : undefined}
416
- onKeyDown={
417
- isSortable
418
- ? (e) => {
419
- if (e.key === 'Enter' || e.key === ' ') {
420
- e.preventDefault();
421
- handleSort(col.key);
422
- }
423
- }
424
- : undefined
425
- }
641
+ onKeyDown={isSortable ? e => {
642
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSort(col.key); }
643
+ } : undefined}
426
644
  tabIndex={isSortable ? 0 : undefined}
427
- role={isSortable ? 'columnheader button' : 'columnheader'}
428
645
  >
429
- <div className={tkx('flex items-center gap-1')}>
646
+ <div className={tkx('flex items-center gap-1')} style={{ position: 'relative' }}>
430
647
  {col.renderHeader ? col.renderHeader(col) : safeHeader}
431
- {isSortable && (
432
- <SortIcon direction={isSorted ? sortDir : null} />
433
- )}
648
+ {isSortable && <SortIcon direction={isSorted ? sortDir : null} />}
434
649
 
435
650
  {/* Resize handle */}
436
651
  {col.resizable && (
@@ -440,14 +655,10 @@ export function TkxDataGrid<T = any>({
440
655
  aria-label={`Resize column ${safeHeader}`}
441
656
  className={tkx('cursor-col-resize shrink-0')}
442
657
  style={{
443
- position: 'absolute',
444
- right: 0,
445
- top: 0,
446
- bottom: 0,
447
- width: 4,
448
- backgroundColor: 'transparent',
658
+ position: 'absolute', right: 0, top: 0, bottom: 0,
659
+ width: 4, backgroundColor: 'transparent',
449
660
  }}
450
- onMouseDown={(e) => handleResizeStart(col.key, e)}
661
+ onMouseDown={e => handleResizeStart(col.key, e)}
451
662
  />
452
663
  )}
453
664
  </div>
@@ -455,6 +666,58 @@ export function TkxDataGrid<T = any>({
455
666
  );
456
667
  })}
457
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
+ )}
458
721
  </thead>
459
722
 
460
723
  {/* ── Loading ───────────────────────────────────────────── */}
@@ -463,141 +726,142 @@ export function TkxDataGrid<T = any>({
463
726
  {/* ── Body ──────────────────────────────────────────────── */}
464
727
  {!loading && (
465
728
  <tbody>
466
- {data.length === 0 ? (
729
+ {pagedData.length === 0 ? (
467
730
  <tr>
468
- <td
469
- colSpan={totalCols}
470
- className={tkx('text-center py-10')}
471
- style={{ color: theme.textMuted }}
472
- >
731
+ <td colSpan={totalCols} className={tkx('text-center py-10')}
732
+ style={{ color: theme.textMuted }}>
473
733
  {safeEmpty}
474
734
  </td>
475
735
  </tr>
476
736
  ) : (
477
737
  <>
478
- {/* Top spacer for virtual scrolling */}
479
- {isVirtual && offsetY > 0 && (
480
- <tr aria-hidden="true">
481
- <td colSpan={totalCols} style={{ height: offsetY, padding: 0, border: 'none' }} />
482
- </tr>
483
- )}
484
- {visibleData.map((row, i) => {
485
- const rowIndex = startIndex + i;
486
- const id = getRowId(row, rowKey);
487
- const isSelected = selectedSet.has(id);
488
- const isStriped = striped && rowIndex % 2 === 1;
489
-
490
- let rowBg = 'transparent';
491
- if (isSelected) rowBg = `${theme.primary}15`;
492
- else if (isStriped) rowBg = theme.surfaceAlt;
493
-
494
- return (
495
- <tr
496
- key={id}
497
- role="row"
498
- aria-selected={selectable ? isSelected : undefined}
499
- onClick={onRowClick ? () => onRowClick(row) : undefined}
500
- className={tkx(onRowClick ? 'cursor-pointer' : '')}
501
- style={{
502
- backgroundColor: rowBg,
503
- transition: reduced
504
- ? 'none'
505
- : 'background-color 120ms ease',
506
- ...(isVirtual ? { height: rowHeight, boxSizing: 'border-box' } : {}),
507
- }}
508
- onMouseEnter={
509
- onRowClick
510
- ? (e) => {
511
- (e.currentTarget as HTMLElement).style.backgroundColor =
512
- isSelected
513
- ? `${theme.primary}22`
514
- : `${theme.surfaceAlt}`;
515
- }
516
- : undefined
517
- }
518
- onMouseLeave={
519
- onRowClick
520
- ? (e) => {
521
- (e.currentTarget as HTMLElement).style.backgroundColor = rowBg;
522
- }
523
- : undefined
524
- }
525
- >
526
- {/* Selection checkbox */}
527
- {selectable && (
528
- <td
529
- style={{
530
- borderBottom: `1px solid ${theme.border}`,
531
- borderRight: bordered ? borderStyle : 'none',
532
- padding: `${py} ${px}`,
533
- textAlign: 'center',
534
- width: 40,
535
- }}
536
- >
537
- <button
538
- onClick={(e) => {
539
- e.stopPropagation();
540
- toggleRow(id);
541
- }}
542
- aria-label={
543
- isSelected
544
- ? `Deselect row ${id}`
545
- : `Select row ${id}`
546
- }
547
- className={tkx(
548
- 'bg-transparent border-none cursor-pointer p-0',
549
- 'flex items-center justify-center',
550
- 'focus-visible:focus-ring',
551
- )}
552
- style={{ margin: '0 auto' }}
553
- >
554
- <CheckboxIcon checked={isSelected} />
555
- </button>
556
- </td>
557
- )}
558
-
559
- {/* Data cells */}
560
- {columns.map((col) => {
561
- const value = getCellValue(row, col.key);
562
- const cellContent = col.renderCell
563
- ? col.renderCell(value, row)
564
- : typeof value === 'string'
565
- ? sanitizeString(value)
566
- : String(value ?? '');
567
-
568
- return (
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 && (
569
782
  <td
570
- key={col.key}
571
- className={tkx('text-sm')}
783
+ role="gridcell"
572
784
  style={{
573
- ...getColStyle(col),
574
785
  borderBottom: `1px solid ${theme.border}`,
575
- borderRight: bordered ? borderStyle : 'none',
576
- color: theme.text,
577
- overflow: 'hidden',
578
- textOverflow: 'ellipsis',
579
- whiteSpace: 'nowrap',
786
+ borderRight,
787
+ padding: `${py} ${px}`,
788
+ textAlign: 'center',
789
+ width: 40,
580
790
  }}
581
791
  >
582
- {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>
583
805
  </td>
584
- );
585
- })}
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' }} />
586
845
  </tr>
587
- );
588
- })}
589
- {/* Bottom spacer for virtual scrolling */}
590
- {isVirtual && endIndex < data.length && (
591
- <tr aria-hidden="true">
592
- <td colSpan={totalCols} style={{ height: (data.length - endIndex) * rowHeight, padding: 0, border: 'none' }} />
593
- </tr>
594
- )}
846
+ )}
595
847
  </>
596
848
  )}
597
849
  </tbody>
598
850
  )}
599
851
  </table>
600
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
+ )}
601
865
  </div>
602
866
  );
603
867
  }