@thangnqdev/ui 0.2.0 → 0.2.2

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 (190) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +144 -142
  3. package/bin/cli.js +125 -0
  4. package/dist/bento/index.cjs.map +1 -1
  5. package/dist/bento/index.js +2 -2
  6. package/dist/buttons/index.cjs.map +1 -1
  7. package/dist/buttons/index.js +3 -3
  8. package/dist/{chunk-77KTYOMQ.js → chunk-2OXZK6IP.js} +3 -3
  9. package/dist/chunk-2OXZK6IP.js.map +1 -0
  10. package/dist/{chunk-4CRFNALF.js → chunk-4GNFT2ZJ.js} +7 -7
  11. package/dist/chunk-4GNFT2ZJ.js.map +1 -0
  12. package/dist/{chunk-2YJBW4E2.js → chunk-4S6L5V7X.js} +2 -2
  13. package/dist/chunk-4S6L5V7X.js.map +1 -0
  14. package/dist/{chunk-I2FQBMVW.js → chunk-66VWUJEI.js} +3 -3
  15. package/dist/chunk-66VWUJEI.js.map +1 -0
  16. package/dist/{chunk-YOCGBORV.js → chunk-7NMB2Y64.js} +4 -4
  17. package/dist/chunk-7NMB2Y64.js.map +1 -0
  18. package/dist/{chunk-V52JOSDF.js → chunk-A7P3O5Z5.js} +3 -3
  19. package/dist/chunk-A7P3O5Z5.js.map +1 -0
  20. package/dist/{chunk-ME4XILG5.js → chunk-ATYGDTMK.js} +5 -5
  21. package/dist/chunk-ATYGDTMK.js.map +1 -0
  22. package/dist/{chunk-S6XJMYME.js → chunk-C4SUXMI3.js} +3 -3
  23. package/dist/chunk-C4SUXMI3.js.map +1 -0
  24. package/dist/{chunk-EXFR2BFJ.js → chunk-DUPPPGXY.js} +7 -7
  25. package/dist/chunk-DUPPPGXY.js.map +1 -0
  26. package/dist/{chunk-I5O2WCGF.js → chunk-EU2LPUYP.js} +3 -3
  27. package/dist/chunk-EU2LPUYP.js.map +1 -0
  28. package/dist/{chunk-D7FB3TCA.js → chunk-GXI2H5XA.js} +3 -3
  29. package/dist/chunk-GXI2H5XA.js.map +1 -0
  30. package/dist/{chunk-ADO3SVUA.js → chunk-IABPUCAQ.js} +2 -2
  31. package/dist/chunk-IABPUCAQ.js.map +1 -0
  32. package/dist/{chunk-PC5SZFZC.js → chunk-JOKEKOP3.js} +8 -8
  33. package/dist/chunk-JOKEKOP3.js.map +1 -0
  34. package/dist/{chunk-GXWB6BSY.js → chunk-KUQWUTZA.js} +4 -4
  35. package/dist/chunk-KUQWUTZA.js.map +1 -0
  36. package/dist/{chunk-ZEDXARXW.js → chunk-U3J4UPV3.js} +2 -2
  37. package/dist/chunk-U3J4UPV3.js.map +1 -0
  38. package/dist/{chunk-DSEL7ELZ.js → chunk-VE2PNSDQ.js} +4 -4
  39. package/dist/chunk-VE2PNSDQ.js.map +1 -0
  40. package/dist/{chunk-YJ64RI7J.js → chunk-WAFHC4DQ.js} +3 -3
  41. package/dist/chunk-WAFHC4DQ.js.map +1 -0
  42. package/dist/{chunk-W5WNWTQC.js → chunk-YJ67WYVW.js} +5 -5
  43. package/dist/chunk-YJ67WYVW.js.map +1 -0
  44. package/dist/{chunk-4MS6ZHFQ.js → chunk-Z427NPMU.js} +7 -7
  45. package/dist/chunk-Z427NPMU.js.map +1 -0
  46. package/dist/core/index.cjs +4 -4
  47. package/dist/core/index.cjs.map +1 -1
  48. package/dist/core/index.js +2 -2
  49. package/dist/data-display/index.cjs.map +1 -1
  50. package/dist/data-display/index.js +5 -5
  51. package/dist/feedback/index.cjs.map +1 -1
  52. package/dist/feedback/index.js +2 -2
  53. package/dist/forms/index.cjs.map +1 -1
  54. package/dist/forms/index.js +8 -8
  55. package/dist/glass/index.cjs.map +1 -1
  56. package/dist/glass/index.js +2 -2
  57. package/dist/index.cjs +4 -4
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.js +19 -19
  60. package/dist/layout/index.cjs.map +1 -1
  61. package/dist/layout/index.js +2 -2
  62. package/dist/navigation/index.cjs.map +1 -1
  63. package/dist/navigation/index.js +6 -6
  64. package/dist/overlays/index.cjs.map +1 -1
  65. package/dist/overlays/index.js +7 -7
  66. package/dist/reset.css +45 -45
  67. package/dist/tokens.css +11 -11
  68. package/dist/typography/index.cjs.map +1 -1
  69. package/dist/typography/index.js +4 -4
  70. package/llms.txt +3 -3
  71. package/package.json +166 -159
  72. package/scripts/postinstall.js +125 -0
  73. package/src/bento/BentoCard.tsx +140 -140
  74. package/src/bento/BentoGrid.tsx +50 -50
  75. package/src/bento/index.ts +2 -2
  76. package/src/bento/tokens.css +46 -46
  77. package/src/buttons/Button.tsx +104 -104
  78. package/src/buttons/ButtonGroup.tsx +43 -43
  79. package/src/buttons/IconButton.tsx +79 -79
  80. package/src/buttons/SplitButton.tsx +186 -186
  81. package/src/buttons/ToggleButton.tsx +157 -157
  82. package/src/buttons/index.ts +5 -5
  83. package/src/buttons/tokens.css +6 -6
  84. package/src/core/ThemeProvider.tsx +133 -133
  85. package/src/core/index.ts +2 -2
  86. package/src/core/tokens.css +134 -134
  87. package/src/data-display/Accordion.tsx +147 -147
  88. package/src/data-display/Avatar.tsx +134 -134
  89. package/src/data-display/Card.tsx +112 -112
  90. package/src/data-display/Chip.tsx +74 -74
  91. package/src/data-display/DataTablePro.tsx +534 -534
  92. package/src/data-display/StatCard.tsx +60 -60
  93. package/src/data-display/Table.tsx +74 -74
  94. package/src/data-display/Timeline.tsx +62 -62
  95. package/src/data-display/TreeView.tsx +279 -279
  96. package/src/data-display/tokens.css +4 -4
  97. package/src/feedback/Alert.tsx +80 -80
  98. package/src/feedback/EmptyState.tsx +45 -45
  99. package/src/feedback/Skeleton.tsx +66 -66
  100. package/src/feedback/Spinner.tsx +65 -65
  101. package/src/feedback/index.ts +5 -5
  102. package/src/feedback/tokens.css +4 -4
  103. package/src/forms/Checkbox.tsx +149 -149
  104. package/src/forms/CheckboxGroup.tsx +76 -76
  105. package/src/forms/Combobox.tsx +512 -512
  106. package/src/forms/DatePicker.tsx +784 -784
  107. package/src/forms/FileUpload.tsx +266 -266
  108. package/src/forms/FormField.tsx +115 -115
  109. package/src/forms/Input.tsx +151 -151
  110. package/src/forms/NumberInput.tsx +262 -262
  111. package/src/forms/PasswordInput.tsx +35 -35
  112. package/src/forms/PinInput.tsx +139 -139
  113. package/src/forms/Radio.tsx +142 -142
  114. package/src/forms/RangeSlider.tsx +237 -237
  115. package/src/forms/SearchInput.tsx +34 -34
  116. package/src/forms/Select.tsx +103 -103
  117. package/src/forms/Slider.tsx +85 -85
  118. package/src/forms/Textarea.tsx +114 -114
  119. package/src/forms/tokens.css +6 -6
  120. package/src/glass/GlassButton.tsx +59 -59
  121. package/src/glass/GlassCard.tsx +71 -71
  122. package/src/glass/index.ts +3 -3
  123. package/src/glass/tokens.css +81 -81
  124. package/src/index.ts +26 -26
  125. package/src/layout/AspectRatio.tsx +39 -39
  126. package/src/layout/Container.tsx +53 -53
  127. package/src/layout/Divider.tsx +61 -61
  128. package/src/layout/Flex.tsx +79 -79
  129. package/src/layout/Grid.tsx +124 -124
  130. package/src/layout/PageLayout.tsx +59 -59
  131. package/src/layout/Section.tsx +47 -47
  132. package/src/layout/Spacer.tsx +30 -30
  133. package/src/layout/Stack.tsx +88 -88
  134. package/src/layout/tokens.css +8 -8
  135. package/src/navigation/Breadcrumb.tsx +73 -73
  136. package/src/navigation/CommandPalette.tsx +142 -142
  137. package/src/navigation/DropdownMenu.tsx +244 -244
  138. package/src/navigation/Pagination.tsx +114 -114
  139. package/src/navigation/Stepper.tsx +93 -93
  140. package/src/navigation/Tabs.tsx +265 -265
  141. package/src/navigation/index.ts +6 -6
  142. package/src/navigation/tokens.css +4 -4
  143. package/src/overlays/AlertDialog.tsx +63 -63
  144. package/src/overlays/BottomSheet.tsx +143 -143
  145. package/src/overlays/ContextMenu.tsx +161 -161
  146. package/src/overlays/Drawer.tsx +160 -160
  147. package/src/overlays/FocusTrap.tsx +122 -122
  148. package/src/overlays/Modal.tsx +296 -296
  149. package/src/overlays/Popover.tsx +156 -156
  150. package/src/overlays/Portal.tsx +23 -23
  151. package/src/overlays/Toast.tsx +175 -175
  152. package/src/overlays/Tooltip.tsx +125 -125
  153. package/src/overlays/tokens.css +8 -8
  154. package/src/registry/components.ts +1368 -1368
  155. package/src/styles/components.css +39 -39
  156. package/src/styles/index.css +5 -5
  157. package/src/styles/reset.css +45 -45
  158. package/src/styles/tokens.css +11 -11
  159. package/src/typography/Blockquote.tsx +32 -32
  160. package/src/typography/Code.tsx +89 -89
  161. package/src/typography/Heading.tsx +56 -56
  162. package/src/typography/Kbd.tsx +25 -25
  163. package/src/typography/Label.tsx +47 -47
  164. package/src/typography/Link.tsx +56 -56
  165. package/src/typography/Text.tsx +62 -62
  166. package/src/typography/index.ts +7 -7
  167. package/src/typography/tokens.css +5 -5
  168. package/src/utils/cn.ts +6 -6
  169. package/src/utils/scroll-lock.ts +19 -19
  170. package/src/utils/useControllableState.ts +54 -54
  171. package/ui-contract.json +1 -1
  172. package/dist/chunk-2YJBW4E2.js.map +0 -1
  173. package/dist/chunk-4CRFNALF.js.map +0 -1
  174. package/dist/chunk-4MS6ZHFQ.js.map +0 -1
  175. package/dist/chunk-77KTYOMQ.js.map +0 -1
  176. package/dist/chunk-ADO3SVUA.js.map +0 -1
  177. package/dist/chunk-D7FB3TCA.js.map +0 -1
  178. package/dist/chunk-DSEL7ELZ.js.map +0 -1
  179. package/dist/chunk-EXFR2BFJ.js.map +0 -1
  180. package/dist/chunk-GXWB6BSY.js.map +0 -1
  181. package/dist/chunk-I2FQBMVW.js.map +0 -1
  182. package/dist/chunk-I5O2WCGF.js.map +0 -1
  183. package/dist/chunk-ME4XILG5.js.map +0 -1
  184. package/dist/chunk-PC5SZFZC.js.map +0 -1
  185. package/dist/chunk-S6XJMYME.js.map +0 -1
  186. package/dist/chunk-V52JOSDF.js.map +0 -1
  187. package/dist/chunk-W5WNWTQC.js.map +0 -1
  188. package/dist/chunk-YJ64RI7J.js.map +0 -1
  189. package/dist/chunk-YOCGBORV.js.map +0 -1
  190. package/dist/chunk-ZEDXARXW.js.map +0 -1
