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.
- package/dist/TkxForm-BWK4LqY3.js +1461 -0
- package/dist/TkxForm-ljQjX7KD.cjs +15 -0
- package/dist/charts.cjs +1 -0
- package/dist/charts.js +480 -0
- package/dist/headless.cjs +1 -0
- package/dist/headless.js +258 -0
- package/dist/index-BINBzXuY.cjs +2 -0
- package/dist/index-eT_U4qB2.js +512 -0
- package/dist/index.cjs +20 -32
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7942 -8903
- package/dist/src/charts/TkxAreaChart.d.ts +29 -0
- package/dist/src/charts/TkxAreaChart.d.ts.map +1 -0
- package/dist/src/charts/TkxBarChart.d.ts +28 -0
- package/dist/src/charts/TkxBarChart.d.ts.map +1 -0
- package/dist/src/charts/TkxDonutChart.d.ts +23 -0
- package/dist/src/charts/TkxDonutChart.d.ts.map +1 -0
- package/dist/src/charts/TkxLineChart.d.ts +35 -0
- package/dist/src/charts/TkxLineChart.d.ts.map +1 -0
- package/dist/src/charts/TkxPieChart.d.ts +19 -0
- package/dist/src/charts/TkxPieChart.d.ts.map +1 -0
- package/dist/src/charts/TkxRadarChart.d.ts +20 -0
- package/dist/src/charts/TkxRadarChart.d.ts.map +1 -0
- package/dist/src/charts/TkxScatterChart.d.ts +28 -0
- package/dist/src/charts/TkxScatterChart.d.ts.map +1 -0
- package/dist/src/charts/index.d.ts +15 -0
- package/dist/src/charts/index.d.ts.map +1 -0
- package/dist/src/charts/shared.d.ts +33 -0
- package/dist/src/charts/shared.d.ts.map +1 -0
- package/dist/src/components/TkxDataGrid.d.ts +16 -2
- package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
- package/dist/src/components/TkxForm.d.ts +33 -3
- package/dist/src/components/TkxForm.d.ts.map +1 -1
- package/dist/src/components/TkxImage.d.ts.map +1 -1
- package/dist/src/components/TkxTypography.d.ts +1 -1
- package/dist/src/components/TkxTypography.d.ts.map +1 -1
- package/dist/src/engine/tkx.d.ts.map +1 -1
- package/dist/src/headless/index.d.ts +18 -0
- package/dist/src/headless/index.d.ts.map +1 -0
- package/dist/src/headless/useControllable.d.ts +20 -0
- package/dist/src/headless/useControllable.d.ts.map +1 -0
- package/dist/src/headless/useDebounce.d.ts +14 -0
- package/dist/src/headless/useDebounce.d.ts.map +1 -0
- package/dist/src/headless/useDisclosure.d.ts +20 -0
- package/dist/src/headless/useDisclosure.d.ts.map +1 -0
- package/dist/src/headless/useFormState.d.ts +44 -0
- package/dist/src/headless/useFormState.d.ts.map +1 -0
- package/dist/src/headless/useIntersectionObserver.d.ts +23 -0
- package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -0
- package/dist/src/headless/useListSelection.d.ts +29 -0
- package/dist/src/headless/useListSelection.d.ts.map +1 -0
- package/dist/src/headless/useLocalStorage.d.ts +9 -0
- package/dist/src/headless/useLocalStorage.d.ts.map +1 -0
- package/dist/src/headless/useMediaQuery.d.ts +21 -0
- package/dist/src/headless/useMediaQuery.d.ts.map +1 -0
- package/dist/src/headless/useRovingTabIndex.d.ts +33 -0
- package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -0
- package/dist/src/headless/useThrottle.d.ts +9 -0
- package/dist/src/headless/useThrottle.d.ts.map +1 -0
- package/dist/src/i18n/index.d.ts +47 -2
- package/dist/src/i18n/index.d.ts.map +1 -1
- package/dist/src/themes/index.d.ts.map +1 -1
- package/package.json +61 -7
- package/src/charts/TkxAreaChart.tsx +119 -0
- package/src/charts/TkxBarChart.tsx +115 -0
- package/src/charts/TkxDonutChart.tsx +145 -0
- package/src/charts/TkxLineChart.tsx +135 -0
- package/src/charts/TkxPieChart.tsx +82 -0
- package/src/charts/TkxRadarChart.tsx +90 -0
- package/src/charts/TkxScatterChart.tsx +102 -0
- package/src/charts/index.ts +20 -0
- package/src/charts/shared.ts +45 -0
- package/src/components/TkxDataGrid.tsx +532 -268
- package/src/components/TkxForm.tsx +67 -15
- package/src/components/TkxImage.tsx +1 -1
- package/src/components/TkxTypography.tsx +4 -2
- package/src/engine/tkx.ts +60 -3
- package/src/headless/index.ts +44 -0
- package/src/headless/useControllable.ts +40 -0
- package/src/headless/useDebounce.ts +35 -0
- package/src/headless/useDisclosure.ts +28 -0
- package/src/headless/useFormState.ts +111 -0
- package/src/headless/useIntersectionObserver.ts +51 -0
- package/src/headless/useListSelection.ts +101 -0
- package/src/headless/useLocalStorage.ts +46 -0
- package/src/headless/useMediaQuery.ts +37 -0
- package/src/headless/useRovingTabIndex.ts +84 -0
- package/src/headless/useThrottle.ts +46 -0
- package/src/i18n/I18nProvider.tsx +1 -1
- package/src/i18n/index.ts +678 -38
- 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
|
|
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"
|
|
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
|
-
{
|
|
150
|
+
{Array.from({ length: 5 }, (_, r) => (
|
|
129
151
|
<tr key={r}>
|
|
130
152
|
{Array.from({ length: colCount }, (_, c) => (
|
|
131
|
-
<td
|
|
132
|
-
|
|
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
|
-
// ──
|
|
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 &&
|
|
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 (
|
|
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 =
|
|
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(
|
|
205
|
-
:
|
|
206
|
-
const visibleData = isVirtual ?
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
276
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
{/*
|
|
583
|
+
<tr role="row">
|
|
584
|
+
{/* Select-all */}
|
|
349
585
|
{selectable && (
|
|
350
586
|
<th
|
|
351
587
|
scope="col"
|
|
352
|
-
|
|
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
|
|
594
|
+
borderRight,
|
|
360
595
|
padding: `${py} ${px}`,
|
|
361
596
|
width: 40,
|
|
362
597
|
textAlign: 'center',
|
|
363
598
|
}}
|
|
364
599
|
>
|
|
365
|
-
<
|
|
366
|
-
|
|
367
|
-
|
|
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=
|
|
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
|
-
|
|
609
|
+
<CheckboxIcon checked={allSelected} indeterminate={someSelected && !allSelected} />
|
|
610
|
+
</label>
|
|
380
611
|
</th>
|
|
381
612
|
)}
|
|
382
613
|
|
|
383
614
|
{/* Column headers */}
|
|
384
|
-
{columns.map(
|
|
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
|
-
|
|
394
|
-
|
|
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
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
{
|
|
729
|
+
{pagedData.length === 0 ? (
|
|
467
730
|
<tr>
|
|
468
|
-
<td
|
|
469
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
?
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
571
|
-
className={tkx('text-sm')}
|
|
783
|
+
role="gridcell"
|
|
572
784
|
style={{
|
|
573
|
-
...getColStyle(col),
|
|
574
785
|
borderBottom: `1px solid ${theme.border}`,
|
|
575
|
-
borderRight
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
whiteSpace: 'nowrap',
|
|
786
|
+
borderRight,
|
|
787
|
+
padding: `${py} ${px}`,
|
|
788
|
+
textAlign: 'center',
|
|
789
|
+
width: 40,
|
|
580
790
|
}}
|
|
581
791
|
>
|
|
582
|
-
|
|
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
|
}
|