@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.
- package/LICENSE +21 -21
- package/README.md +144 -142
- package/bin/cli.js +125 -0
- package/dist/bento/index.cjs.map +1 -1
- package/dist/bento/index.js +2 -2
- package/dist/buttons/index.cjs.map +1 -1
- package/dist/buttons/index.js +3 -3
- package/dist/{chunk-77KTYOMQ.js → chunk-2OXZK6IP.js} +3 -3
- package/dist/chunk-2OXZK6IP.js.map +1 -0
- package/dist/{chunk-4CRFNALF.js → chunk-4GNFT2ZJ.js} +7 -7
- package/dist/chunk-4GNFT2ZJ.js.map +1 -0
- package/dist/{chunk-2YJBW4E2.js → chunk-4S6L5V7X.js} +2 -2
- package/dist/chunk-4S6L5V7X.js.map +1 -0
- package/dist/{chunk-I2FQBMVW.js → chunk-66VWUJEI.js} +3 -3
- package/dist/chunk-66VWUJEI.js.map +1 -0
- package/dist/{chunk-YOCGBORV.js → chunk-7NMB2Y64.js} +4 -4
- package/dist/chunk-7NMB2Y64.js.map +1 -0
- package/dist/{chunk-V52JOSDF.js → chunk-A7P3O5Z5.js} +3 -3
- package/dist/chunk-A7P3O5Z5.js.map +1 -0
- package/dist/{chunk-ME4XILG5.js → chunk-ATYGDTMK.js} +5 -5
- package/dist/chunk-ATYGDTMK.js.map +1 -0
- package/dist/{chunk-S6XJMYME.js → chunk-C4SUXMI3.js} +3 -3
- package/dist/chunk-C4SUXMI3.js.map +1 -0
- package/dist/{chunk-EXFR2BFJ.js → chunk-DUPPPGXY.js} +7 -7
- package/dist/chunk-DUPPPGXY.js.map +1 -0
- package/dist/{chunk-I5O2WCGF.js → chunk-EU2LPUYP.js} +3 -3
- package/dist/chunk-EU2LPUYP.js.map +1 -0
- package/dist/{chunk-D7FB3TCA.js → chunk-GXI2H5XA.js} +3 -3
- package/dist/chunk-GXI2H5XA.js.map +1 -0
- package/dist/{chunk-ADO3SVUA.js → chunk-IABPUCAQ.js} +2 -2
- package/dist/chunk-IABPUCAQ.js.map +1 -0
- package/dist/{chunk-PC5SZFZC.js → chunk-JOKEKOP3.js} +8 -8
- package/dist/chunk-JOKEKOP3.js.map +1 -0
- package/dist/{chunk-GXWB6BSY.js → chunk-KUQWUTZA.js} +4 -4
- package/dist/chunk-KUQWUTZA.js.map +1 -0
- package/dist/{chunk-ZEDXARXW.js → chunk-U3J4UPV3.js} +2 -2
- package/dist/chunk-U3J4UPV3.js.map +1 -0
- package/dist/{chunk-DSEL7ELZ.js → chunk-VE2PNSDQ.js} +4 -4
- package/dist/chunk-VE2PNSDQ.js.map +1 -0
- package/dist/{chunk-YJ64RI7J.js → chunk-WAFHC4DQ.js} +3 -3
- package/dist/chunk-WAFHC4DQ.js.map +1 -0
- package/dist/{chunk-W5WNWTQC.js → chunk-YJ67WYVW.js} +5 -5
- package/dist/chunk-YJ67WYVW.js.map +1 -0
- package/dist/{chunk-4MS6ZHFQ.js → chunk-Z427NPMU.js} +7 -7
- package/dist/chunk-Z427NPMU.js.map +1 -0
- package/dist/core/index.cjs +4 -4
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/data-display/index.cjs.map +1 -1
- package/dist/data-display/index.js +5 -5
- package/dist/feedback/index.cjs.map +1 -1
- package/dist/feedback/index.js +2 -2
- package/dist/forms/index.cjs.map +1 -1
- package/dist/forms/index.js +8 -8
- package/dist/glass/index.cjs.map +1 -1
- package/dist/glass/index.js +2 -2
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -19
- package/dist/layout/index.cjs.map +1 -1
- package/dist/layout/index.js +2 -2
- package/dist/navigation/index.cjs.map +1 -1
- package/dist/navigation/index.js +6 -6
- package/dist/overlays/index.cjs.map +1 -1
- package/dist/overlays/index.js +7 -7
- package/dist/reset.css +45 -45
- package/dist/tokens.css +11 -11
- package/dist/typography/index.cjs.map +1 -1
- package/dist/typography/index.js +4 -4
- package/llms.txt +3 -3
- package/package.json +166 -159
- package/scripts/postinstall.js +125 -0
- package/src/bento/BentoCard.tsx +140 -140
- package/src/bento/BentoGrid.tsx +50 -50
- package/src/bento/index.ts +2 -2
- package/src/bento/tokens.css +46 -46
- package/src/buttons/Button.tsx +104 -104
- package/src/buttons/ButtonGroup.tsx +43 -43
- package/src/buttons/IconButton.tsx +79 -79
- package/src/buttons/SplitButton.tsx +186 -186
- package/src/buttons/ToggleButton.tsx +157 -157
- package/src/buttons/index.ts +5 -5
- package/src/buttons/tokens.css +6 -6
- package/src/core/ThemeProvider.tsx +133 -133
- package/src/core/index.ts +2 -2
- package/src/core/tokens.css +134 -134
- package/src/data-display/Accordion.tsx +147 -147
- package/src/data-display/Avatar.tsx +134 -134
- package/src/data-display/Card.tsx +112 -112
- package/src/data-display/Chip.tsx +74 -74
- package/src/data-display/DataTablePro.tsx +534 -534
- package/src/data-display/StatCard.tsx +60 -60
- package/src/data-display/Table.tsx +74 -74
- package/src/data-display/Timeline.tsx +62 -62
- package/src/data-display/TreeView.tsx +279 -279
- package/src/data-display/tokens.css +4 -4
- package/src/feedback/Alert.tsx +80 -80
- package/src/feedback/EmptyState.tsx +45 -45
- package/src/feedback/Skeleton.tsx +66 -66
- package/src/feedback/Spinner.tsx +65 -65
- package/src/feedback/index.ts +5 -5
- package/src/feedback/tokens.css +4 -4
- package/src/forms/Checkbox.tsx +149 -149
- package/src/forms/CheckboxGroup.tsx +76 -76
- package/src/forms/Combobox.tsx +512 -512
- package/src/forms/DatePicker.tsx +784 -784
- package/src/forms/FileUpload.tsx +266 -266
- package/src/forms/FormField.tsx +115 -115
- package/src/forms/Input.tsx +151 -151
- package/src/forms/NumberInput.tsx +262 -262
- package/src/forms/PasswordInput.tsx +35 -35
- package/src/forms/PinInput.tsx +139 -139
- package/src/forms/Radio.tsx +142 -142
- package/src/forms/RangeSlider.tsx +237 -237
- package/src/forms/SearchInput.tsx +34 -34
- package/src/forms/Select.tsx +103 -103
- package/src/forms/Slider.tsx +85 -85
- package/src/forms/Textarea.tsx +114 -114
- package/src/forms/tokens.css +6 -6
- package/src/glass/GlassButton.tsx +59 -59
- package/src/glass/GlassCard.tsx +71 -71
- package/src/glass/index.ts +3 -3
- package/src/glass/tokens.css +81 -81
- package/src/index.ts +26 -26
- package/src/layout/AspectRatio.tsx +39 -39
- package/src/layout/Container.tsx +53 -53
- package/src/layout/Divider.tsx +61 -61
- package/src/layout/Flex.tsx +79 -79
- package/src/layout/Grid.tsx +124 -124
- package/src/layout/PageLayout.tsx +59 -59
- package/src/layout/Section.tsx +47 -47
- package/src/layout/Spacer.tsx +30 -30
- package/src/layout/Stack.tsx +88 -88
- package/src/layout/tokens.css +8 -8
- package/src/navigation/Breadcrumb.tsx +73 -73
- package/src/navigation/CommandPalette.tsx +142 -142
- package/src/navigation/DropdownMenu.tsx +244 -244
- package/src/navigation/Pagination.tsx +114 -114
- package/src/navigation/Stepper.tsx +93 -93
- package/src/navigation/Tabs.tsx +265 -265
- package/src/navigation/index.ts +6 -6
- package/src/navigation/tokens.css +4 -4
- package/src/overlays/AlertDialog.tsx +63 -63
- package/src/overlays/BottomSheet.tsx +143 -143
- package/src/overlays/ContextMenu.tsx +161 -161
- package/src/overlays/Drawer.tsx +160 -160
- package/src/overlays/FocusTrap.tsx +122 -122
- package/src/overlays/Modal.tsx +296 -296
- package/src/overlays/Popover.tsx +156 -156
- package/src/overlays/Portal.tsx +23 -23
- package/src/overlays/Toast.tsx +175 -175
- package/src/overlays/Tooltip.tsx +125 -125
- package/src/overlays/tokens.css +8 -8
- package/src/registry/components.ts +1368 -1368
- package/src/styles/components.css +39 -39
- package/src/styles/index.css +5 -5
- package/src/styles/reset.css +45 -45
- package/src/styles/tokens.css +11 -11
- package/src/typography/Blockquote.tsx +32 -32
- package/src/typography/Code.tsx +89 -89
- package/src/typography/Heading.tsx +56 -56
- package/src/typography/Kbd.tsx +25 -25
- package/src/typography/Label.tsx +47 -47
- package/src/typography/Link.tsx +56 -56
- package/src/typography/Text.tsx +62 -62
- package/src/typography/index.ts +7 -7
- package/src/typography/tokens.css +5 -5
- package/src/utils/cn.ts +6 -6
- package/src/utils/scroll-lock.ts +19 -19
- package/src/utils/useControllableState.ts +54 -54
- package/ui-contract.json +1 -1
- package/dist/chunk-2YJBW4E2.js.map +0 -1
- package/dist/chunk-4CRFNALF.js.map +0 -1
- package/dist/chunk-4MS6ZHFQ.js.map +0 -1
- package/dist/chunk-77KTYOMQ.js.map +0 -1
- package/dist/chunk-ADO3SVUA.js.map +0 -1
- package/dist/chunk-D7FB3TCA.js.map +0 -1
- package/dist/chunk-DSEL7ELZ.js.map +0 -1
- package/dist/chunk-EXFR2BFJ.js.map +0 -1
- package/dist/chunk-GXWB6BSY.js.map +0 -1
- package/dist/chunk-I2FQBMVW.js.map +0 -1
- package/dist/chunk-I5O2WCGF.js.map +0 -1
- package/dist/chunk-ME4XILG5.js.map +0 -1
- package/dist/chunk-PC5SZFZC.js.map +0 -1
- package/dist/chunk-S6XJMYME.js.map +0 -1
- package/dist/chunk-V52JOSDF.js.map +0 -1
- package/dist/chunk-W5WNWTQC.js.map +0 -1
- package/dist/chunk-YJ64RI7J.js.map +0 -1
- package/dist/chunk-YOCGBORV.js.map +0 -1
- 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
|
+
}
|