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.
- 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 +63 -14
- package/dist/index.d.ts +33 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11396 -6336
- 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/TkxAffix.d.ts +10 -0
- package/dist/src/components/TkxAffix.d.ts.map +1 -0
- package/dist/src/components/TkxAnchor.d.ts +14 -0
- package/dist/src/components/TkxAnchor.d.ts.map +1 -0
- package/dist/src/components/TkxCascader.d.ts +16 -0
- package/dist/src/components/TkxCascader.d.ts.map +1 -0
- package/dist/src/components/TkxDataGrid.d.ts +19 -1
- 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/TkxList.d.ts +24 -0
- package/dist/src/components/TkxList.d.ts.map +1 -0
- package/dist/src/components/TkxMentions.d.ts +15 -0
- package/dist/src/components/TkxMentions.d.ts.map +1 -0
- package/dist/src/components/TkxQRCode.d.ts +11 -0
- package/dist/src/components/TkxQRCode.d.ts.map +1 -0
- package/dist/src/components/TkxResult.d.ts +11 -0
- package/dist/src/components/TkxResult.d.ts.map +1 -0
- package/dist/src/components/TkxSegmented.d.ts +16 -0
- package/dist/src/components/TkxSegmented.d.ts.map +1 -0
- package/dist/src/components/TkxSelect.d.ts +5 -1
- package/dist/src/components/TkxSelect.d.ts.map +1 -1
- package/dist/src/components/TkxTour.d.ts +15 -0
- package/dist/src/components/TkxTour.d.ts.map +1 -0
- package/dist/src/components/TkxTypography.d.ts +1 -1
- package/dist/src/components/TkxTypography.d.ts.map +1 -1
- package/dist/src/components/TkxWatermark.d.ts +12 -0
- package/dist/src/components/TkxWatermark.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +10 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/engine/tkx.d.ts +38 -0
- 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/TkxAffix.tsx +138 -0
- package/src/components/TkxAnchor.tsx +193 -0
- package/src/components/TkxCascader.tsx +276 -0
- package/src/components/TkxDataGrid.tsx +557 -230
- package/src/components/TkxForm.tsx +67 -15
- package/src/components/TkxImage.tsx +1 -1
- package/src/components/TkxList.tsx +242 -0
- package/src/components/TkxMentions.tsx +210 -0
- package/src/components/TkxQRCode.tsx +174 -0
- package/src/components/TkxResult.tsx +128 -0
- package/src/components/TkxSegmented.tsx +169 -0
- package/src/components/TkxSelect.tsx +209 -6
- package/src/components/TkxTour.tsx +239 -0
- package/src/components/TkxTypography.tsx +4 -2
- package/src/components/TkxWatermark.tsx +143 -0
- package/src/components/index.ts +10 -0
- package/src/engine/tkx.ts +122 -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
|
@@ -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"
|
|
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
|
-
{
|
|
150
|
+
{Array.from({ length: 5 }, (_, r) => (
|
|
124
151
|
<tr key={r}>
|
|
125
152
|
{Array.from({ length: colCount }, (_, c) => (
|
|
126
|
-
<td
|
|
127
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
231
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
{/*
|
|
583
|
+
<tr role="row">
|
|
584
|
+
{/* Select-all */}
|
|
302
585
|
{selectable && (
|
|
303
586
|
<th
|
|
304
587
|
scope="col"
|
|
305
|
-
|
|
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
|
|
594
|
+
borderRight,
|
|
313
595
|
padding: `${py} ${px}`,
|
|
314
596
|
width: 40,
|
|
315
597
|
textAlign: 'center',
|
|
316
598
|
}}
|
|
317
599
|
>
|
|
318
|
-
<
|
|
319
|
-
|
|
320
|
-
|
|
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=
|
|
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
|
-
|
|
609
|
+
<CheckboxIcon checked={allSelected} indeterminate={someSelected && !allSelected} />
|
|
610
|
+
</label>
|
|
333
611
|
</th>
|
|
334
612
|
)}
|
|
335
613
|
|
|
336
614
|
{/* Column headers */}
|
|
337
|
-
{columns.map(
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
{
|
|
729
|
+
{pagedData.length === 0 ? (
|
|
420
730
|
<tr>
|
|
421
|
-
<td
|
|
422
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
515
|
-
className={tkx('text-sm')}
|
|
783
|
+
role="gridcell"
|
|
516
784
|
style={{
|
|
517
|
-
...getColStyle(col),
|
|
518
785
|
borderBottom: `1px solid ${theme.border}`,
|
|
519
|
-
borderRight
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
whiteSpace: 'nowrap',
|
|
786
|
+
borderRight,
|
|
787
|
+
padding: `${py} ${px}`,
|
|
788
|
+
textAlign: 'center',
|
|
789
|
+
width: 40,
|
|
524
790
|
}}
|
|
525
791
|
>
|
|
526
|
-
|
|
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
|
}
|