@thangnqdev/ui 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -206
  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/bento/styles.css +2 -46
  7. package/dist/buttons/index.cjs +111 -18
  8. package/dist/buttons/index.cjs.map +1 -1
  9. package/dist/buttons/index.d.cts +1 -1
  10. package/dist/buttons/index.d.ts +1 -1
  11. package/dist/buttons/index.js +3 -3
  12. package/dist/buttons/styles.css +2 -6
  13. package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
  14. package/dist/chunk-2YJBW4E2.js.map +1 -0
  15. package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
  16. package/dist/chunk-4CRFNALF.js.map +1 -0
  17. package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
  18. package/dist/chunk-4MS6ZHFQ.js.map +1 -0
  19. package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
  20. package/dist/chunk-77KTYOMQ.js.map +1 -0
  21. package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
  22. package/dist/chunk-ADO3SVUA.js.map +1 -0
  23. package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
  24. package/dist/chunk-D7FB3TCA.js.map +1 -0
  25. package/dist/chunk-DSEL7ELZ.js +314 -0
  26. package/dist/chunk-DSEL7ELZ.js.map +1 -0
  27. package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
  28. package/dist/chunk-EXFR2BFJ.js.map +1 -0
  29. package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
  30. package/dist/chunk-GXWB6BSY.js.map +1 -0
  31. package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
  32. package/dist/chunk-I2FQBMVW.js.map +1 -0
  33. package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
  34. package/dist/chunk-I5O2WCGF.js.map +1 -0
  35. package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
  36. package/dist/chunk-ME4XILG5.js.map +1 -0
  37. package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
  38. package/dist/chunk-PC5SZFZC.js.map +1 -0
  39. package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
  40. package/dist/chunk-S6XJMYME.js.map +1 -0
  41. package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
  42. package/dist/chunk-V52JOSDF.js.map +1 -0
  43. package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
  44. package/dist/chunk-W5WNWTQC.js.map +1 -0
  45. package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
  46. package/dist/chunk-YJ64RI7J.js.map +1 -0
  47. package/dist/chunk-YOCGBORV.js +155 -0
  48. package/dist/chunk-YOCGBORV.js.map +1 -0
  49. package/dist/chunk-ZEDXARXW.js +96 -0
  50. package/dist/chunk-ZEDXARXW.js.map +1 -0
  51. package/dist/components.css +2 -0
  52. package/dist/core/index.cjs +33 -24
  53. package/dist/core/index.cjs.map +1 -1
  54. package/dist/core/index.js +2 -2
  55. package/dist/core/styles.css +2 -134
  56. package/dist/data-display/index.cjs +770 -247
  57. package/dist/data-display/index.cjs.map +1 -1
  58. package/dist/data-display/index.d.cts +1 -1
  59. package/dist/data-display/index.d.ts +1 -1
  60. package/dist/data-display/index.js +5 -4
  61. package/dist/data-display/styles.css +2 -4
  62. package/dist/feedback/index.cjs.map +1 -1
  63. package/dist/feedback/index.js +2 -2
  64. package/dist/feedback/styles.css +2 -4
  65. package/dist/forms/index.cjs +1211 -459
  66. package/dist/forms/index.cjs.map +1 -1
  67. package/dist/forms/index.d.cts +1 -1
  68. package/dist/forms/index.d.ts +1 -1
  69. package/dist/forms/index.js +8 -8
  70. package/dist/forms/styles.css +2 -6
  71. package/dist/glass/index.cjs.map +1 -1
  72. package/dist/glass/index.js +2 -2
  73. package/dist/glass/styles.css +2 -81
  74. package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
  75. package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
  76. package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
  77. package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
  78. package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
  79. package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
  80. package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
  81. package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
  82. package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
  83. package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
  84. package/dist/index.cjs +2481 -917
  85. package/dist/index.cjs.map +1 -1
  86. package/dist/index.d.cts +5 -5
  87. package/dist/index.d.ts +5 -5
  88. package/dist/index.js +19 -19
  89. package/dist/layout/index.cjs.map +1 -1
  90. package/dist/layout/index.js +2 -2
  91. package/dist/layout/styles.css +2 -8
  92. package/dist/navigation/index.cjs +288 -48
  93. package/dist/navigation/index.cjs.map +1 -1
  94. package/dist/navigation/index.d.cts +1 -1
  95. package/dist/navigation/index.d.ts +1 -1
  96. package/dist/navigation/index.js +6 -6
  97. package/dist/navigation/styles.css +2 -4
  98. package/dist/overlays/index.cjs +566 -229
  99. package/dist/overlays/index.cjs.map +1 -1
  100. package/dist/overlays/index.d.cts +1 -1
  101. package/dist/overlays/index.d.ts +1 -1
  102. package/dist/overlays/index.js +7 -7
  103. package/dist/overlays/styles.css +2 -8
  104. package/dist/reset.css +45 -0
  105. package/dist/styles.css +1 -1
  106. package/dist/tokens.css +11 -0
  107. package/dist/typography/index.cjs.map +1 -1
  108. package/dist/typography/index.js +4 -4
  109. package/dist/typography/styles.css +2 -5
  110. package/llms.txt +211 -113
  111. package/package.json +167 -116
  112. package/scripts/postinstall.js +125 -0
  113. package/src/bento/BentoCard.tsx +140 -140
  114. package/src/bento/BentoGrid.tsx +50 -50
  115. package/src/bento/index.ts +2 -2
  116. package/src/bento/tokens.css +46 -46
  117. package/src/buttons/Button.tsx +104 -104
  118. package/src/buttons/ButtonGroup.tsx +43 -43
  119. package/src/buttons/IconButton.tsx +79 -79
  120. package/src/buttons/SplitButton.tsx +186 -86
  121. package/src/buttons/ToggleButton.tsx +157 -157
  122. package/src/buttons/index.ts +5 -5
  123. package/src/buttons/tokens.css +6 -6
  124. package/src/core/ThemeProvider.tsx +135 -123
  125. package/src/core/index.ts +2 -2
  126. package/src/core/tokens.css +134 -134
  127. package/src/data-display/Accordion.tsx +147 -147
  128. package/src/data-display/Avatar.tsx +134 -134
  129. package/src/data-display/Card.tsx +112 -112
  130. package/src/data-display/Chip.tsx +74 -74
  131. package/src/data-display/DataTablePro.tsx +299 -121
  132. package/src/data-display/StatCard.tsx +60 -60
  133. package/src/data-display/Table.tsx +74 -74
  134. package/src/data-display/Timeline.tsx +62 -62
  135. package/src/data-display/TreeView.tsx +243 -104
  136. package/src/data-display/tokens.css +4 -4
  137. package/src/feedback/Alert.tsx +80 -80
  138. package/src/feedback/EmptyState.tsx +45 -45
  139. package/src/feedback/Skeleton.tsx +66 -66
  140. package/src/feedback/Spinner.tsx +65 -65
  141. package/src/feedback/index.ts +5 -5
  142. package/src/feedback/tokens.css +4 -4
  143. package/src/forms/Checkbox.tsx +149 -69
  144. package/src/forms/CheckboxGroup.tsx +76 -29
  145. package/src/forms/Combobox.tsx +172 -28
  146. package/src/forms/DatePicker.tsx +385 -81
  147. package/src/forms/FileUpload.tsx +51 -7
  148. package/src/forms/FormField.tsx +115 -115
  149. package/src/forms/Input.tsx +151 -151
  150. package/src/forms/NumberInput.tsx +34 -20
  151. package/src/forms/PasswordInput.tsx +35 -35
  152. package/src/forms/PinInput.tsx +139 -125
  153. package/src/forms/Radio.tsx +142 -142
  154. package/src/forms/RangeSlider.tsx +237 -111
  155. package/src/forms/SearchInput.tsx +34 -34
  156. package/src/forms/Select.tsx +103 -103
  157. package/src/forms/Slider.tsx +85 -85
  158. package/src/forms/Textarea.tsx +114 -114
  159. package/src/forms/tokens.css +6 -6
  160. package/src/glass/GlassButton.tsx +59 -59
  161. package/src/glass/GlassCard.tsx +71 -71
  162. package/src/glass/index.ts +3 -3
  163. package/src/glass/tokens.css +81 -81
  164. package/src/index.ts +26 -26
  165. package/src/layout/AspectRatio.tsx +39 -39
  166. package/src/layout/Container.tsx +53 -53
  167. package/src/layout/Divider.tsx +61 -61
  168. package/src/layout/Flex.tsx +79 -79
  169. package/src/layout/Grid.tsx +124 -124
  170. package/src/layout/PageLayout.tsx +59 -59
  171. package/src/layout/Section.tsx +47 -47
  172. package/src/layout/Spacer.tsx +30 -30
  173. package/src/layout/Stack.tsx +88 -88
  174. package/src/layout/tokens.css +8 -8
  175. package/src/navigation/Breadcrumb.tsx +73 -73
  176. package/src/navigation/CommandPalette.tsx +142 -142
  177. package/src/navigation/DropdownMenu.tsx +244 -160
  178. package/src/navigation/Pagination.tsx +114 -114
  179. package/src/navigation/Stepper.tsx +93 -93
  180. package/src/navigation/Tabs.tsx +265 -133
  181. package/src/navigation/index.ts +6 -6
  182. package/src/navigation/tokens.css +4 -4
  183. package/src/overlays/AlertDialog.tsx +63 -54
  184. package/src/overlays/BottomSheet.tsx +30 -10
  185. package/src/overlays/ContextMenu.tsx +51 -9
  186. package/src/overlays/Drawer.tsx +160 -145
  187. package/src/overlays/FocusTrap.tsx +122 -49
  188. package/src/overlays/Modal.tsx +296 -219
  189. package/src/overlays/Popover.tsx +55 -25
  190. package/src/overlays/Portal.tsx +23 -23
  191. package/src/overlays/Toast.tsx +175 -137
  192. package/src/overlays/Tooltip.tsx +125 -100
  193. package/src/overlays/tokens.css +8 -8
  194. package/src/registry/components.ts +1368 -0
  195. package/src/styles/components.css +39 -0
  196. package/src/styles/index.css +2 -93
  197. package/src/styles/reset.css +45 -0
  198. package/src/styles/tokens.css +11 -2
  199. package/src/typography/Blockquote.tsx +32 -32
  200. package/src/typography/Code.tsx +89 -89
  201. package/src/typography/Heading.tsx +56 -56
  202. package/src/typography/Kbd.tsx +25 -25
  203. package/src/typography/Label.tsx +47 -47
  204. package/src/typography/Link.tsx +56 -56
  205. package/src/typography/Text.tsx +62 -62
  206. package/src/typography/index.ts +7 -7
  207. package/src/typography/tokens.css +5 -5
  208. package/src/utils/cn.ts +6 -6
  209. package/src/utils/scroll-lock.ts +19 -0
  210. package/src/utils/useControllableState.ts +54 -0
  211. package/ui-contract.json +192 -35
  212. package/dist/chunk-2OXZK6IP.js.map +0 -1
  213. package/dist/chunk-4S6L5V7X.js.map +0 -1
  214. package/dist/chunk-5FZ6ZFII.js.map +0 -1
  215. package/dist/chunk-66VWUJEI.js.map +0 -1
  216. package/dist/chunk-A7P3O5Z5.js.map +0 -1
  217. package/dist/chunk-ATYGDTMK.js.map +0 -1
  218. package/dist/chunk-C4SUXMI3.js.map +0 -1
  219. package/dist/chunk-EU2LPUYP.js.map +0 -1
  220. package/dist/chunk-F5Q7BTMH.js.map +0 -1
  221. package/dist/chunk-GXI2H5XA.js.map +0 -1
  222. package/dist/chunk-H3LCNPMC.js +0 -101
  223. package/dist/chunk-H3LCNPMC.js.map +0 -1
  224. package/dist/chunk-IABPUCAQ.js.map +0 -1
  225. package/dist/chunk-KJRBRTQE.js.map +0 -1
  226. package/dist/chunk-LBUBXUYQ.js +0 -40
  227. package/dist/chunk-LBUBXUYQ.js.map +0 -1
  228. package/dist/chunk-LWFRUJRU.js.map +0 -1
  229. package/dist/chunk-NFVONJUK.js.map +0 -1
  230. package/dist/chunk-PF4ZHGVW.js.map +0 -1
  231. package/dist/chunk-WAFHC4DQ.js.map +0 -1
  232. package/dist/chunk-X76KBH4Y.js +0 -71
  233. package/dist/chunk-X76KBH4Y.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo, forwardRef } from 'react';