@@ -1,534 +1,534 @@
1
- import React, { useState, useMemo, forwardRef, useEffect, useRef } from 'react';
2
- import {
3
- ChevronUp,
4
- ChevronDown,
5
- ChevronsUpDown,
6
- Search,
7
- Download,
8
- Trash2,
9
- ChevronLeft,
10
- ChevronRight,
11
- Filter,
12
- Loader2,
13
- } from 'lucide-react';
14
- import { cn } from '../utils/cn';
15
- import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
16
- import { Button } from '../buttons/Button';
17
- import { Checkbox } from '../forms/Checkbox';
18
-
19
- export interface SortRule {
20
- key: string;
21
- direction: 'asc' | 'desc';
22
- }
23
-
24
- export interface ColumnDef<TData, TValue = any> {
25
- key: string;
26
- header: string | React.ReactNode;
27
- accessor?: (row: TData) => TValue;
28
- render?: (row: TData, index: number, value: TValue) => React.ReactNode;
29
- sortable?: boolean;
30
- sortComparator?: (a: TData, b: TData) => number;
31
- width?: string;
32
- }
33
-
34
- export interface DataTableProProps<TData extends Record<string, any>> {
35
- data: TData[];
36
- columns: ColumnDef<TData>[];
37
- keyExtractor: (row: TData) => string;
38
- searchable?: boolean;
39
- searchPlaceholder?: string;
40
- selectable?: boolean;
41
- selectedKeys?: string[];
42
- defaultSelectedKeys?: string[];
43
- onSelectionChange?: (selectedKeys: string[]) => void;
44
- pageSize?: number;
45
- pageSizeOptions?: number[];
46
- title?: string;
47
- description?: string;
48
- actions?: React.ReactNode;
49
- className?: string;
50
- loading?: boolean;
51
-
52
- // Sorting
53
- multiSort?: boolean;
54
- sorting?: SortRule[] | null;
55
- defaultSorting?: SortRule[];
56
- onSortingChange?: (sorting: SortRule[]) => void;
57
-
58
- // Server-side / Manual controls
59
- manualPagination?: boolean;
60
- manualSorting?: boolean;
61
- manualFiltering?: boolean;
62
- pageIndex?: number;
63
- pageCount?: number;
64
- totalCount?: number;
65
- onPaginationChange?: (page: number, pageSize: number) => void;
66
- searchQuery?: string;
67
- onSearchChange?: (query: string) => void;
68
- onSelectAllDataset?: () => void;
69
- emptyText?: string;
70
- }
71
-
72
- export function DataTablePro<TData extends Record<string, any>>({
73
- data,
74
- columns,
75
- keyExtractor,
76
- searchable = true,
77
- searchPlaceholder = 'Search all records...',
78
- selectable = true,
79
- selectedKeys: controlledSelectedKeys,
80
- defaultSelectedKeys = [],
81
- onSelectionChange,
82
- pageSize: initialPageSize = 10,
83
- pageSizeOptions = [5, 10, 25, 50],
84
- title,
85
- description,
86
- actions,
87
- className,
88
- loading = false,
89
-
90
- // Multi-column sorting
91
- multiSort = true,
92
- sorting: controlledSorting,
93
- defaultSorting = [],
94
- onSortingChange,
95
-
96
- // Server-side / manual mode props
97
- manualPagination = false,
98
- manualSorting = false,
99
- manualFiltering = false,
100
- pageIndex: controlledPageIndex,
101
- pageCount: controlledPageCount,
102
- totalCount: controlledTotalCount,
103
- onPaginationChange,
104
- searchQuery: controlledSearchQuery,
105
- onSearchChange,
106
- onSelectAllDataset,
107
- emptyText = 'No matching records found.',
108
- }: DataTableProProps<TData>) {
109
- // 1. Search state
110
- const [internalSearch, setInternalSearch] = useState('');
111
- const search = controlledSearchQuery !== undefined ? controlledSearchQuery : internalSearch;
112
-
113
- // 2. Multi-column sorting state
114
- const [internalSorting, setInternalSorting] = useState<SortRule[]>(defaultSorting);
115
- const currentSorting = controlledSorting !== undefined ? controlledSorting ?? [] : internalSorting;
116
-
117
- // 3. Pagination state
118
- const [internalCurrentPage, setInternalCurrentPage] = useState(1);
119
- const currentPage = controlledPageIndex !== undefined ? controlledPageIndex : internalCurrentPage;
120
-
121
- const [internalPageSize, setInternalPageSize] = useState(initialPageSize);
122
- const pageSize = internalPageSize;
123
-
124
- // 4. Selection state
125
- const [internalSelectedKeys, setInternalSelectedKeys] = useState<string[]>(defaultSelectedKeys);
126
- const isSelectedControlled = controlledSelectedKeys !== undefined;
127
- const selectedKeys = isSelectedControlled ? controlledSelectedKeys : internalSelectedKeys;
128
-
129
- const handleSelectionUpdate = (nextKeys: string[]) => {
130
- if (!isSelectedControlled) {
131
- setInternalSelectedKeys(nextKeys);
132
- }
133
- onSelectionChange?.(nextKeys);
134
- };
135
-
136
- // Search filtering (Client-side when not manual)
137
- const filteredData = useMemo(() => {
138
- if (manualFiltering || !search.trim()) return data;
139
- const q = search.toLowerCase();
140
-
141
- return data.filter((row) => {
142
- return columns.some((col) => {
143
- const val = col.accessor ? col.accessor(row) : row[col.key];
144
- if (val === null || val === undefined) return false;
145
- return String(val).toLowerCase().includes(q);
146
- });
147
- });
148
- }, [data, search, columns, manualFiltering]);
149
-
150
- // True Multi-Column Sorting (Client-side when not manual)
151
- const sortedData = useMemo(() => {
152
- if (manualSorting || currentSorting.length === 0) return filteredData;
153
-
154
- return [...filteredData].sort((a, b) => {
155
- for (const { key, direction } of currentSorting) {
156
- const col = columns.find((c) => c.key === key);
157
- let comp = 0;
158
-
159
- if (col?.sortComparator) {
160
- comp = col.sortComparator(a, b);
161
- } else {
162
- const valA = col?.accessor ? col.accessor(a) : a[key];
163
- const valB = col?.accessor ? col.accessor(b) : b[key];
164
-
165
- if (valA === valB) continue;
166
- if (valA === null || valA === undefined) return 1;
167
- if (valB === null || valB === undefined) return -1;
168
-
169
- comp = valA < valB ? -1 : 1;
170
- }
171
-
172
- if (comp !== 0) {
173
- return direction === 'asc' ? comp : -comp;
174
- }
175
- }
176
- return 0;
177
- });
178
- }, [filteredData, currentSorting, columns, manualSorting]);
179
-
180
- // Pagination calculations
181
- const totalItems = controlledTotalCount !== undefined ? controlledTotalCount : sortedData.length;
182
- const totalPages = controlledPageCount !== undefined
183
- ? Math.max(1, controlledPageCount)
184
- : Math.max(1, Math.ceil(totalItems / pageSize));
185
-
186
- // Reset/clamp page if out of range on client-side filter
187
- useEffect(() => {
188
- if (!manualPagination && currentPage > totalPages && totalPages > 0) {
189
- setInternalCurrentPage(totalPages);
190
- onPaginationChange?.(totalPages, pageSize);
191
- }
192
- }, [totalPages, currentPage, manualPagination, pageSize, onPaginationChange]);
193
-
194
- const paginatedData = useMemo(() => {
195
- if (manualPagination) return data;
196
- const start = (currentPage - 1) * pageSize;
197
- return sortedData.slice(start, start + pageSize);
198
- }, [data, sortedData, currentPage, pageSize, manualPagination]);
199
-
200
- // Sorting handler supporting Shift+Click for multi-sort
201
- const handleSort = (key: string, e?: React.MouseEvent) => {
202
- const isShift = e?.shiftKey || false;
203
- let nextSorting: SortRule[] = [...currentSorting];
204
- const existingIdx = nextSorting.findIndex((s) => s.key === key);
205
-
206
- if (isShift && multiSort) {
207
- if (existingIdx >= 0) {
208
- if (nextSorting[existingIdx].direction === 'asc') {
209
- nextSorting[existingIdx] = { key, direction: 'desc' };
210
- } else {
211
- nextSorting.splice(existingIdx, 1);
212
- }
213
- } else {
214
- nextSorting.push({ key, direction: 'asc' });
215
- }
216
- } else {
217
- if (existingIdx >= 0 && nextSorting.length === 1) {
218
- if (nextSorting[0].direction === 'asc') {
219
- nextSorting = [{ key, direction: 'desc' }];
220
- } else {
221
- nextSorting = [];
222
- }
223
- } else {
224
- nextSorting = [{ key, direction: 'asc' }];
225
- }
226
- }
227
-
228
- if (controlledSorting === undefined) {
229
- setInternalSorting(nextSorting);
230
- }
231
- onSortingChange?.(nextSorting);
232
- };
233
-
234
- const handlePageChange = (newPage: number) => {
235
- const clamped = Math.max(1, Math.min(totalPages, newPage));
236
- if (controlledPageIndex === undefined) {
237
- setInternalCurrentPage(clamped);
238
- }
239
- onPaginationChange?.(clamped, pageSize);
240
- };
241
-
242
- const handlePageSizeChange = (newSize: number) => {
243
- setInternalPageSize(newSize);
244
- if (controlledPageIndex === undefined) {
245
- setInternalCurrentPage(1);
246
- }
247
- onPaginationChange?.(1, newSize);
248
- };
249
-
250
- const handleSearchChange = (query: string) => {
251
- if (controlledSearchQuery === undefined) {
252
- setInternalSearch(query);
253
- if (controlledPageIndex === undefined) {
254
- setInternalCurrentPage(1);
255
- }
256
- }
257
- onSearchChange?.(query);
258
- };
259
-
260
- // Cross-page selection logic
261
- const currentPageKeys = useMemo(() => paginatedData.map(keyExtractor), [paginatedData, keyExtractor]);
262
- const isAllCurrentPageSelected =
263
- currentPageKeys.length > 0 && currentPageKeys.every((k) => selectedKeys.includes(k));
264
- const isSomeCurrentPageSelected =
265
- currentPageKeys.some((k) => selectedKeys.includes(k)) && !isAllCurrentPageSelected;
266
-
267
- const toggleSelectAllCurrentPage = () => {
268
- if (isAllCurrentPageSelected) {
269
- const next = selectedKeys.filter((k) => !currentPageKeys.includes(k));
270
- handleSelectionUpdate(next);
271
- } else {
272
- const combined = Array.from(new Set([...selectedKeys, ...currentPageKeys]));
273
- handleSelectionUpdate(combined);
274
- }
275
- };
276
-
277
- const toggleSelectRow = (key: string) => {
278
- const next = selectedKeys.includes(key)
279
- ? selectedKeys.filter((k) => k !== key)
280
- : [...selectedKeys, key];
281
- handleSelectionUpdate(next);
282
- };
283
-
284
- const canSelectAllDataset = !manualPagination || typeof onSelectAllDataset === 'function';
285
-
286
- const selectAllDataset = () => {
287
- if (manualPagination) {
288
- onSelectAllDataset?.();
289
- return;
290
- }
291
- const allKeys = sortedData.map(keyExtractor);
292
- handleSelectionUpdate(allKeys);
293
- };
294
-
295
- const clearSelection = () => {
296
- handleSelectionUpdate([]);
297
- };
298
-
299
- const exportCSV = () => {
300
- const exportRows = manualPagination ? data : sortedData;
301
- const headers = columns.map((c) => (typeof c.header === 'string' ? c.header : c.key));
302
- const rows = exportRows.map((row) =>
303
- columns.map((c) => {
304
- const val = c.accessor ? c.accessor(row) : row[c.key];
305
- return `"${String(val ?? '').replace(/"/g, '""')}"`;
306
- }).join(',')
307
- );
308
- const csvContent = 'data:text/csv;charset=utf-8,' + [headers.join(','), ...rows].join('\n');
309
- const encodedUri = encodeURI(csvContent);
310
- const link = document.createElement('a');
311
- link.setAttribute('href', encodedUri);
312
- link.setAttribute('download', `export_${Date.now()}.csv`);
313
- document.body.appendChild(link);
314
- link.click();
315
- document.body.removeChild(link);
316
- };
317
-
318
- return (
319
- <div className={cn('w-full space-y-4', className)}>
320
- {/* Header Toolbar */}
321
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
322
- {(title || description) && (
323
- <div>
324
- {title && <h3 className="text-base font-bold tracking-tight text-[var(--ui-text-primary)]">{title}</h3>}
325
- {description && <p className="text-xs text-[var(--ui-text-muted)]">{description}</p>}
326
- </div>
327
- )}
328
-
329
- <div className="flex flex-wrap items-center gap-2.5">
330
- {searchable && (
331
- <div className="relative w-full sm:w-64">
332
- <Search className="absolute left-3 w-3.5 h-3.5 text-[var(--ui-text-muted)] pointer-events-none" />
333
- <input
334
- type="text"
335
- placeholder={searchPlaceholder}
336
- value={search}
337
- onChange={(e) => handleSearchChange(e.target.value)}
338
- className="w-full h-8 pl-8 pr-3 text-xs rounded-xl bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] focus:outline-none focus:ring-2 focus:ring-[var(--ui-primary)]/40 text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] transition-all shadow-inner"
339
- />
340
- </div>
341
- )}
342
-
343
- <Button size="xs" variant="outline" leftIcon={<Download className="w-3 h-3" />} onClick={exportCSV}>
344
- Export CSV
345
- </Button>
346
-
347
- {actions}
348
- </div>
349
- </div>
350
-
351
- {/* Bulk selection banner */}
352
- {selectedKeys.length > 0 && (
353
- <div className="px-4 py-2 rounded-xl bg-[var(--ui-primary)]/15 border border-[var(--ui-primary)]/30 flex flex-wrap items-center justify-between gap-2 animate-in fade-in duration-200">
354
- <div className="flex items-center gap-2 text-xs font-semibold text-[var(--ui-primary)]">
355
- <span>Selected {selectedKeys.length} records</span>
356
- {canSelectAllDataset && selectedKeys.length < totalItems && (
357
- <button
358
- type="button"
359
- onClick={selectAllDataset}
360
- className="underline hover:text-[var(--ui-primary)]/80 cursor-pointer font-bold ml-1"
361
- >
362
- Select all {totalItems} records
363
- </button>
364
- )}
365
- </div>
366
- <Button
367
- size="xs"
368
- variant="ghost"
369
- className="text-xs text-rose-500 hover:text-rose-600 hover:bg-rose-500/10"
370
- onClick={clearSelection}
371
- >
372
- Clear Selection
373
- </Button>
374
- </div>
375
- )}
376
-
377
- {/* Table Container */}
378
- <div className="relative w-full overflow-x-auto rounded-2xl border border-[var(--ui-border-default)] bg-[var(--ui-bg-surface)] shadow-sm">
379
- {loading && (
380
- <div className="absolute inset-0 z-20 bg-[var(--ui-bg-surface)]/60 backdrop-blur-xs flex items-center justify-center">
381
- <Loader2 className="w-6 h-6 animate-spin text-[var(--ui-primary)]" />
382
- </div>
383
- )}
384
-
385
- <Table>
386
- <TableHeader>
387
- <TableRow>
388
- {selectable && (
389
- <TableHead className="w-10 px-4">
390
- <Checkbox
391
- checked={isAllCurrentPageSelected}
392
- indeterminate={isSomeCurrentPageSelected}
393
- onCheckedChange={toggleSelectAllCurrentPage}
394
- aria-label="Select all on page"
395
- />
396
- </TableHead>
397
- )}
398
-
399
- {columns.map((col) => {
400
- const sortIndex = currentSorting.findIndex((s) => s.key === col.key);
401
- const sortRule = sortIndex >= 0 ? currentSorting[sortIndex] : null;
402
-
403
- return (
404
- <TableHead
405
- key={col.key}
406
- style={{ width: col.width }}
407
- onClick={(e) => col.sortable !== false && handleSort(col.key, e)}
408
- className={cn(
409
- col.sortable !== false &&
410
- 'cursor-pointer select-none hover:text-[var(--ui-text-primary)] transition-colors'
411
- )}
412
- >
413
- <div className="flex items-center gap-1.5">
414
- <span>{col.header}</span>
415
- {col.sortable !== false && (
416
- <div className="flex items-center gap-0.5 text-[var(--ui-text-muted)]">
417
- {sortRule ? (
418
- sortRule.direction === 'asc' ? (
419
- <ChevronUp className="w-3.5 h-3.5 text-[var(--ui-primary)] font-bold" />
420
- ) : (
421
- <ChevronDown className="w-3.5 h-3.5 text-[var(--ui-primary)] font-bold" />
422
- )
423
- ) : (
424
- <ChevronsUpDown className="w-3 h-3 opacity-40" />
425
- )}
426
- {currentSorting.length > 1 && sortIndex >= 0 && (
427
- <span className="text-[10px] font-mono font-bold text-[var(--ui-primary)]">
428
- {sortIndex + 1}
429
- </span>
430
- )}
431
- </div>
432
- )}
433
- </div>
434
- </TableHead>
435
- );
436
- })}
437
- </TableRow>
438
- </TableHeader>
439
-
440
- <TableBody>
441
- {paginatedData.length === 0 ? (
442
- <TableRow>
443
- <TableCell
444
- colSpan={columns.length + (selectable ? 1 : 0)}
445
- className="h-32 text-center text-xs text-[var(--ui-text-muted)]"
446
- >
447
- {emptyText}
448
- </TableCell>
449
- </TableRow>
450
- ) : (
451
- paginatedData.map((row, rowIdx) => {
452
- const key = keyExtractor(row);
453
- const isSelected = selectedKeys.includes(key);
454
-
455
- return (
456
- <TableRow
457
- key={key}
458
- data-state={isSelected ? 'selected' : undefined}
459
- className={cn(isSelected && 'bg-[var(--ui-primary)]/10')}
460
- >
461
- {selectable && (
462
- <TableCell className="w-10 px-4">
463
- <Checkbox
464
- checked={isSelected}
465
- onCheckedChange={() => toggleSelectRow(key)}
466
- aria-label={`Select row ${key}`}
467
- />
468
- </TableCell>
469
- )}
470
-
471
- {columns.map((col) => {
472
- const val = col.accessor ? col.accessor(row) : row[col.key];
473
- const cellContent = col.render ? col.render(row, rowIdx, val) : val;
474
-
475
- return <TableCell key={col.key}>{cellContent}</TableCell>;
476
- })}
477
- </TableRow>
478
- );
479
- })
480
- )}
481
- </TableBody>
482
- </Table>
483
- </div>
484
-
485
- {/* Pagination Footer */}
486
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-1 text-xs text-[var(--ui-text-muted)] font-mono">
487
- <div className="flex items-center gap-2">
488
- <span>Rows per page:</span>
489
- <select
490
- value={pageSize}
491
- onChange={(e) => handlePageSizeChange(Number(e.target.value))}
492
- className="px-2 py-1 rounded-lg bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] text-[var(--ui-text-primary)] font-mono text-xs focus:outline-none"
493
- >
494
- {pageSizeOptions.map((opt) => (
495
- <option key={opt} value={opt}>
496
- {opt}
497
- </option>
498
- ))}
499
- </select>
500
- <span className="text-[var(--ui-text-muted)]">
501
- Showing {totalItems === 0 ? 0 : Math.min((currentPage - 1) * pageSize + 1, totalItems)}–
502
- {Math.min(currentPage * pageSize, totalItems)} of {totalItems}
503
- </span>
504
- </div>
505
-
506
- <div className="flex items-center gap-1.5">
507
- <Button
508
- size="xs"
509
- variant="outline"
510
- disabled={currentPage <= 1 || loading}
511
- onClick={() => handlePageChange(currentPage - 1)}
512
- aria-label="Previous page"
513
- >
514
- <ChevronLeft className="w-3.5 h-3.5" />
515
- </Button>
516
-
517
- <span className="px-2 font-bold text-[var(--ui-text-primary)]">
518
- {currentPage} / {totalPages}
519
- </span>
520
-
521
- <Button
522
- size="xs"
523
- variant="outline"
524
- disabled={currentPage >= totalPages || loading}
525
- onClick={() => handlePageChange(currentPage + 1)}
526
- aria-label="Next page"
527
- >
528
- <ChevronRight className="w-3.5 h-3.5" />
529
- </Button>
530
- </div>
531
- </div>
532
- </div>
533
- );
534
- }
1
+ import React, { useState, useMemo, forwardRef, useEffect, useRef } from 'react';
2
+ import {
3
+ ChevronUp,
4
+ ChevronDown,
5
+ ChevronsUpDown,
6
+ Search,
7
+ Download,
8
+ Trash2,
9
+ ChevronLeft,
10
+ ChevronRight,
11
+ Filter,
12
+ Loader2,
13
+ } from 'lucide-react';
14
+ import { cn } from '../utils/cn';
15
+ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
16
+ import { Button } from '../buttons/Button';
17
+ import { Checkbox } from '../forms/Checkbox';
18
+
19
+ export interface SortRule {
20
+ key: string;
21
+ direction: 'asc' | 'desc';
22
+ }
23
+
24
+ export interface ColumnDef<TData, TValue = any> {
25
+ key: string;
26
+ header: string | React.ReactNode;
27
+ accessor?: (row: TData) => TValue;
28
+ render?: (row: TData, index: number, value: TValue) => React.ReactNode;
29
+ sortable?: boolean;
30
+ sortComparator?: (a: TData, b: TData) => number;
31
+ width?: string;
32
+ }
33
+
34
+ export interface DataTableProProps<TData extends Record<string, any>> {
35
+ data: TData[];
36
+ columns: ColumnDef<TData>[];
37
+ keyExtractor: (row: TData) => string;
38
+ searchable?: boolean;
39
+ searchPlaceholder?: string;
40
+ selectable?: boolean;
41
+ selectedKeys?: string[];
42
+ defaultSelectedKeys?: string[];
43
+ onSelectionChange?: (selectedKeys: string[]) => void;
44
+ pageSize?: number;
45
+ pageSizeOptions?: number[];
46
+ title?: string;
47
+ description?: string;
48
+ actions?: React.ReactNode;
49
+ className?: string;
50
+ loading?: boolean;
51
+
52
+ // Sorting
53
+ multiSort?: boolean;
54
+ sorting?: SortRule[] | null;
55
+ defaultSorting?: SortRule[];
56
+ onSortingChange?: (sorting: SortRule[]) => void;
57
+
58
+ // Server-side / Manual controls
59
+ manualPagination?: boolean;
60
+ manualSorting?: boolean;
61
+ manualFiltering?: boolean;
62
+ pageIndex?: number;
63
+ pageCount?: number;
64
+ totalCount?: number;
65
+ onPaginationChange?: (page: number, pageSize: number) => void;
66
+ searchQuery?: string;
67
+ onSearchChange?: (query: string) => void;
68
+ onSelectAllDataset?: () => void;
69
+ emptyText?: string;
70
+ }
71
+
72
+ export function DataTablePro<TData extends Record<string, any>>({
73
+ data,
74
+ columns,
75
+ keyExtractor,
76
+ searchable = true,
77
+ searchPlaceholder = 'Search all records...',
78
+ selectable = true,
79
+ selectedKeys: controlledSelectedKeys,
80
+ defaultSelectedKeys = [],
81
+ onSelectionChange,
82
+ pageSize: initialPageSize = 10,
83
+ pageSizeOptions = [5, 10, 25, 50],
84
+ title,
85
+ description,
86
+ actions,
87
+ className,
88
+ loading = false,
89
+
90
+ // Multi-column sorting
91
+ multiSort = true,
92
+ sorting: controlledSorting,
93
+ defaultSorting = [],
94
+ onSortingChange,
95
+
96
+ // Server-side / manual mode props
97
+ manualPagination = false,
98
+ manualSorting = false,
99
+ manualFiltering = false,
100
+ pageIndex: controlledPageIndex,
101
+ pageCount: controlledPageCount,
102
+ totalCount: controlledTotalCount,
103
+ onPaginationChange,
104
+ searchQuery: controlledSearchQuery,
105
+ onSearchChange,
106
+ onSelectAllDataset,
107
+ emptyText = 'No matching records found.',
108
+ }: DataTableProProps<TData>) {
109
+ // 1. Search state
110
+ const [internalSearch, setInternalSearch] = useState('');
111
+ const search = controlledSearchQuery !== undefined ? controlledSearchQuery : internalSearch;
112
+
113
+ // 2. Multi-column sorting state
114
+ const [internalSorting, setInternalSorting] = useState<SortRule[]>(defaultSorting);
115
+ const currentSorting = controlledSorting !== undefined ? controlledSorting ?? [] : internalSorting;
116
+
117
+ // 3. Pagination state
118
+ const [internalCurrentPage, setInternalCurrentPage] = useState(1);
119
+ const currentPage = controlledPageIndex !== undefined ? controlledPageIndex : internalCurrentPage;
120
+
121
+ const [internalPageSize, setInternalPageSize] = useState(initialPageSize);
122
+ const pageSize = internalPageSize;
123
+
124
+ // 4. Selection state
125
+ const [internalSelectedKeys, setInternalSelectedKeys] = useState<string[]>(defaultSelectedKeys);
126
+ const isSelectedControlled = controlledSelectedKeys !== undefined;
127
+ const selectedKeys = isSelectedControlled ? controlledSelectedKeys : internalSelectedKeys;
128
+
129
+ const handleSelectionUpdate = (nextKeys: string[]) => {
130
+ if (!isSelectedControlled) {
131
+ setInternalSelectedKeys(nextKeys);
132
+ }
133
+ onSelectionChange?.(nextKeys);
134
+ };
135
+
136
+ // Search filtering (Client-side when not manual)
137
+ const filteredData = useMemo(() => {
138
+ if (manualFiltering || !search.trim()) return data;
139
+ const q = search.toLowerCase();
140
+
141
+ return data.filter((row) => {
142
+ return columns.some((col) => {
143
+ const val = col.accessor ? col.accessor(row) : row[col.key];
144
+ if (val === null || val === undefined) return false;
145
+ return String(val).toLowerCase().includes(q);
146
+ });
147
+ });
148
+ }, [data, search, columns, manualFiltering]);
149
+
150
+ // True Multi-Column Sorting (Client-side when not manual)
151
+ const sortedData = useMemo(() => {
152
+ if (manualSorting || currentSorting.length === 0) return filteredData;
153
+
154
+ return [...filteredData].sort((a, b) => {
155
+ for (const { key, direction } of currentSorting) {
156
+ const col = columns.find((c) => c.key === key);
157
+ let comp = 0;
158
+
159
+ if (col?.sortComparator) {
160
+ comp = col.sortComparator(a, b);
161
+ } else {
162
+ const valA = col?.accessor ? col.accessor(a) : a[key];
163
+ const valB = col?.accessor ? col.accessor(b) : b[key];
164
+
165
+ if (valA === valB) continue;
166
+ if (valA === null || valA === undefined) return 1;
167
+ if (valB === null || valB === undefined) return -1;
168
+
169
+ comp = valA < valB ? -1 : 1;
170
+ }
171
+
172
+ if (comp !== 0) {
173
+ return direction === 'asc' ? comp : -comp;
174
+ }
175
+ }
176
+ return 0;
177
+ });
178
+ }, [filteredData, currentSorting, columns, manualSorting]);
179
+
180
+ // Pagination calculations
181
+ const totalItems = controlledTotalCount !== undefined ? controlledTotalCount : sortedData.length;
182
+ const totalPages = controlledPageCount !== undefined
183
+ ? Math.max(1, controlledPageCount)
184
+ : Math.max(1, Math.ceil(totalItems / pageSize));
185
+
186
+ // Reset/clamp page if out of range on client-side filter
187
+ useEffect(() => {
188
+ if (!manualPagination && currentPage > totalPages && totalPages > 0) {
189
+ setInternalCurrentPage(totalPages);
190
+ onPaginationChange?.(totalPages, pageSize);
191
+ }
192
+ }, [totalPages, currentPage, manualPagination, pageSize, onPaginationChange]);
193
+
194
+ const paginatedData = useMemo(() => {
195
+ if (manualPagination) return data;
196
+ const start = (currentPage - 1) * pageSize;
197
+ return sortedData.slice(start, start + pageSize);
198
+ }, [data, sortedData, currentPage, pageSize, manualPagination]);
199
+
200
+ // Sorting handler supporting Shift+Click for multi-sort
201
+ const handleSort = (key: string, e?: React.MouseEvent) => {
202
+ const isShift = e?.shiftKey || false;
203
+ let nextSorting: SortRule[] = [...currentSorting];
204
+ const existingIdx = nextSorting.findIndex((s) => s.key === key);
205
+
206
+ if (isShift && multiSort) {
207
+ if (existingIdx >= 0) {
208
+ if (nextSorting[existingIdx].direction === 'asc') {
209
+ nextSorting[existingIdx] = { key, direction: 'desc' };
210
+ } else {
211
+ nextSorting.splice(existingIdx, 1);
212
+ }
213
+ } else {
214
+ nextSorting.push({ key, direction: 'asc' });
215
+ }
216
+ } else {
217
+ if (existingIdx >= 0 && nextSorting.length === 1) {
218
+ if (nextSorting[0].direction === 'asc') {
219
+ nextSorting = [{ key, direction: 'desc' }];
220
+ } else {
221
+ nextSorting = [];
222
+ }
223
+ } else {
224
+ nextSorting = [{ key, direction: 'asc' }];
225
+ }
226
+ }
227
+
228
+ if (controlledSorting === undefined) {
229
+ setInternalSorting(nextSorting);
230
+ }
231
+ onSortingChange?.(nextSorting);
232
+ };
233
+
234
+ const handlePageChange = (newPage: number) => {
235
+ const clamped = Math.max(1, Math.min(totalPages, newPage));
236
+ if (controlledPageIndex === undefined) {
237
+ setInternalCurrentPage(clamped);
238
+ }
239
+ onPaginationChange?.(clamped, pageSize);
240
+ };
241
+
242
+ const handlePageSizeChange = (newSize: number) => {
243
+ setInternalPageSize(newSize);
244
+ if (controlledPageIndex === undefined) {
245
+ setInternalCurrentPage(1);
246
+ }
247
+ onPaginationChange?.(1, newSize);
248
+ };
249
+
250
+ const handleSearchChange = (query: string) => {
251
+ if (controlledSearchQuery === undefined) {
252
+ setInternalSearch(query);
253
+ if (controlledPageIndex === undefined) {
254
+ setInternalCurrentPage(1);
255
+ }
256
+ }
257
+ onSearchChange?.(query);
258
+ };
259
+
260
+ // Cross-page selection logic
261
+ const currentPageKeys = useMemo(() => paginatedData.map(keyExtractor), [paginatedData, keyExtractor]);
262
+ const isAllCurrentPageSelected =
263
+ currentPageKeys.length > 0 && currentPageKeys.every((k) => selectedKeys.includes(k));
264
+ const isSomeCurrentPageSelected =
265
+ currentPageKeys.some((k) => selectedKeys.includes(k)) && !isAllCurrentPageSelected;
266
+
267
+ const toggleSelectAllCurrentPage = () => {
268
+ if (isAllCurrentPageSelected) {
269
+ const next = selectedKeys.filter((k) => !currentPageKeys.includes(k));
270
+ handleSelectionUpdate(next);
271
+ } else {
272
+ const combined = Array.from(new Set([...selectedKeys, ...currentPageKeys]));
273
+ handleSelectionUpdate(combined);
274
+ }
275
+ };
276
+
277
+ const toggleSelectRow = (key: string) => {
278
+ const next = selectedKeys.includes(key)
279
+ ? selectedKeys.filter((k) => k !== key)
280
+ : [...selectedKeys, key];
281
+ handleSelectionUpdate(next);
282
+ };
283
+
284
+ const canSelectAllDataset = !manualPagination || typeof onSelectAllDataset === 'function';
285
+
286
+ const selectAllDataset = () => {
287
+ if (manualPagination) {
288
+ onSelectAllDataset?.();
289
+ return;
290
+ }
291
+ const allKeys = sortedData.map(keyExtractor);
292
+ handleSelectionUpdate(allKeys);
293
+ };
294
+
295
+ const clearSelection = () => {
296
+ handleSelectionUpdate([]);
297
+ };
298
+
299
+ const exportCSV = () => {
300
+ const exportRows = manualPagination ? data : sortedData;
301
+ const headers = columns.map((c) => (typeof c.header === 'string' ? c.header : c.key));
302
+ const rows = exportRows.map((row) =>
303
+ columns.map((c) => {
304
+ const val = c.accessor ? c.accessor(row) : row[c.key];
305
+ return `"${String(val ?? '').replace(/"/g, '""')}"`;
306
+ }).join(',')
307
+ );
308
+ const csvContent = 'data:text/csv;charset=utf-8,' + [headers.join(','), ...rows].join('\n');
309
+ const encodedUri = encodeURI(csvContent);
310
+ const link = document.createElement('a');
311
+ link.setAttribute('href', encodedUri);
312
+ link.setAttribute('download', `export_${Date.now()}.csv`);
313
+ document.body.appendChild(link);
314
+ link.click();
315
+ document.body.removeChild(link);
316
+ };
317
+
318
+ return (
319
+ <div className={cn('w-full space-y-4', className)}>
320
+ {/* Header Toolbar */}
321
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
322
+ {(title || description) && (
323
+ <div>
324
+ {title && <h3 className="text-base font-bold tracking-tight text-[var(--ui-text-primary)]">{title}</h3>}
325
+ {description && <p className="text-xs text-[var(--ui-text-muted)]">{description}</p>}
326
+ </div>
327
+ )}
328
+
329
+ <div className="flex flex-wrap items-center gap-2.5">
330
+ {searchable && (
331
+ <div className="relative w-full sm:w-64">
332
+ <Search className="absolute left-3 w-3.5 h-3.5 text-[var(--ui-text-muted)] pointer-events-none" />
333
+ <input
334
+ type="text"
335
+ placeholder={searchPlaceholder}
336
+ value={search}
337
+ onChange={(e) => handleSearchChange(e.target.value)}
338
+ className="w-full h-8 pl-8 pr-3 text-xs rounded-xl bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] focus:outline-none focus:ring-2 focus:ring-[var(--ui-primary)]/40 text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)] transition-all shadow-inner"
339
+ />
340
+ </div>
341
+ )}
342
+
343
+ <Button size="xs" variant="outline" leftIcon={<Download className="w-3 h-3" />} onClick={exportCSV}>
344
+ Export CSV
345
+ </Button>
346
+
347
+ {actions}
348
+ </div>
349
+ </div>
350
+
351
+ {/* Bulk selection banner */}
352
+ {selectedKeys.length > 0 && (
353
+ <div className="px-4 py-2 rounded-xl bg-[var(--ui-primary)]/15 border border-[var(--ui-primary)]/30 flex flex-wrap items-center justify-between gap-2 animate-in fade-in duration-200">
354
+ <div className="flex items-center gap-2 text-xs font-semibold text-[var(--ui-primary)]">
355
+ <span>Selected {selectedKeys.length} records</span>
356
+ {canSelectAllDataset && selectedKeys.length < totalItems && (
357
+ <button
358
+ type="button"
359
+ onClick={selectAllDataset}
360
+ className="underline hover:text-[var(--ui-primary)]/80 cursor-pointer font-bold ml-1"
361
+ >
362
+ Select all {totalItems} records
363
+ </button>
364
+ )}
365
+ </div>
366
+ <Button
367
+ size="xs"
368
+ variant="ghost"
369
+ className="text-xs text-rose-500 hover:text-rose-600 hover:bg-rose-500/10"
370
+ onClick={clearSelection}
371
+ >
372
+ Clear Selection
373
+ </Button>
374
+ </div>
375
+ )}
376
+
377
+ {/* Table Container */}
378
+ <div className="relative w-full overflow-x-auto rounded-2xl border border-[var(--ui-border-default)] bg-[var(--ui-bg-surface)] shadow-sm">
379
+ {loading && (
380
+ <div className="absolute inset-0 z-20 bg-[var(--ui-bg-surface)]/60 backdrop-blur-xs flex items-center justify-center">
381
+ <Loader2 className="w-6 h-6 animate-spin text-[var(--ui-primary)]" />
382
+ </div>
383
+ )}
384
+
385
+ <Table>
386
+ <TableHeader>
387
+ <TableRow>
388
+ {selectable && (
389
+ <TableHead className="w-10 px-4">
390
+ <Checkbox
391
+ checked={isAllCurrentPageSelected}
392
+ indeterminate={isSomeCurrentPageSelected}
393
+ onCheckedChange={toggleSelectAllCurrentPage}
394
+ aria-label="Select all on page"
395
+ />
396
+ </TableHead>
397
+ )}
398
+
399
+ {columns.map((col) => {
400
+ const sortIndex = currentSorting.findIndex((s) => s.key === col.key);
401
+ const sortRule = sortIndex >= 0 ? currentSorting[sortIndex] : null;
402
+
403
+ return (
404
+ <TableHead
405
+ key={col.key}
406
+ style={{ width: col.width }}
407
+ onClick={(e) => col.sortable !== false && handleSort(col.key, e)}
408
+ className={cn(
409
+ col.sortable !== false &&
410
+ 'cursor-pointer select-none hover:text-[var(--ui-text-primary)] transition-colors'
411
+ )}
412
+ >
413
+ <div className="flex items-center gap-1.5">
414
+ <span>{col.header}</span>
415
+ {col.sortable !== false && (
416
+ <div className="flex items-center gap-0.5 text-[var(--ui-text-muted)]">
417
+ {sortRule ? (
418
+ sortRule.direction === 'asc' ? (
419
+ <ChevronUp className="w-3.5 h-3.5 text-[var(--ui-primary)] font-bold" />
420
+ ) : (
421
+ <ChevronDown className="w-3.5 h-3.5 text-[var(--ui-primary)] font-bold" />
422
+ )
423
+ ) : (
424
+ <ChevronsUpDown className="w-3 h-3 opacity-40" />
425
+ )}
426
+ {currentSorting.length > 1 && sortIndex >= 0 && (
427
+ <span className="text-[10px] font-mono font-bold text-[var(--ui-primary)]">
428
+ {sortIndex + 1}
429
+ </span>
430
+ )}
431
+ </div>
432
+ )}
433
+ </div>
434
+ </TableHead>
435
+ );
436
+ })}
437
+ </TableRow>
438
+ </TableHeader>
439
+
440
+ <TableBody>
441
+ {paginatedData.length === 0 ? (
442
+ <TableRow>
443
+ <TableCell
444
+ colSpan={columns.length + (selectable ? 1 : 0)}
445
+ className="h-32 text-center text-xs text-[var(--ui-text-muted)]"
446
+ >
447
+ {emptyText}
448
+ </TableCell>
449
+ </TableRow>
450
+ ) : (
451
+ paginatedData.map((row, rowIdx) => {
452
+ const key = keyExtractor(row);
453
+ const isSelected = selectedKeys.includes(key);
454
+
455
+ return (
456
+ <TableRow
457
+ key={key}
458
+ data-state={isSelected ? 'selected' : undefined}
459
+ className={cn(isSelected && 'bg-[var(--ui-primary)]/10')}
460
+ >
461
+ {selectable && (
462
+ <TableCell className="w-10 px-4">
463
+ <Checkbox
464
+ checked={isSelected}
465
+ onCheckedChange={() => toggleSelectRow(key)}
466
+ aria-label={`Select row ${key}`}
467
+ />
468
+ </TableCell>
469
+ )}
470
+
471
+ {columns.map((col) => {
472
+ const val = col.accessor ? col.accessor(row) : row[col.key];
473
+ const cellContent = col.render ? col.render(row, rowIdx, val) : val;
474
+
475
+ return <TableCell key={col.key}>{cellContent}</TableCell>;
476
+ })}
477
+ </TableRow>
478
+ );
479
+ })
480
+ )}
481
+ </TableBody>
482
+ </Table>
483
+ </div>
484
+
485
+ {/* Pagination Footer */}
486
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-1 text-xs text-[var(--ui-text-muted)] font-mono">
487
+ <div className="flex items-center gap-2">
488
+ <span>Rows per page:</span>
489
+ <select
490
+ value={pageSize}
491
+ onChange={(e) => handlePageSizeChange(Number(e.target.value))}
492
+ className="px-2 py-1 rounded-lg bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] text-[var(--ui-text-primary)] font-mono text-xs focus:outline-none"
493
+ >
494
+ {pageSizeOptions.map((opt) => (
495
+ <option key={opt} value={opt}>
496
+ {opt}
497
+ </option>
498
+ ))}
499
+ </select>
500
+ <span className="text-[var(--ui-text-muted)]">
501
+ Showing {totalItems === 0 ? 0 : Math.min((currentPage - 1) * pageSize + 1, totalItems)}–
502
+ {Math.min(currentPage * pageSize, totalItems)} of {totalItems}
503
+ </span>
504
+ </div>
505
+
506
+ <div className="flex items-center gap-1.5">
507
+ <Button
508
+ size="xs"
509
+ variant="outline"
510
+ disabled={currentPage <= 1 || loading}
511
+ onClick={() => handlePageChange(currentPage - 1)}
512
+ aria-label="Previous page"
513
+ >
514
+ <ChevronLeft className="w-3.5 h-3.5" />
515
+ </Button>
516
+
517
+ <span className="px-2 font-bold text-[var(--ui-text-primary)]">
518
+ {currentPage} / {totalPages}
519
+ </span>
520
+
521
+ <Button
522
+ size="xs"
523
+ variant="outline"
524
+ disabled={currentPage >= totalPages || loading}
525
+ onClick={() => handlePageChange(currentPage + 1)}
526
+ aria-label="Next page"
527
+ >
528
+ <ChevronRight className="w-3.5 h-3.5" />
529
+ </Button>
530
+ </div>
531
+ </div>
532
+ </div>
533
+ );
534
+ }