1
+ import React, { useState, useMemo, forwardRef, useEffect, useRef } from 'react';
2
2
  import {
3
3
  ChevronUp,
4
4
  ChevronDown,
@@ -9,30 +9,37 @@ import {
9
9
  ChevronLeft,
10
10
  ChevronRight,
11
11
  Filter,
12
+ Loader2,
12
13
  } from 'lucide-react';
13
14
  import { cn } from '../utils/cn';
14
15
  import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
15
16
  import { Button } from '../buttons/Button';
16
- import { Input } from '../forms/Input';
17
17
  import { Checkbox } from '../forms/Checkbox';
18
- import { Chip } from './Chip';
19
18
 
20
- export interface ColumnDef<T> {
19
+ export interface SortRule {
20
+ key: string;
21
+ direction: 'asc' | 'desc';
22
+ }
23
+
24
+ export interface ColumnDef<TData, TValue = any> {
21
25
  key: string;
22
26
  header: string | React.ReactNode;
23
- accessor?: (row: T) => any;
24
- render?: (row: T, index: number) => React.ReactNode;
27
+ accessor?: (row: TData) => TValue;
28
+ render?: (row: TData, index: number, value: TValue) => React.ReactNode;
25
29
  sortable?: boolean;
30
+ sortComparator?: (a: TData, b: TData) => number;
26
31
  width?: string;
27
32
  }
28
33
 
29
- export interface DataTableProProps<T> {
30
- data: T[];
31
- columns: ColumnDef<T>[];
32
- keyExtractor: (row: T) => string;
34
+ export interface DataTableProProps<TData extends Record<string, any>> {
35
+ data: TData[];
36
+ columns: ColumnDef<TData>[];
37
+ keyExtractor: (row: TData) => string;
33
38
  searchable?: boolean;
34
39
  searchPlaceholder?: string;
35
40
  selectable?: boolean;
41
+ selectedKeys?: string[];
42
+ defaultSelectedKeys?: string[];
36
43
  onSelectionChange?: (selectedKeys: string[]) => void;
37
44
  pageSize?: number;
38
45
  pageSizeOptions?: number[];
@@ -40,15 +47,37 @@ export interface DataTableProProps<T> {
40
47
  description?: string;
41
48
  actions?: React.ReactNode;
42
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;
43
70
  }
44
71
 
45
- export function DataTablePro<T extends Record<string, any>>({
72
+ export function DataTablePro<TData extends Record<string, any>>({
46
73
  data,
47
74
  columns,
48
75
  keyExtractor,
49
76
  searchable = true,
50
77
  searchPlaceholder = 'Search all records...',
51
78
  selectable = true,
79
+ selectedKeys: controlledSelectedKeys,
80
+ defaultSelectedKeys = [],
52
81
  onSelectionChange,
53
82
  pageSize: initialPageSize = 10,
54
83
  pageSizeOptions = [5, 10, 25, 50],
@@ -56,73 +85,192 @@ export function DataTablePro<T extends Record<string, any>>({
56
85
  description,
57
86
  actions,
58
87
  className,
59
- }: DataTableProProps<T>) {
60
- const [search, setSearch] = useState('');
61
- const [sortKey, setSortKey] = useState<string | null>(null);
62
- const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
63
- const [currentPage, setCurrentPage] = useState(1);
64
- const [pageSize, setPageSize] = useState(initialPageSize);
65
- const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
66
-
67
- // 1. Search filter
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)
68
137
  const filteredData = useMemo(() => {
69
- if (!search.trim()) return data;
138
+ if (manualFiltering || !search.trim()) return data;
70
139
  const q = search.toLowerCase();
140
+
71
141
  return data.filter((row) => {
72
- return Object.values(row).some((val) => {
142
+ return columns.some((col) => {
143
+ const val = col.accessor ? col.accessor(row) : row[col.key];
73
144
  if (val === null || val === undefined) return false;
74
145
  return String(val).toLowerCase().includes(q);
75
146
  });
76
147
  });
77
- }, [data, search]);
148
+ }, [data, search, columns, manualFiltering]);
78
149
 
79
- // 2. Sorting
150
+ // True Multi-Column Sorting (Client-side when not manual)
80
151
  const sortedData = useMemo(() => {
81
- if (!sortKey) return filteredData;
82
- const col = columns.find((c) => c.key === sortKey);
152
+ if (manualSorting || currentSorting.length === 0) return filteredData;
83
153
 
84
154
  return [...filteredData].sort((a, b) => {
85
- const valA = col?.accessor ? col.accessor(a) : a[sortKey];
86
- const valB = col?.accessor ? col.accessor(b) : b[sortKey];
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];
87
164
 
88
- if (valA === valB) return 0;
89
- if (valA === null || valA === undefined) return 1;
90
- if (valB === null || valB === undefined) return -1;
165
+ if (valA === valB) continue;
166
+ if (valA === null || valA === undefined) return 1;
167
+ if (valB === null || valB === undefined) return -1;
91
168
 
92
- const comp = valA < valB ? -1 : 1;
93
- return sortDirection === 'asc' ? comp : -comp;
169
+ comp = valA < valB ? -1 : 1;
170
+ }
171
+
172
+ if (comp !== 0) {
173
+ return direction === 'asc' ? comp : -comp;
174
+ }
175
+ }
176
+ return 0;
94
177
  });
95
- }, [filteredData, sortKey, sortDirection, columns]);
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]);
96
193
 
97
- // 3. Pagination
98
- const totalPages = Math.max(1, Math.ceil(sortedData.length / pageSize));
99
194
  const paginatedData = useMemo(() => {
195
+ if (manualPagination) return data;
100
196
  const start = (currentPage - 1) * pageSize;
101
197
  return sortedData.slice(start, start + pageSize);
102
- }, [sortedData, currentPage, pageSize]);
198
+ }, [data, sortedData, currentPage, pageSize, manualPagination]);
103
199
 
104
- const handleSort = (key: string) => {
105
- if (sortKey === key) {
106
- if (sortDirection === 'asc') {
107
- setSortDirection('desc');
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
+ }
108
213
  } else {
109
- setSortKey(null);
110
- setSortDirection('asc');
214
+ nextSorting.push({ key, direction: 'asc' });
111
215
  }
112
216
  } else {
113
- setSortKey(key);
114
- setSortDirection('asc');
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
+ }
115
256
  }
257
+ onSearchChange?.(query);
116
258
  };
117
259
 
118
- const toggleSelectAll = () => {
119
- if (selectedKeys.length === paginatedData.length) {
120
- setSelectedKeys([]);
121
- onSelectionChange?.([]);
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);
122
271
  } else {
123
- const allKeys = paginatedData.map(keyExtractor);
124
- setSelectedKeys(allKeys);
125
- onSelectionChange?.(allKeys);
272
+ const combined = Array.from(new Set([...selectedKeys, ...currentPageKeys]));
273
+ handleSelectionUpdate(combined);
126
274
  }
127
275
  };
128
276
 
@@ -130,13 +278,28 @@ export function DataTablePro<T extends Record<string, any>>({
130
278
  const next = selectedKeys.includes(key)
131
279
  ? selectedKeys.filter((k) => k !== key)
132
280
  : [...selectedKeys, key];
133
- setSelectedKeys(next);
134
- onSelectionChange?.(next);
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([]);
135
297
  };
136
298
 
137
299
  const exportCSV = () => {
300
+ const exportRows = manualPagination ? data : sortedData;
138
301
  const headers = columns.map((c) => (typeof c.header === 'string' ? c.header : c.key));
139
- const rows = sortedData.map((row) =>
302
+ const rows = exportRows.map((row) =>
140
303
  columns.map((c) => {
141
304
  const val = c.accessor ? c.accessor(row) : row[c.key];
142
305
  return `"${String(val ?? '').replace(/"/g, '""')}"`;
@@ -152,9 +315,6 @@ export function DataTablePro<T extends Record<string, any>>({
152
315
  document.body.removeChild(link);
153
316
  };
154
317
 
155
- const isAllSelected =
156
- paginatedData.length > 0 && selectedKeys.length === paginatedData.length;
157
-
158
318
  return (
159
319
  <div className={cn('w-full space-y-4', className)}>
160
320
  {/* Header Toolbar */}
@@ -174,10 +334,7 @@ export function DataTablePro<T extends Record<string, any>>({
174
334
  type="text"
175
335
  placeholder={searchPlaceholder}
176
336
  value={search}
177
- onChange={(e) => {
178
- setSearch(e.target.value);
179
- setCurrentPage(1);
180
- }}
337
+ onChange={(e) => handleSearchChange(e.target.value)}
181
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"
182
339
  />
183
340
  </div>
@@ -191,20 +348,26 @@ export function DataTablePro<T extends Record<string, any>>({
191
348
  </div>
192
349
  </div>
193
350
 
194
- {/* Bulk selection pill */}
351
+ {/* Bulk selection banner */}
195
352
  {selectedKeys.length > 0 && (
196
- <div className="px-4 py-2 rounded-xl bg-[var(--ui-primary)]/15 border border-[var(--ui-primary)]/30 flex items-center justify-between animate-in fade-in duration-200">
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">
197
354
  <div className="flex items-center gap-2 text-xs font-semibold text-[var(--ui-primary)]">
198
- <span>Selected {selectedKeys.length} of {sortedData.length} records</span>
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
+ )}
199
365
  </div>
200
366
  <Button
201
367
  size="xs"
202
368
  variant="ghost"
203
369
  className="text-xs text-rose-500 hover:text-rose-600 hover:bg-rose-500/10"
204
- onClick={() => {
205
- setSelectedKeys([]);
206
- onSelectionChange?.([]);
207
- }}
370
+ onClick={clearSelection}
208
371
  >
209
372
  Clear Selection
210
373
  </Button>
@@ -213,55 +376,75 @@ export function DataTablePro<T extends Record<string, any>>({
213
376
 
214
377
  {/* Table Container */}
215
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
+
216
385
  <Table>
217
386
  <TableHeader>
218
387
  <TableRow>
219
388
  {selectable && (
220
389
  <TableHead className="w-10 px-4">
221
- <input
222
- type="checkbox"
223
- checked={isAllSelected}
224
- onChange={toggleSelectAll}
225
- className="rounded border-[var(--ui-border-default)] text-[var(--ui-primary)] focus:ring-[var(--ui-primary)] cursor-pointer"
390
+ <Checkbox
391
+ checked={isAllCurrentPageSelected}
392
+ indeterminate={isSomeCurrentPageSelected}
393
+ onCheckedChange={toggleSelectAllCurrentPage}
394
+ aria-label="Select all on page"
226
395
  />
227
396
  </TableHead>
228
397
  )}
229
398
 
230
- {columns.map((col) => (
231
- <TableHead
232
- key={col.key}
233
- style={{ width: col.width }}
234
- onClick={() => col.sortable !== false && handleSort(col.key)}
235
- className={cn(
236
- col.sortable !== false && 'cursor-pointer select-none hover:text-[var(--ui-text-primary)] transition-colors'
237
- )}
238
- >
239
- <div className="flex items-center gap-1.5">
240
- <span>{col.header}</span>
241
- {col.sortable !== false && (
242
- <span className="text-[var(--ui-text-muted)]">
243
- {sortKey === col.key ? (
244
- sortDirection === 'asc' ? (
245
- <ChevronUp className="w-3.5 h-3.5 text-[var(--ui-primary)] font-bold" />
246
- ) : (
247
- <ChevronDown className="w-3.5 h-3.5 text-[var(--ui-primary)] font-bold" />
248
- )
249
- ) : (
250
- <ChevronsUpDown className="w-3 h-3 opacity-40" />
251
- )}
252
- </span>
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'
253
411
  )}
254
- </div>
255
- </TableHead>
256
- ))}
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
+ })}
257
437
  </TableRow>
258
438
  </TableHeader>
259
439
 
260
440
  <TableBody>
261
441
  {paginatedData.length === 0 ? (
262
442
  <TableRow>
263
- <TableCell colSpan={columns.length + (selectable ? 1 : 0)} className="h-32 text-center text-xs text-[var(--ui-text-muted)]">
264
- No matching records found.
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}
265
448
  </TableCell>
266
449
  </TableRow>
267
450
  ) : (
@@ -277,21 +460,17 @@ export function DataTablePro<T extends Record<string, any>>({
277
460
  >
278
461
  {selectable && (
279
462
  <TableCell className="w-10 px-4">
280
- <input
281
- type="checkbox"
463
+ <Checkbox
282
464
  checked={isSelected}
283
- onChange={() => toggleSelectRow(key)}
284
- className="rounded border-[var(--ui-border-default)] text-[var(--ui-primary)] focus:ring-[var(--ui-primary)] cursor-pointer"
465
+ onCheckedChange={() => toggleSelectRow(key)}
466
+ aria-label={`Select row ${key}`}
285
467
  />
286
468
  </TableCell>
287
469
  )}
288
470
 
289
471
  {columns.map((col) => {
290
- const cellContent = col.render
291
- ? col.render(row, rowIdx)
292
- : col.accessor
293
- ? col.accessor(row)
294
- : row[col.key];
472
+ const val = col.accessor ? col.accessor(row) : row[col.key];
473
+ const cellContent = col.render ? col.render(row, rowIdx, val) : val;
295
474
 
296
475
  return <TableCell key={col.key}>{cellContent}</TableCell>;
297
476
  })}
@@ -309,10 +488,7 @@ export function DataTablePro<T extends Record<string, any>>({
309
488
  <span>Rows per page:</span>
310
489
  <select
311
490
  value={pageSize}
312
- onChange={(e) => {
313
- setPageSize(Number(e.target.value));
314
- setCurrentPage(1);
315
- }}
491
+ onChange={(e) => handlePageSizeChange(Number(e.target.value))}
316
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"
317
493
  >
318
494
  {pageSizeOptions.map((opt) => (
@@ -322,8 +498,8 @@ export function DataTablePro<T extends Record<string, any>>({
322
498
  ))}
323
499
  </select>
324
500
  <span className="text-[var(--ui-text-muted)]">
325
- Showing {Math.min((currentPage - 1) * pageSize + 1, sortedData.length)}–
326
- {Math.min(currentPage * pageSize, sortedData.length)} of {sortedData.length}
501
+ Showing {totalItems === 0 ? 0 : Math.min((currentPage - 1) * pageSize + 1, totalItems)}–
502
+ {Math.min(currentPage * pageSize, totalItems)} of {totalItems}
327
503
  </span>
328
504
  </div>
329
505
 
@@ -331,8 +507,9 @@ export function DataTablePro<T extends Record<string, any>>({
331
507
  <Button
332
508
  size="xs"
333
509
  variant="outline"
334
- disabled={currentPage === 1}
335
- onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
510
+ disabled={currentPage <= 1 || loading}
511
+ onClick={() => handlePageChange(currentPage - 1)}
512
+ aria-label="Previous page"
336
513
  >
337
514
  <ChevronLeft className="w-3.5 h-3.5" />
338
515
  </Button>
@@ -344,8 +521,9 @@ export function DataTablePro<T extends Record<string, any>>({
344
521
  <Button
345
522
  size="xs"
346
523
  variant="outline"
347
- disabled={currentPage === totalPages}
348
- onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
524
+ disabled={currentPage >= totalPages || loading}
525
+ onClick={() => handlePageChange(currentPage + 1)}
526
+ aria-label="Next page"
349
527
  >
350
528
  <ChevronRight className="w-3.5 h-3.5" />
351
529
  </Button>