@rovula/ui 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/bundle.css +513 -67
- package/dist/cjs/bundle.js +589 -589
- package/dist/cjs/bundle.js.map +1 -1
- package/dist/cjs/types/components/Avatar/Avatar.d.ts +1 -1
- package/dist/cjs/types/components/Avatar/Avatar.stories.d.ts +1 -1
- package/dist/cjs/types/components/Avatar/Avatar.styles.d.ts +1 -0
- package/dist/cjs/types/components/DataTable/DataTable.d.ts +195 -4
- package/dist/cjs/types/components/DataTable/DataTable.editing.d.ts +20 -0
- package/dist/cjs/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
- package/dist/cjs/types/components/DataTable/DataTable.stories.d.ts +268 -6
- package/dist/cjs/types/components/Dropdown/Dropdown.d.ts +22 -0
- package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
- package/dist/cjs/types/components/ScrollArea/ScrollArea.d.ts +3 -3
- package/dist/cjs/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
- package/dist/cjs/types/components/Table/Table.d.ts +33 -3
- package/dist/cjs/types/components/Table/Table.stories.d.ts +86 -4
- package/dist/cjs/types/components/TextInput/TextInput.stories.d.ts +8 -0
- package/dist/cjs/types/components/TextInput/TextInput.styles.d.ts +1 -0
- package/dist/components/Avatar/Avatar.js +2 -1
- package/dist/components/Avatar/Avatar.styles.js +3 -0
- package/dist/components/Avatar/AvatarBase.js +1 -1
- package/dist/components/DataTable/DataTable.editing.js +385 -0
- package/dist/components/DataTable/DataTable.editing.types.js +1 -0
- package/dist/components/DataTable/DataTable.js +983 -50
- package/dist/components/DataTable/DataTable.stories.js +1077 -25
- package/dist/components/Dropdown/Dropdown.js +8 -6
- package/dist/components/ScrollArea/ScrollArea.js +2 -2
- package/dist/components/ScrollArea/ScrollArea.stories.js +68 -2
- package/dist/components/Table/Table.js +103 -13
- package/dist/components/Table/Table.stories.js +226 -9
- package/dist/components/TextInput/TextInput.js +6 -4
- package/dist/components/TextInput/TextInput.stories.js +8 -0
- package/dist/components/TextInput/TextInput.styles.js +7 -1
- package/dist/esm/bundle.css +513 -67
- package/dist/esm/bundle.js +1545 -1545
- package/dist/esm/bundle.js.map +1 -1
- package/dist/esm/types/components/Avatar/Avatar.d.ts +1 -1
- package/dist/esm/types/components/Avatar/Avatar.stories.d.ts +1 -1
- package/dist/esm/types/components/Avatar/Avatar.styles.d.ts +1 -0
- package/dist/esm/types/components/DataTable/DataTable.d.ts +195 -4
- package/dist/esm/types/components/DataTable/DataTable.editing.d.ts +20 -0
- package/dist/esm/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
- package/dist/esm/types/components/DataTable/DataTable.stories.d.ts +268 -6
- package/dist/esm/types/components/Dropdown/Dropdown.d.ts +22 -0
- package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
- package/dist/esm/types/components/ScrollArea/ScrollArea.d.ts +3 -3
- package/dist/esm/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
- package/dist/esm/types/components/Table/Table.d.ts +33 -3
- package/dist/esm/types/components/Table/Table.stories.d.ts +86 -4
- package/dist/esm/types/components/TextInput/TextInput.stories.d.ts +8 -0
- package/dist/esm/types/components/TextInput/TextInput.styles.d.ts +1 -0
- package/dist/index.d.ts +493 -122
- package/dist/src/theme/global.css +762 -96
- package/package.json +14 -2
- package/src/components/Avatar/Avatar.styles.ts +4 -1
- package/src/components/Avatar/Avatar.tsx +3 -2
- package/src/components/Avatar/AvatarBase.tsx +3 -3
- package/src/components/DataTable/DataTable.editing.tsx +861 -0
- package/src/components/DataTable/DataTable.editing.types.ts +192 -0
- package/src/components/DataTable/DataTable.stories.tsx +2169 -31
- package/src/components/DataTable/DataTable.test.tsx +696 -0
- package/src/components/DataTable/DataTable.tsx +2260 -94
- package/src/components/Dropdown/Dropdown.tsx +22 -6
- package/src/components/ScrollArea/ScrollArea.stories.tsx +146 -3
- package/src/components/ScrollArea/ScrollArea.tsx +6 -6
- package/src/components/Table/Table.stories.tsx +789 -44
- package/src/components/Table/Table.tsx +294 -28
- package/src/components/TextInput/TextInput.stories.tsx +80 -0
- package/src/components/TextInput/TextInput.styles.ts +7 -1
- package/src/components/TextInput/TextInput.tsx +21 -14
- package/src/test/setup.ts +50 -0
- package/src/theme/global.css +81 -42
- package/src/theme/presets/colors.js +12 -0
- package/src/theme/themes/variable.css +27 -28
- package/src/theme/tokens/baseline.css +2 -1
- package/src/theme/tokens/components/scrollbar.css +9 -4
- package/src/theme/tokens/components/table.css +63 -0
|
@@ -1,171 +1,2337 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Cell,
|
|
2
3
|
ColumnDef,
|
|
3
4
|
ColumnFiltersState,
|
|
4
|
-
|
|
5
|
+
ColumnOrderState,
|
|
6
|
+
ColumnSizingInfoState,
|
|
7
|
+
ColumnSizingState,
|
|
8
|
+
ExpandedState,
|
|
9
|
+
Header,
|
|
10
|
+
PaginationState,
|
|
11
|
+
Row,
|
|
12
|
+
RowSelectionState,
|
|
13
|
+
RowData,
|
|
5
14
|
SortingState,
|
|
15
|
+
Table as TanstackTable,
|
|
6
16
|
VisibilityState,
|
|
7
17
|
flexRender,
|
|
8
18
|
getCoreRowModel,
|
|
19
|
+
getExpandedRowModel,
|
|
9
20
|
getFilteredRowModel,
|
|
10
|
-
|
|
21
|
+
getPaginationRowModel,
|
|
11
22
|
getSortedRowModel,
|
|
12
23
|
useReactTable,
|
|
13
24
|
} from "@tanstack/react-table";
|
|
14
|
-
import React, {
|
|
25
|
+
import React, {
|
|
26
|
+
useCallback,
|
|
27
|
+
useEffect,
|
|
28
|
+
useLayoutEffect,
|
|
29
|
+
useRef,
|
|
30
|
+
useState,
|
|
31
|
+
} from "react";
|
|
32
|
+
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
|
|
15
33
|
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Extend TanStack's ColumnMeta so every column definition can carry
|
|
36
|
+
// `meta: { exactWidth: <number | string> }`. When set the column is
|
|
37
|
+
// rendered at exactly that width (width + minWidth + maxWidth all equal)
|
|
38
|
+
// regardless of tableLayout mode, content width, or leftover table space.
|
|
39
|
+
// Accepts a number (px) or any CSS length / calc() expression.
|
|
40
|
+
//
|
|
41
|
+
// `meta.align` — `text-left` / `text-center` / `text-right` on body cells and
|
|
42
|
+
// on the header label wrapper (sort / ⋮ stay beside the label).
|
|
43
|
+
// `meta.cellClassName` / `meta.headerCellClassName` — merged before the
|
|
44
|
+
// table-level `cellClassName` / `headerCellClassName` props so callbacks can
|
|
45
|
+
// override.
|
|
46
|
+
//
|
|
47
|
+
// `meta.colSpan` — merge this body cell with the next N-1 visible columns
|
|
48
|
+
// (`<td colSpan={N}>`). Use `number` or `(row) => number` when only some rows
|
|
49
|
+
// span (e.g. a footer "add" row). Skipped columns do not render their own `<td>`.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
declare module "@tanstack/react-table" {
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
53
|
+
interface ColumnMeta<TData extends RowData, TValue> {
|
|
54
|
+
exactWidth?: number | string;
|
|
55
|
+
align?: "left" | "center" | "right";
|
|
56
|
+
cellClassName?: string;
|
|
57
|
+
headerCellClassName?: string;
|
|
58
|
+
colSpan?: number | ((row: Row<TData>) => number);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** TanStack Table types used by `DataTable` props — import from `@rovula/ui` to stay aligned with this package and `ColumnMeta` augmentation. */
|
|
63
|
+
export type {
|
|
64
|
+
Cell,
|
|
65
|
+
ColumnDef,
|
|
66
|
+
ExpandedState,
|
|
67
|
+
Header,
|
|
68
|
+
PaginationState,
|
|
69
|
+
Row,
|
|
70
|
+
RowData,
|
|
71
|
+
RowSelectionState,
|
|
72
|
+
SortingState,
|
|
73
|
+
Table as TanstackTable,
|
|
74
|
+
} from "@tanstack/react-table";
|
|
75
|
+
|
|
76
|
+
export type {
|
|
77
|
+
EditableColumnDef,
|
|
78
|
+
EditDisplayMode,
|
|
79
|
+
EditTrigger,
|
|
80
|
+
DataTableEditingProps,
|
|
81
|
+
} from "./DataTable.editing.types";
|
|
82
|
+
|
|
83
|
+
function columnMetaAlignClass(
|
|
84
|
+
meta: { align?: "left" | "center" | "right" } | undefined,
|
|
85
|
+
): string | undefined {
|
|
86
|
+
switch (meta?.align) {
|
|
87
|
+
case "center":
|
|
88
|
+
return "text-center";
|
|
89
|
+
case "right":
|
|
90
|
+
return "text-right";
|
|
91
|
+
case "left":
|
|
92
|
+
return "text-left";
|
|
93
|
+
default:
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Walk visible body cells left-to-right; when a column defines `meta.colSpan`,
|
|
100
|
+
* emit one `<td>` spanning that many columns and skip the covered cells.
|
|
101
|
+
*/
|
|
102
|
+
function getVisibleCellsForRender<TData extends RowData>(
|
|
103
|
+
cells: Cell<TData, unknown>[],
|
|
104
|
+
): { cell: Cell<TData, unknown>; colSpan: number }[] {
|
|
105
|
+
const out: { cell: Cell<TData, unknown>; colSpan: number }[] = [];
|
|
106
|
+
let i = 0;
|
|
107
|
+
while (i < cells.length) {
|
|
108
|
+
const cell = cells[i]!;
|
|
109
|
+
const meta = cell.column.columnDef.meta as
|
|
110
|
+
| { colSpan?: number | ((row: Row<TData>) => number) }
|
|
111
|
+
| undefined;
|
|
112
|
+
let want = 1;
|
|
113
|
+
if (meta?.colSpan != null) {
|
|
114
|
+
const raw =
|
|
115
|
+
typeof meta.colSpan === "function"
|
|
116
|
+
? meta.colSpan(cell.row)
|
|
117
|
+
: meta.colSpan;
|
|
118
|
+
const n = Math.floor(Number(raw));
|
|
119
|
+
if (Number.isFinite(n) && n >= 1) want = n;
|
|
120
|
+
}
|
|
121
|
+
const maxSpan = cells.length - i;
|
|
122
|
+
const colSpan = Math.min(want, maxSpan);
|
|
123
|
+
out.push({ cell, colSpan: Math.max(1, colSpan) });
|
|
124
|
+
i += Math.max(1, colSpan);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderDataTableBodyCells<TData extends RowData>(opts: {
|
|
130
|
+
row: Row<TData>;
|
|
131
|
+
getSubRows?: (row: TData) => TData[] | undefined;
|
|
132
|
+
firstDataCellId: (row: Row<TData>) => string | undefined;
|
|
133
|
+
fixedColStyles: Map<string, React.CSSProperties> | null | undefined;
|
|
134
|
+
flexWidth: string | undefined;
|
|
135
|
+
lockPxExactWidth: boolean;
|
|
136
|
+
resizable: boolean;
|
|
137
|
+
tableLayout: "auto" | "fixed" | "equal";
|
|
138
|
+
resizableSlackGrowColId: string | null;
|
|
139
|
+
cellClassName?: (
|
|
140
|
+
cell: Cell<TData, unknown>,
|
|
141
|
+
row: Row<TData>,
|
|
142
|
+
) => string | undefined;
|
|
143
|
+
onCellClick?: (
|
|
144
|
+
cell: Cell<TData, unknown>,
|
|
145
|
+
row: Row<TData>,
|
|
146
|
+
event: React.MouseEvent,
|
|
147
|
+
) => void;
|
|
148
|
+
}): React.ReactNode {
|
|
149
|
+
const {
|
|
150
|
+
row,
|
|
151
|
+
getSubRows,
|
|
152
|
+
firstDataCellId,
|
|
153
|
+
fixedColStyles,
|
|
154
|
+
flexWidth,
|
|
155
|
+
lockPxExactWidth,
|
|
156
|
+
resizable,
|
|
157
|
+
tableLayout,
|
|
158
|
+
resizableSlackGrowColId,
|
|
159
|
+
cellClassName,
|
|
160
|
+
onCellClick,
|
|
161
|
+
} = opts;
|
|
162
|
+
|
|
163
|
+
return getVisibleCellsForRender(row.getVisibleCells()).map(
|
|
164
|
+
({ cell, colSpan }) => {
|
|
165
|
+
const isFirstDataCell =
|
|
166
|
+
Boolean(getSubRows) && cell.id === firstDataCellId(row);
|
|
167
|
+
const usePixelResizeWidth =
|
|
168
|
+
resizable &&
|
|
169
|
+
tableLayout !== "equal" &&
|
|
170
|
+
!(
|
|
171
|
+
resizableSlackGrowColId != null &&
|
|
172
|
+
cell.column.id === resizableSlackGrowColId
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<TableCell
|
|
177
|
+
key={cell.id}
|
|
178
|
+
colSpan={colSpan > 1 ? colSpan : undefined}
|
|
179
|
+
style={resolveBodyCellWidthStyle(
|
|
180
|
+
cell.column.columnDef as ColumnDef<unknown, unknown>,
|
|
181
|
+
fixedColStyles?.get(cell.column.id),
|
|
182
|
+
flexWidth,
|
|
183
|
+
lockPxExactWidth,
|
|
184
|
+
usePixelResizeWidth ? cell.column.getSize() : undefined,
|
|
185
|
+
)}
|
|
186
|
+
className={cn(
|
|
187
|
+
columnMetaAlignClass(cell.column.columnDef.meta),
|
|
188
|
+
cell.column.columnDef.meta?.cellClassName,
|
|
189
|
+
cellClassName?.(cell, row),
|
|
190
|
+
)}
|
|
191
|
+
onClick={
|
|
192
|
+
onCellClick
|
|
193
|
+
? (e: React.MouseEvent) => onCellClick(cell, row, e)
|
|
194
|
+
: undefined
|
|
195
|
+
}
|
|
196
|
+
>
|
|
197
|
+
{isFirstDataCell ? (
|
|
198
|
+
<div
|
|
199
|
+
className="flex items-center gap-1"
|
|
200
|
+
style={{ paddingLeft: `${row.depth * 20}px` }}
|
|
201
|
+
>
|
|
202
|
+
{row.getCanExpand() ? (
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
onClick={row.getToggleExpandedHandler()}
|
|
206
|
+
className="flex items-center justify-center size-5 rounded hover:bg-table-c-hover shrink-0 transition-colors"
|
|
207
|
+
aria-label={row.getIsExpanded() ? "Collapse" : "Expand"}
|
|
208
|
+
>
|
|
209
|
+
{row.getIsExpanded() ? (
|
|
210
|
+
<ChevronDown className="size-4" />
|
|
211
|
+
) : (
|
|
212
|
+
<ChevronRight className="size-4" />
|
|
213
|
+
)}
|
|
214
|
+
</button>
|
|
215
|
+
) : (
|
|
216
|
+
<span className="size-5 shrink-0" />
|
|
217
|
+
)}
|
|
218
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
219
|
+
</div>
|
|
220
|
+
) : (
|
|
221
|
+
flexRender(cell.column.columnDef.cell, cell.getContext())
|
|
222
|
+
)}
|
|
223
|
+
</TableCell>
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
import * as Portal from "@radix-ui/react-portal";
|
|
16
230
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
231
|
+
DndContext,
|
|
232
|
+
DragEndEvent,
|
|
233
|
+
KeyboardSensor,
|
|
234
|
+
PointerSensor,
|
|
235
|
+
closestCenter,
|
|
236
|
+
useSensor,
|
|
237
|
+
useSensors,
|
|
238
|
+
} from "@dnd-kit/core";
|
|
239
|
+
import {
|
|
240
|
+
restrictToVerticalAxis,
|
|
241
|
+
restrictToParentElement,
|
|
242
|
+
} from "@dnd-kit/modifiers";
|
|
243
|
+
import {
|
|
244
|
+
SortableContext,
|
|
245
|
+
arrayMove,
|
|
246
|
+
sortableKeyboardCoordinates,
|
|
247
|
+
useSortable,
|
|
248
|
+
verticalListSortingStrategy,
|
|
249
|
+
} from "@dnd-kit/sortable";
|
|
250
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
251
|
+
|
|
252
|
+
import {
|
|
253
|
+
ArrowDown,
|
|
254
|
+
ArrowUp,
|
|
255
|
+
ArrowUpDown,
|
|
256
|
+
ChevronDown,
|
|
257
|
+
ChevronRight,
|
|
258
|
+
ClipboardList,
|
|
259
|
+
Columns3,
|
|
260
|
+
EllipsisVertical,
|
|
261
|
+
Equal,
|
|
262
|
+
EyeOff,
|
|
263
|
+
Loader2,
|
|
264
|
+
} from "lucide-react";
|
|
22
265
|
|
|
266
|
+
import ActionButton from "@/components/ActionButton/ActionButton";
|
|
267
|
+
import {
|
|
268
|
+
DropdownMenu,
|
|
269
|
+
DropdownMenuContent,
|
|
270
|
+
DropdownMenuItem,
|
|
271
|
+
DropdownMenuTrigger,
|
|
272
|
+
} from "@/components/DropdownMenu/DropdownMenu";
|
|
273
|
+
import { Checkbox } from "../Checkbox/Checkbox";
|
|
274
|
+
import { Switch } from "../Switch/Switch";
|
|
275
|
+
import Button from "../Button/Button";
|
|
23
276
|
import {
|
|
24
277
|
Table,
|
|
25
278
|
TableBody,
|
|
26
279
|
TableCell,
|
|
27
280
|
TableHead,
|
|
28
281
|
TableHeader,
|
|
282
|
+
TablePagination,
|
|
283
|
+
TablePaginationProps,
|
|
29
284
|
TableRow,
|
|
30
285
|
} from "../Table/Table";
|
|
286
|
+
import { cn } from "@/utils/cn";
|
|
287
|
+
import type {
|
|
288
|
+
EditableColumnDef,
|
|
289
|
+
DataTableEditingProps,
|
|
290
|
+
} from "./DataTable.editing.types";
|
|
291
|
+
import {
|
|
292
|
+
EditContext,
|
|
293
|
+
useDataTableEditing,
|
|
294
|
+
resolveEditableColumns,
|
|
295
|
+
detectEditableColumnIds,
|
|
296
|
+
} from "./DataTable.editing";
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Types
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
31
301
|
|
|
32
|
-
export
|
|
33
|
-
|
|
302
|
+
export type DataTablePaginationMode = "server" | "client" | "infinite";
|
|
303
|
+
|
|
304
|
+
export interface DataTableProps<TData, TValue>
|
|
305
|
+
extends DataTableEditingProps<TData> {
|
|
306
|
+
columns: (ColumnDef<TData, TValue> | EditableColumnDef<TData, TValue>)[];
|
|
34
307
|
data: TData[];
|
|
308
|
+
|
|
309
|
+
// --- Sorting ---
|
|
35
310
|
manualSorting?: boolean;
|
|
36
311
|
onSorting?: (sorting: SortingState) => void;
|
|
312
|
+
|
|
313
|
+
// --- Pagination ---
|
|
314
|
+
/**
|
|
315
|
+
* "client" – DataTable manages page state internally with TanStack
|
|
316
|
+
* "server" – caller controls page state; pass pageIndex/pageSize/totalCount
|
|
317
|
+
* "infinite" – no pagination bar; fetchMoreData enables infinite scroll
|
|
318
|
+
*/
|
|
319
|
+
paginationMode?: DataTablePaginationMode;
|
|
320
|
+
/** Required for "server" mode */
|
|
321
|
+
totalCount?: number;
|
|
322
|
+
pageIndex?: number;
|
|
323
|
+
pageSize?: number;
|
|
324
|
+
onPaginationChange?: (state: PaginationState) => void;
|
|
325
|
+
pageSizeOptions?: number[];
|
|
326
|
+
/** Called when user scrolls near bottom (infinite mode). */
|
|
37
327
|
fetchMoreData?: () => void;
|
|
328
|
+
/**
|
|
329
|
+
* Threshold in pixels from the bottom of the scroll container at which
|
|
330
|
+
* `fetchMoreData` is triggered. Defaults to 10px.
|
|
331
|
+
*/
|
|
332
|
+
fetchMoreOffset?: number;
|
|
333
|
+
/**
|
|
334
|
+
* When `paginationMode` is `"infinite"`, shows a built-in footer row (spinner +
|
|
335
|
+
* label) and suppresses additional `fetchMoreData` calls until this is false again.
|
|
336
|
+
*/
|
|
337
|
+
fetchingMore?: boolean;
|
|
338
|
+
/** Accessible label next to the spinner (default: "Loading more…"). */
|
|
339
|
+
fetchingMoreLabel?: string;
|
|
340
|
+
/**
|
|
341
|
+
* When there are no rows yet, shows a built-in loading state instead of the
|
|
342
|
+
* empty placeholder. Also suppresses `fetchMoreData` while true (infinite mode).
|
|
343
|
+
*/
|
|
344
|
+
loading?: boolean;
|
|
345
|
+
/** Label for the initial-loading state (default: "Loading…"). */
|
|
346
|
+
loadingLabel?: string;
|
|
347
|
+
|
|
348
|
+
// --- Virtualization (experimental) ---
|
|
349
|
+
/**
|
|
350
|
+
* When true, DataTable will only render a vertical window of rows based on
|
|
351
|
+
* the scroll position of its internal scroll container. This is a basic
|
|
352
|
+
* fixed-row-height virtualizer intended for large but finite lists.
|
|
353
|
+
*
|
|
354
|
+
* Notes / limitations:
|
|
355
|
+
* - Only vertical row virtualization is supported.
|
|
356
|
+
* - Assumes approximately fixed row height; pass `virtualRowEstimate` to tune.
|
|
357
|
+
* - Works with selection, row actions, and basic infinite scroll, but more
|
|
358
|
+
* complex combinations (tree rows, reorder, etc.) may have visual quirks.
|
|
359
|
+
*/
|
|
360
|
+
virtualized?: boolean;
|
|
361
|
+
/**
|
|
362
|
+
* Estimated height in pixels for a single table row when `virtualized` is
|
|
363
|
+
* true. Used to compute how many rows fit in the viewport and the spacer
|
|
364
|
+
* heights above/below the rendered window.
|
|
365
|
+
* @default 40
|
|
366
|
+
*/
|
|
367
|
+
virtualRowEstimate?: number;
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Optional row id(s) to highlight visually. Highlighting is purely cosmetic
|
|
371
|
+
* and does not affect selection state.
|
|
372
|
+
*/
|
|
373
|
+
highlightRowId?: string | string[];
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* When true and `highlightRowId` is set, moving the mouse pointer out of the
|
|
377
|
+
* scroll area will automatically scroll the first highlighted row back into
|
|
378
|
+
* view (centered).
|
|
379
|
+
*/
|
|
380
|
+
scrollToHighlightOnMouseLeave?: boolean;
|
|
381
|
+
|
|
382
|
+
// --- Selection ---
|
|
383
|
+
selectable?: boolean;
|
|
384
|
+
onRowSelectionChange?: (rows: RowSelectionState) => void;
|
|
385
|
+
|
|
386
|
+
// --- Row actions ---
|
|
387
|
+
/** Render actions into the last fixed-width column. Receives the TanStack Row. */
|
|
388
|
+
rowActions?: (row: Row<TData>) => React.ReactNode;
|
|
389
|
+
|
|
390
|
+
// --- Row reorder ---
|
|
391
|
+
/**
|
|
392
|
+
* Enable drag-to-reorder rows. A grip handle column is prepended automatically.
|
|
393
|
+
* Requires each data item to have a unique `id` field (string | number)
|
|
394
|
+
* or supply `getRowId` to derive one.
|
|
395
|
+
*/
|
|
396
|
+
reorderable?: boolean;
|
|
397
|
+
/** Return a unique string id for each row. Defaults to `(row) => row.id`. */
|
|
398
|
+
getRowId?: (row: TData) => string;
|
|
399
|
+
/** Called after the user drops a row into a new position. Receives the reordered data array. */
|
|
400
|
+
onRowReorder?: (data: TData[]) => void;
|
|
401
|
+
/**
|
|
402
|
+
* When `reorderable`, rows that return `true` are not draggable and are
|
|
403
|
+
* omitted from the sortable context (e.g. a pinned footer "add" row).
|
|
404
|
+
* Those rows are also kept **after** all sortable rows whenever column sort
|
|
405
|
+
* is applied (order among locked rows follows original `data` order).
|
|
406
|
+
*/
|
|
407
|
+
isRowReorderLocked?: (row: Row<TData>) => boolean;
|
|
408
|
+
|
|
409
|
+
// --- Row & cell click ---
|
|
410
|
+
/** Fired when a body row is clicked. */
|
|
411
|
+
onRowClick?: (row: Row<TData>, event: React.MouseEvent) => void;
|
|
412
|
+
/** Fired when an individual body cell is clicked. */
|
|
413
|
+
onCellClick?: (
|
|
414
|
+
cell: Cell<TData, unknown>,
|
|
415
|
+
row: Row<TData>,
|
|
416
|
+
event: React.MouseEvent,
|
|
417
|
+
) => void;
|
|
418
|
+
|
|
419
|
+
// --- Expandable rows ---
|
|
420
|
+
/** Return child rows to enable tree expansion */
|
|
421
|
+
getSubRows?: (row: TData) => TData[] | undefined;
|
|
422
|
+
/** Initial expanded state — `true` = all expanded, `{}` = all collapsed (uncontrolled) */
|
|
423
|
+
defaultExpanded?: ExpandedState | boolean;
|
|
424
|
+
/** Controlled expanded state — omit to let DataTable manage internally */
|
|
425
|
+
expanded?: ExpandedState;
|
|
426
|
+
onExpandedChange?: (expanded: ExpandedState) => void;
|
|
427
|
+
|
|
428
|
+
// --- Visual ---
|
|
429
|
+
/**
|
|
430
|
+
* Rounded frame + `border-table-c-border` around the scroll area and pagination
|
|
431
|
+
* (same token as primitive `Table` `bordered`). Set `false` when embedding in
|
|
432
|
+
* another bordered surface.
|
|
433
|
+
*/
|
|
434
|
+
bordered?: boolean;
|
|
435
|
+
/**
|
|
436
|
+
* `"panel"` sets `data-surface="panel"` for table token overrides (modal /
|
|
437
|
+
* drawer). `"default"` leaves surface to ancestors.
|
|
438
|
+
*/
|
|
439
|
+
surface?: "default" | "panel";
|
|
440
|
+
/** Alternate row background colours (RowA / RowB) */
|
|
441
|
+
striped?: boolean;
|
|
442
|
+
/** Add vertical column dividers */
|
|
443
|
+
divided?: boolean;
|
|
444
|
+
/** Return a className string to apply to a specific body row. */
|
|
445
|
+
rowClassName?: (row: Row<TData>, index: number) => string | undefined;
|
|
446
|
+
/** Return a className string to apply to a specific body cell. */
|
|
447
|
+
cellClassName?: (
|
|
448
|
+
cell: Cell<TData, unknown>,
|
|
449
|
+
row: Row<TData>,
|
|
450
|
+
) => string | undefined;
|
|
451
|
+
/** Return a className string to apply to a specific column header cell (`<th>`). */
|
|
452
|
+
headerCellClassName?: (header: Header<TData, unknown>) => string | undefined;
|
|
453
|
+
/** Additional className for the header section (`<thead>`). */
|
|
454
|
+
headerClassName?: string;
|
|
455
|
+
/** Additional className for the header row (`<tr>` inside `<thead>`). */
|
|
456
|
+
headerRowClassName?: string;
|
|
457
|
+
/**
|
|
458
|
+
* Controls when the sort indicator button is visible on sortable columns.
|
|
459
|
+
* - `"hover"` – shown only when hovering the column header (default)
|
|
460
|
+
* - `"always"` – always visible
|
|
461
|
+
*/
|
|
462
|
+
sortIndicatorVisibility?: "always" | "hover";
|
|
463
|
+
/**
|
|
464
|
+
* CSS `table-layout` mode.
|
|
465
|
+
* - `"auto"` – browser sizes columns based on content (default)
|
|
466
|
+
* - `"fixed"` – non-`exactWidth` columns use their `size` as pixel widths;
|
|
467
|
+
* the **last** visible non-exact column absorbs leftover width so the table
|
|
468
|
+
* stays full-width. `meta.exactWidth` (px) columns stay locked.
|
|
469
|
+
* - `"equal"` – all visible columns without `meta.exactWidth` share the
|
|
470
|
+
* remaining table width equally via
|
|
471
|
+
* `calc((100% - <sum of exactWidth>px) / <flex column count>)`.
|
|
472
|
+
* Columns with `meta.exactWidth` are still locked to their exact value.
|
|
473
|
+
* With `resizable`, drag-to-resize is disabled for `equal` so widths stay
|
|
474
|
+
* equal; `fixed`/`auto` keep column resize handles.
|
|
475
|
+
*/
|
|
476
|
+
tableLayout?: "auto" | "fixed" | "equal";
|
|
477
|
+
/**
|
|
478
|
+
* Show a column menu (⋮) on each hideable column header: first a dropdown
|
|
479
|
+
* (“Hide … column”, “Manage columns”), then the full manage panel from the
|
|
480
|
+
* second action. Pass `true` or `ColumnManagementOptions` to tune features.
|
|
481
|
+
*/
|
|
482
|
+
columnManagement?: boolean | ColumnManagementOptions;
|
|
483
|
+
/** Allow user to drag-resize column widths */
|
|
484
|
+
resizable?: boolean;
|
|
485
|
+
/**
|
|
486
|
+
* Global minimum column width in pixels when `resizable` is true.
|
|
487
|
+
* Individual columns can override with `minSize` in their column def.
|
|
488
|
+
* @default 60
|
|
489
|
+
*/
|
|
490
|
+
columnMinSize?: number;
|
|
491
|
+
/**
|
|
492
|
+
* Global maximum column width in pixels when `resizable` is true.
|
|
493
|
+
* Individual columns can override with `maxSize` in their column def.
|
|
494
|
+
* @default Number.MAX_SAFE_INTEGER (no limit)
|
|
495
|
+
*/
|
|
496
|
+
columnMaxSize?: number;
|
|
497
|
+
|
|
498
|
+
className?: string;
|
|
499
|
+
/**
|
|
500
|
+
* Sets `data-testid` on the root container and derives sub-ids for the
|
|
501
|
+
* table (`{testId}-table`), header (`{testId}-thead`), body (`{testId}-tbody`),
|
|
502
|
+
* rows (`{testId}-row-{rowId}`), and cells (`{testId}-cell-{rowId}-{colId}`).
|
|
503
|
+
*/
|
|
504
|
+
testId?: string;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// Checkbox column builder
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
function buildCheckboxColumn<TData>(): ColumnDef<TData, unknown> {
|
|
512
|
+
return {
|
|
513
|
+
id: "__select__",
|
|
514
|
+
header: ({ table }) => (
|
|
515
|
+
<Checkbox
|
|
516
|
+
checked={
|
|
517
|
+
table.getIsSomeRowsSelected()
|
|
518
|
+
? "indeterminate"
|
|
519
|
+
: table.getIsAllRowsSelected()
|
|
520
|
+
}
|
|
521
|
+
onCheckedChange={(v) => table.toggleAllRowsSelected(!!v)}
|
|
522
|
+
aria-label="Select all"
|
|
523
|
+
/>
|
|
524
|
+
),
|
|
525
|
+
cell: ({ row }) => (
|
|
526
|
+
<Checkbox
|
|
527
|
+
checked={row.getIsSelected()}
|
|
528
|
+
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
|
529
|
+
aria-label="Select row"
|
|
530
|
+
disabled={!row.getCanSelect()}
|
|
531
|
+
/>
|
|
532
|
+
),
|
|
533
|
+
enableSorting: false,
|
|
534
|
+
enableHiding: false,
|
|
535
|
+
enableResizing: false,
|
|
536
|
+
size: 42,
|
|
537
|
+
maxSize: 42,
|
|
538
|
+
minSize: 42,
|
|
539
|
+
meta: { exactWidth: 42 },
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// Row-actions column builder
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
function buildActionsColumn<TData>(
|
|
548
|
+
render: (row: Row<TData>) => React.ReactNode,
|
|
549
|
+
): ColumnDef<TData, unknown> {
|
|
550
|
+
return {
|
|
551
|
+
id: "__actions__",
|
|
552
|
+
header: () => null,
|
|
553
|
+
cell: ({ row }) => (
|
|
554
|
+
<div className="flex items-center justify-center gap-1">
|
|
555
|
+
{render(row)}
|
|
556
|
+
</div>
|
|
557
|
+
),
|
|
558
|
+
enableSorting: false,
|
|
559
|
+
enableHiding: false,
|
|
560
|
+
enableResizing: false,
|
|
561
|
+
size: 80,
|
|
562
|
+
maxSize: 80,
|
|
563
|
+
minSize: 80,
|
|
564
|
+
meta: { exactWidth: 80 },
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// Row-reorder drag-handle column
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
function buildReorderColumn<TData>(): ColumnDef<TData, unknown> {
|
|
573
|
+
return {
|
|
574
|
+
id: "__reorder__",
|
|
575
|
+
header: () => null,
|
|
576
|
+
cell: ({ row }) => <RowDragHandle rowId={row.id} />,
|
|
577
|
+
enableSorting: false,
|
|
578
|
+
enableHiding: false,
|
|
579
|
+
enableResizing: false,
|
|
580
|
+
size: 54,
|
|
581
|
+
maxSize: 54,
|
|
582
|
+
minSize: 54,
|
|
583
|
+
meta: { exactWidth: 54 },
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function RowDragHandle({ rowId }: { rowId: string }) {
|
|
588
|
+
const { attributes, listeners, setNodeRef, isDragging } = useSortable({
|
|
589
|
+
id: rowId,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
return (
|
|
593
|
+
<ActionButton
|
|
594
|
+
ref={setNodeRef}
|
|
595
|
+
{...attributes}
|
|
596
|
+
{...listeners}
|
|
597
|
+
variant="icon"
|
|
598
|
+
size="sm"
|
|
599
|
+
active={isDragging}
|
|
600
|
+
className={cn(
|
|
601
|
+
"touch-none",
|
|
602
|
+
isDragging ? "cursor-grabbing" : "cursor-grab",
|
|
603
|
+
)}
|
|
604
|
+
aria-label="Drag to reorder"
|
|
605
|
+
>
|
|
606
|
+
<Equal className="!size-4" />
|
|
607
|
+
</ActionButton>
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
// SortableTableRow — wraps a <TableRow> with dnd-kit sortable transform
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
function SortableTableRow({
|
|
616
|
+
id,
|
|
617
|
+
children,
|
|
618
|
+
...props
|
|
619
|
+
}: { id: string } & React.ComponentProps<typeof TableRow>) {
|
|
620
|
+
const { transform, transition, isDragging, setNodeRef } = useSortable({
|
|
621
|
+
id,
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const style: React.CSSProperties = {
|
|
625
|
+
transform: CSS.Transform.toString(
|
|
626
|
+
transform ? { ...transform, scaleX: 1, scaleY: 1 } : null,
|
|
627
|
+
),
|
|
628
|
+
transition,
|
|
629
|
+
opacity: isDragging ? 0.5 : 1,
|
|
630
|
+
position: "relative",
|
|
631
|
+
zIndex: isDragging ? 50 : undefined,
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
return (
|
|
635
|
+
<TableRow ref={setNodeRef} style={style} {...props}>
|
|
636
|
+
{children}
|
|
637
|
+
</TableRow>
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// ColumnManagementOptions
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
export interface ColumnManagementOptions {
|
|
646
|
+
/** Show drag-handle to reorder columns. @default true */
|
|
647
|
+
reorder?: boolean;
|
|
648
|
+
/** Show per-column visibility Switch. @default true */
|
|
649
|
+
visibility?: boolean;
|
|
650
|
+
/** Show "Hide all" button. @default true */
|
|
651
|
+
hideAll?: boolean;
|
|
652
|
+
/** Show "Show all" button. @default true */
|
|
653
|
+
showAll?: boolean;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const PINNED_START = ["__reorder__", "__select__"] as const;
|
|
657
|
+
const PINNED_END = ["__actions__"] as const;
|
|
658
|
+
|
|
659
|
+
/** Label for column-management menu (“Hide {name} column”). */
|
|
660
|
+
function manageColumnHeaderLabel<TData>(
|
|
661
|
+
header: Header<TData, unknown>,
|
|
662
|
+
): string {
|
|
663
|
+
const h = header.column.columnDef.header;
|
|
664
|
+
return typeof h === "string" ? h : header.column.id;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* If a column carries `meta.exactWidth`, return CSS that locks the cell to
|
|
669
|
+
* exactly that width. Accepts a number (px) or any CSS value / calc().
|
|
670
|
+
*
|
|
671
|
+
* Works in every `tableLayout` mode — `minWidth` + `maxWidth` constrain the
|
|
672
|
+
* cell even in `table-layout: auto` without requiring `table-fixed` on the
|
|
673
|
+
* table element. `overflow: hidden` prevents content from pushing wider.
|
|
674
|
+
*/
|
|
675
|
+
function getExactWidthStyle(
|
|
676
|
+
colDef: ColumnDef<unknown, unknown>,
|
|
677
|
+
lock: boolean,
|
|
678
|
+
): React.CSSProperties | undefined {
|
|
679
|
+
const w = (colDef.meta as { exactWidth?: number | string } | undefined)
|
|
680
|
+
?.exactWidth;
|
|
681
|
+
if (w == null) return undefined;
|
|
682
|
+
|
|
683
|
+
// Only "lock" when the value is safely lockable (number or px string).
|
|
684
|
+
// For `calc()`, `%`, `rem`, etc. we apply only `width` even in fixed mode,
|
|
685
|
+
// otherwise the column becomes rigid in ways that surprise callers.
|
|
686
|
+
const lockable = exactWidthToPx(w) != null;
|
|
687
|
+
const shouldLock = lock && lockable;
|
|
688
|
+
|
|
689
|
+
// Lockable = number or "123px" — set all three to that fixed width.
|
|
690
|
+
// Do NOT use `calc(100% - ${w}px)` here: that means “fill minus w”, not “width = w”.
|
|
691
|
+
const locked = typeof w === "number" ? `${w}px` : (w as string).trim();
|
|
692
|
+
|
|
693
|
+
return shouldLock
|
|
694
|
+
? {
|
|
695
|
+
width: locked,
|
|
696
|
+
minWidth: locked,
|
|
697
|
+
maxWidth: locked,
|
|
698
|
+
overflow: "hidden",
|
|
699
|
+
boxSizing: "border-box",
|
|
700
|
+
}
|
|
701
|
+
: {
|
|
702
|
+
width: w,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Convert `meta.exactWidth` to a numeric px value (only when possible).
|
|
708
|
+
* - `number` => px
|
|
709
|
+
* - `"123px"` => 123
|
|
710
|
+
* - other string formats => null (can't be summed at build time)
|
|
711
|
+
*/
|
|
712
|
+
function exactWidthToPx(
|
|
713
|
+
exactWidth: number | string | undefined,
|
|
714
|
+
): number | null {
|
|
715
|
+
if (typeof exactWidth === "number") {
|
|
716
|
+
return Number.isFinite(exactWidth) ? exactWidth : null;
|
|
717
|
+
}
|
|
718
|
+
if (typeof exactWidth === "string") {
|
|
719
|
+
const m = exactWidth.trim().match(/^(-?\d*\.?\d+)\s*px$/i);
|
|
720
|
+
return m ? parseFloat(m[1]) : null;
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Compute the auto-fill width for columns that do NOT have `exactWidth`.
|
|
727
|
+
* Formula: `calc((100% - <totalExactPx>px) / <flexCount>)`
|
|
728
|
+
*
|
|
729
|
+
* Only values convertible to numeric px participate in the subtraction.
|
|
730
|
+
*/
|
|
731
|
+
function useFlexColumnWidth<TData>(
|
|
732
|
+
table: TanstackTable<TData>,
|
|
733
|
+
): string | undefined {
|
|
734
|
+
return React.useMemo(() => {
|
|
735
|
+
const visible = table.getVisibleLeafColumns();
|
|
736
|
+
let totalExactPx = 0;
|
|
737
|
+
let flexCount = 0;
|
|
738
|
+
for (const col of visible) {
|
|
739
|
+
const ew = (
|
|
740
|
+
col.columnDef.meta as { exactWidth?: number | string } | undefined
|
|
741
|
+
)?.exactWidth;
|
|
742
|
+
const asPx = exactWidthToPx(ew);
|
|
743
|
+
if (asPx != null) {
|
|
744
|
+
totalExactPx += asPx;
|
|
745
|
+
} else if (ew == null) {
|
|
746
|
+
flexCount += 1;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (flexCount === 0) return undefined;
|
|
750
|
+
if (totalExactPx === 0) return undefined;
|
|
751
|
+
return `calc((100% - ${totalExactPx}px) / ${flexCount})`;
|
|
752
|
+
}, [table.getVisibleLeafColumns()]);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Resolve the inline style for a **header** cell (th).
|
|
757
|
+
* - Has `exactWidth` → locked width (min/max/overflow)
|
|
758
|
+
* - `resizableWidth` (TanStack `getSize()`) → drag-resize width (`fixed`/`auto` only)
|
|
759
|
+
* - flexWidth available → equal calc() (`equal` + `resizable` uses this, not getSize)
|
|
760
|
+
* - Otherwise → fallback (e.g. fixed layout map or undefined)
|
|
761
|
+
*/
|
|
762
|
+
function resolveHeaderWidthStyle(
|
|
763
|
+
colDef: ColumnDef<unknown, unknown>,
|
|
764
|
+
flexWidth: string | undefined,
|
|
765
|
+
fallback: React.CSSProperties | undefined,
|
|
766
|
+
lockExactWidth: boolean,
|
|
767
|
+
resizableWidth?: number,
|
|
768
|
+
): React.CSSProperties | undefined {
|
|
769
|
+
const exact = getExactWidthStyle(colDef, lockExactWidth);
|
|
770
|
+
if (exact) return exact;
|
|
771
|
+
if (resizableWidth != null) {
|
|
772
|
+
const colMax = colDef.maxSize;
|
|
773
|
+
return {
|
|
774
|
+
width: resizableWidth,
|
|
775
|
+
...(colMax != null &&
|
|
776
|
+
colMax !== Number.MAX_SAFE_INTEGER && { maxWidth: colMax }),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
if (flexWidth) return { width: flexWidth };
|
|
780
|
+
return fallback;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Resolve the inline style for a **body** cell (td).
|
|
785
|
+
* - Has `exactWidth` → locked width via min/maxWidth + overflow:hidden
|
|
786
|
+
* - `resizableWidth` (TanStack column `getSize()`) → matches header when resizable
|
|
787
|
+
* - fixedCellStyle available → "fixed" layout map
|
|
788
|
+
* - flexWidth available → equal calc()
|
|
789
|
+
* - Otherwise → undefined
|
|
790
|
+
*/
|
|
791
|
+
function resolveBodyCellWidthStyle(
|
|
792
|
+
colDef: ColumnDef<unknown, unknown>,
|
|
793
|
+
fixedCellStyle: React.CSSProperties | undefined,
|
|
794
|
+
flexWidth: string | undefined,
|
|
795
|
+
lockExactWidth: boolean,
|
|
796
|
+
resizableWidth?: number,
|
|
797
|
+
): React.CSSProperties | undefined {
|
|
798
|
+
const exact = getExactWidthStyle(colDef, lockExactWidth);
|
|
799
|
+
if (exact) return exact;
|
|
800
|
+
if (resizableWidth != null) {
|
|
801
|
+
const colMax = colDef.maxSize;
|
|
802
|
+
return {
|
|
803
|
+
width: resizableWidth,
|
|
804
|
+
...(colMax != null &&
|
|
805
|
+
colMax !== Number.MAX_SAFE_INTEGER && { maxWidth: colMax }),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
if (fixedCellStyle) return fixedCellStyle;
|
|
809
|
+
if (flexWidth) return { width: flexWidth };
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
// ManageColumnPanel — "Manage column" popover content (Figma DS node 9474:10149)
|
|
815
|
+
// Each hideable column header renders its own ⋮ trigger.
|
|
816
|
+
// anchorColumnId: the column whose ⋮ was clicked — its Switch is disabled so
|
|
817
|
+
// the user cannot hide it while the panel is open (prevents trigger unmount).
|
|
818
|
+
// Drag is closed immediately on dragStart so the anchor never moves off-screen.
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
type LeafColumn<TData> = ReturnType<
|
|
822
|
+
TanstackTable<TData>["getAllLeafColumns"]
|
|
823
|
+
>[number];
|
|
824
|
+
|
|
825
|
+
function SortableColumnRow<TData>({
|
|
826
|
+
col,
|
|
827
|
+
label,
|
|
828
|
+
isLastVisible,
|
|
829
|
+
showReorder,
|
|
830
|
+
showVisibility,
|
|
831
|
+
}: {
|
|
832
|
+
col: LeafColumn<TData>;
|
|
833
|
+
label: string;
|
|
834
|
+
isLastVisible: boolean;
|
|
835
|
+
showReorder: boolean;
|
|
836
|
+
showVisibility: boolean;
|
|
837
|
+
}) {
|
|
838
|
+
const {
|
|
839
|
+
attributes,
|
|
840
|
+
listeners,
|
|
841
|
+
setNodeRef,
|
|
842
|
+
setActivatorNodeRef,
|
|
843
|
+
transform,
|
|
844
|
+
transition,
|
|
845
|
+
isDragging,
|
|
846
|
+
} = useSortable({ id: col.id });
|
|
847
|
+
|
|
848
|
+
const style: React.CSSProperties = {
|
|
849
|
+
transform: isDragging
|
|
850
|
+
? `translateY(${transform?.y ?? 0}px)`
|
|
851
|
+
: CSS.Transform.toString(transform),
|
|
852
|
+
transition: isDragging ? undefined : transition,
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
return (
|
|
856
|
+
<div
|
|
857
|
+
ref={setNodeRef}
|
|
858
|
+
style={style}
|
|
859
|
+
className={cn(
|
|
860
|
+
"flex h-14 items-center gap-4 pl-6 pr-8 bg-modal-surface",
|
|
861
|
+
"relative select-none",
|
|
862
|
+
isDragging &&
|
|
863
|
+
"z-50 border border-primary-500/40 shadow-[0_8px_24px_-4px_rgba(0,0,0,0.24)] scale-[1.01]",
|
|
864
|
+
)}
|
|
865
|
+
>
|
|
866
|
+
{showReorder && (
|
|
867
|
+
<ActionButton
|
|
868
|
+
ref={setActivatorNodeRef}
|
|
869
|
+
{...attributes}
|
|
870
|
+
{...listeners}
|
|
871
|
+
variant="icon"
|
|
872
|
+
size="sm"
|
|
873
|
+
active={isDragging}
|
|
874
|
+
aria-label={`Drag to reorder ${label}`}
|
|
875
|
+
className={cn(
|
|
876
|
+
"shrink-0 touch-none",
|
|
877
|
+
isDragging ? "cursor-grabbing" : "cursor-grab",
|
|
878
|
+
)}
|
|
879
|
+
>
|
|
880
|
+
<Equal className="size-[14px]" />
|
|
881
|
+
</ActionButton>
|
|
882
|
+
)}
|
|
883
|
+
{showVisibility && (
|
|
884
|
+
<Switch
|
|
885
|
+
checked={col.getIsVisible()}
|
|
886
|
+
onCheckedChange={(v) => col.toggleVisibility(v)}
|
|
887
|
+
disabled={isLastVisible}
|
|
888
|
+
/>
|
|
889
|
+
)}
|
|
890
|
+
<span
|
|
891
|
+
className={cn(
|
|
892
|
+
"flex-1 typography-subtitle4",
|
|
893
|
+
isDragging ? "text-text-contrast-max" : "text-text-g-contrast-high",
|
|
894
|
+
)}
|
|
895
|
+
>
|
|
896
|
+
{label}
|
|
897
|
+
</span>
|
|
898
|
+
</div>
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function ManageColumnPanel<TData>({
|
|
903
|
+
table,
|
|
904
|
+
onClose,
|
|
905
|
+
maxListHeight = 400,
|
|
906
|
+
options = {},
|
|
907
|
+
}: {
|
|
908
|
+
table: TanstackTable<TData>;
|
|
909
|
+
onClose: () => void;
|
|
910
|
+
maxListHeight?: number;
|
|
911
|
+
options?: ColumnManagementOptions;
|
|
912
|
+
}) {
|
|
913
|
+
const {
|
|
914
|
+
reorder = true,
|
|
915
|
+
visibility = true,
|
|
916
|
+
hideAll = true,
|
|
917
|
+
showAll = true,
|
|
918
|
+
} = options;
|
|
919
|
+
const hideable = table.getAllLeafColumns().filter((col) => col.getCanHide());
|
|
920
|
+
const visibleCount = hideable.filter((col) => col.getIsVisible()).length;
|
|
921
|
+
|
|
922
|
+
const headerLabel = (col: LeafColumn<TData>) => {
|
|
923
|
+
const h = col.columnDef.header;
|
|
924
|
+
return typeof h === "string" ? h : col.id;
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
const sensors = useSensors(
|
|
928
|
+
useSensor(PointerSensor),
|
|
929
|
+
useSensor(KeyboardSensor, {
|
|
930
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
931
|
+
}),
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
935
|
+
const { active, over } = event;
|
|
936
|
+
if (!over || active.id === over.id) return;
|
|
937
|
+
|
|
938
|
+
const allColIds = table.getAllLeafColumns().map((c) => c.id);
|
|
939
|
+
const movable = allColIds.filter(
|
|
940
|
+
(id) =>
|
|
941
|
+
!(PINNED_START as readonly string[]).includes(id) &&
|
|
942
|
+
!(PINNED_END as readonly string[]).includes(id),
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
const oldIndex = movable.indexOf(active.id as string);
|
|
946
|
+
const newIndex = movable.indexOf(over.id as string);
|
|
947
|
+
if (oldIndex === -1 || newIndex === -1) return;
|
|
948
|
+
|
|
949
|
+
const fixedStart = allColIds.filter((id) =>
|
|
950
|
+
(PINNED_START as readonly string[]).includes(id),
|
|
951
|
+
);
|
|
952
|
+
const fixedEnd = allColIds.filter((id) =>
|
|
953
|
+
(PINNED_END as readonly string[]).includes(id),
|
|
954
|
+
);
|
|
955
|
+
table.setColumnOrder([
|
|
956
|
+
...fixedStart,
|
|
957
|
+
...arrayMove(movable, oldIndex, newIndex),
|
|
958
|
+
...fixedEnd,
|
|
959
|
+
]);
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
return (
|
|
963
|
+
<>
|
|
964
|
+
{/* Header */}
|
|
965
|
+
<div className="flex items-center gap-2 px-6 pt-4 pb-3 border-b border-modal-line">
|
|
966
|
+
<span className="flex-1 typography-subtitle3 text-text-contrast-max">
|
|
967
|
+
Manage column
|
|
968
|
+
</span>
|
|
969
|
+
{hideAll && (
|
|
970
|
+
<Button
|
|
971
|
+
variant="text"
|
|
972
|
+
color="secondary"
|
|
973
|
+
size="sm"
|
|
974
|
+
disabled={visibleCount <= 1}
|
|
975
|
+
onClick={() => {
|
|
976
|
+
const firstVisible =
|
|
977
|
+
hideable.find((col) => col.getIsVisible()) ?? hideable[0];
|
|
978
|
+
hideable.forEach((col) =>
|
|
979
|
+
col.toggleVisibility(col.id === firstVisible.id),
|
|
980
|
+
);
|
|
981
|
+
}}
|
|
982
|
+
>
|
|
983
|
+
Hide all
|
|
984
|
+
</Button>
|
|
985
|
+
)}
|
|
986
|
+
{showAll && (
|
|
987
|
+
<Button
|
|
988
|
+
variant="text"
|
|
989
|
+
color="primary"
|
|
990
|
+
size="sm"
|
|
991
|
+
onClick={() => table.toggleAllColumnsVisible(true)}
|
|
992
|
+
>
|
|
993
|
+
Show all
|
|
994
|
+
</Button>
|
|
995
|
+
)}
|
|
996
|
+
<Button variant="outline" color="primary" size="sm" onClick={onClose}>
|
|
997
|
+
Done
|
|
998
|
+
</Button>
|
|
999
|
+
</div>
|
|
1000
|
+
|
|
1001
|
+
{/* Column rows — drag-sortable, Y-axis only */}
|
|
1002
|
+
<DndContext
|
|
1003
|
+
sensors={sensors}
|
|
1004
|
+
collisionDetection={closestCenter}
|
|
1005
|
+
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
|
1006
|
+
onDragEnd={handleDragEnd}
|
|
1007
|
+
>
|
|
1008
|
+
<SortableContext
|
|
1009
|
+
items={hideable.map((c) => c.id)}
|
|
1010
|
+
strategy={verticalListSortingStrategy}
|
|
1011
|
+
>
|
|
1012
|
+
<div
|
|
1013
|
+
className="overflow-y-auto ui-scrollbar"
|
|
1014
|
+
style={{ maxHeight: maxListHeight }}
|
|
1015
|
+
>
|
|
1016
|
+
{hideable.map((col) => (
|
|
1017
|
+
<SortableColumnRow
|
|
1018
|
+
key={col.id}
|
|
1019
|
+
col={col}
|
|
1020
|
+
label={headerLabel(col)}
|
|
1021
|
+
isLastVisible={col.getIsVisible() && visibleCount === 1}
|
|
1022
|
+
showReorder={reorder}
|
|
1023
|
+
showVisibility={visibility}
|
|
1024
|
+
/>
|
|
1025
|
+
))}
|
|
1026
|
+
</div>
|
|
1027
|
+
</SortableContext>
|
|
1028
|
+
</DndContext>
|
|
1029
|
+
</>
|
|
1030
|
+
);
|
|
38
1031
|
}
|
|
39
1032
|
|
|
1033
|
+
// ---------------------------------------------------------------------------
|
|
1034
|
+
// DataTable
|
|
1035
|
+
// ---------------------------------------------------------------------------
|
|
1036
|
+
|
|
40
1037
|
export function DataTable<TData, TValue>({
|
|
41
1038
|
data,
|
|
42
1039
|
columns,
|
|
43
1040
|
manualSorting = false,
|
|
44
1041
|
onSorting,
|
|
1042
|
+
paginationMode = "infinite",
|
|
1043
|
+
totalCount,
|
|
1044
|
+
pageIndex: controlledPageIndex,
|
|
1045
|
+
pageSize: controlledPageSize,
|
|
1046
|
+
onPaginationChange,
|
|
1047
|
+
pageSizeOptions,
|
|
45
1048
|
fetchMoreData,
|
|
1049
|
+
fetchMoreOffset,
|
|
1050
|
+
fetchingMore = false,
|
|
1051
|
+
fetchingMoreLabel = "Loading more…",
|
|
1052
|
+
loading = false,
|
|
1053
|
+
loadingLabel = "Loading…",
|
|
1054
|
+
highlightRowId,
|
|
1055
|
+
scrollToHighlightOnMouseLeave = false,
|
|
1056
|
+
selectable = false,
|
|
1057
|
+
onRowSelectionChange,
|
|
1058
|
+
rowActions,
|
|
1059
|
+
reorderable = false,
|
|
1060
|
+
getRowId: getRowIdProp,
|
|
1061
|
+
onRowReorder,
|
|
1062
|
+
isRowReorderLocked,
|
|
1063
|
+
onRowClick,
|
|
1064
|
+
onCellClick,
|
|
1065
|
+
getSubRows,
|
|
1066
|
+
defaultExpanded,
|
|
1067
|
+
expanded: controlledExpanded,
|
|
1068
|
+
onExpandedChange,
|
|
1069
|
+
bordered = true,
|
|
1070
|
+
surface = "default",
|
|
1071
|
+
striped = false,
|
|
1072
|
+
divided = true,
|
|
1073
|
+
rowClassName,
|
|
1074
|
+
cellClassName,
|
|
1075
|
+
headerCellClassName,
|
|
1076
|
+
headerClassName,
|
|
1077
|
+
headerRowClassName,
|
|
1078
|
+
sortIndicatorVisibility = "hover",
|
|
1079
|
+
tableLayout = "auto",
|
|
1080
|
+
columnManagement: columnManagementProp = false,
|
|
1081
|
+
resizable = false,
|
|
1082
|
+
columnMinSize = 60,
|
|
1083
|
+
columnMaxSize = Number.MAX_SAFE_INTEGER,
|
|
1084
|
+
virtualized = false,
|
|
1085
|
+
virtualRowEstimate,
|
|
1086
|
+
className,
|
|
1087
|
+
enableEditing = false,
|
|
1088
|
+
editDisplayMode = "cell",
|
|
1089
|
+
editTrigger = "click",
|
|
1090
|
+
onCellCommit,
|
|
1091
|
+
alwaysEditing,
|
|
1092
|
+
enableCellTabTraversal,
|
|
1093
|
+
editableColumnIds: editableColumnIdsProp,
|
|
1094
|
+
testId,
|
|
46
1095
|
}: DataTableProps<TData, TValue>) {
|
|
47
|
-
|
|
1096
|
+
// scrollable container ref — lives on the wrapper div, not tbody
|
|
1097
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
1098
|
+
const tableHeaderRef = useRef<HTMLTableSectionElement>(null);
|
|
1099
|
+
const userInteractingRef = useRef(false);
|
|
1100
|
+
/** Suppresses marking user scroll during highlight-driven `scrollTo` / `scrollIntoView`. */
|
|
1101
|
+
const programmaticScrollLockRef = useRef(0);
|
|
1102
|
+
const [stickyHeaderHeight, setStickyHeaderHeight] = useState(48);
|
|
1103
|
+
|
|
1104
|
+
const scheduleProgrammaticScrollEnd = useCallback(() => {
|
|
1105
|
+
const el = scrollRef.current;
|
|
1106
|
+
let settled = false;
|
|
1107
|
+
const unlock = () => {
|
|
1108
|
+
if (settled) return;
|
|
1109
|
+
settled = true;
|
|
1110
|
+
programmaticScrollLockRef.current = Math.max(
|
|
1111
|
+
0,
|
|
1112
|
+
programmaticScrollLockRef.current - 1,
|
|
1113
|
+
);
|
|
1114
|
+
};
|
|
1115
|
+
el?.addEventListener("scrollend", unlock, { once: true });
|
|
1116
|
+
window.setTimeout(unlock, 550);
|
|
1117
|
+
}, []);
|
|
48
1118
|
|
|
1119
|
+
const virtualMeasureRowElement = useCallback(
|
|
1120
|
+
(
|
|
1121
|
+
element: HTMLTableRowElement,
|
|
1122
|
+
entry: ResizeObserverEntry | undefined,
|
|
1123
|
+
instance: Virtualizer<HTMLDivElement, HTMLTableRowElement>,
|
|
1124
|
+
) => {
|
|
1125
|
+
const direction = instance.scrollDirection;
|
|
1126
|
+
if (direction === "forward" || direction === null) {
|
|
1127
|
+
const box = entry?.borderBoxSize?.[0];
|
|
1128
|
+
if (box) {
|
|
1129
|
+
return Math.round(box.blockSize);
|
|
1130
|
+
}
|
|
1131
|
+
return Math.round(element.getBoundingClientRect().height);
|
|
1132
|
+
}
|
|
1133
|
+
const raw = element.getAttribute("data-index");
|
|
1134
|
+
const indexKey = raw != null ? Number.parseInt(raw, 10) : NaN;
|
|
1135
|
+
const cache = (
|
|
1136
|
+
instance as unknown as { measurementsCache?: { size: number }[] }
|
|
1137
|
+
).measurementsCache;
|
|
1138
|
+
const cached =
|
|
1139
|
+
Number.isFinite(indexKey) && cache?.[indexKey]
|
|
1140
|
+
? cache[indexKey]!.size
|
|
1141
|
+
: undefined;
|
|
1142
|
+
if (typeof cached === "number") {
|
|
1143
|
+
return cached;
|
|
1144
|
+
}
|
|
1145
|
+
const box = entry?.borderBoxSize?.[0];
|
|
1146
|
+
if (box) {
|
|
1147
|
+
return Math.round(box.blockSize);
|
|
1148
|
+
}
|
|
1149
|
+
return Math.round(element.getBoundingClientRect().height);
|
|
1150
|
+
},
|
|
1151
|
+
[],
|
|
1152
|
+
);
|
|
1153
|
+
|
|
1154
|
+
// Normalize columnManagement: boolean | options → boolean + options object
|
|
1155
|
+
const columnManagement = !!columnManagementProp;
|
|
1156
|
+
const columnManagementOptions: ColumnManagementOptions =
|
|
1157
|
+
typeof columnManagementProp === "object" ? columnManagementProp : {};
|
|
1158
|
+
|
|
1159
|
+
// ---- state ----
|
|
49
1160
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
50
1161
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
51
1162
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
52
|
-
const [
|
|
1163
|
+
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([]);
|
|
1164
|
+
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
|
1165
|
+
const [columnSizingInfo, setColumnSizingInfo] =
|
|
1166
|
+
useState<ColumnSizingInfoState>({
|
|
1167
|
+
startOffset: null,
|
|
1168
|
+
startSize: null,
|
|
1169
|
+
deltaOffset: null,
|
|
1170
|
+
deltaPercentage: null,
|
|
1171
|
+
isResizingColumn: false,
|
|
1172
|
+
columnSizingStart: [],
|
|
1173
|
+
});
|
|
1174
|
+
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
|
1175
|
+
const [internalExpanded, setInternalExpanded] = useState<ExpandedState>(
|
|
1176
|
+
(defaultExpanded as ExpandedState) ?? {},
|
|
1177
|
+
);
|
|
1178
|
+
// Controlled: use prop directly every render; uncontrolled: use internal state
|
|
1179
|
+
const isExpandedControlled = controlledExpanded !== undefined;
|
|
1180
|
+
const expanded = isExpandedControlled ? controlledExpanded : internalExpanded;
|
|
53
1181
|
|
|
54
|
-
const
|
|
55
|
-
|
|
1182
|
+
const [pagination, setPagination] = useState<PaginationState>({
|
|
1183
|
+
pageIndex: controlledPageIndex ?? 0,
|
|
1184
|
+
pageSize: controlledPageSize ?? 10,
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// ---- column-management panel state (lifted here so hiding a column doesn't unmount it) ----
|
|
1188
|
+
const [manageOpen, setManageOpen] = useState(false);
|
|
1189
|
+
const [manageAnchorId, setManageAnchorId] = useState<string | null>(null);
|
|
1190
|
+
const [columnManageMenuOpenId, setColumnManageMenuOpenId] = useState<
|
|
1191
|
+
string | null
|
|
1192
|
+
>(null);
|
|
1193
|
+
const [managePanelPos, setManagePanelPos] = useState<{
|
|
1194
|
+
top: number;
|
|
1195
|
+
right: number;
|
|
1196
|
+
maxListHeight: number;
|
|
1197
|
+
}>({ top: 0, right: 0, maxListHeight: 400 });
|
|
1198
|
+
|
|
1199
|
+
const manageMenuTriggerRef = useRef<Map<string, HTMLButtonElement>>(
|
|
1200
|
+
new Map(),
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
const openManagePanelAt = useCallback(
|
|
1204
|
+
(colId: string, anchorRect: DOMRect) => {
|
|
1205
|
+
const PANEL_W = 460;
|
|
1206
|
+
const MARGIN = 8;
|
|
1207
|
+
setColumnManageMenuOpenId(null);
|
|
1208
|
+
const idealRight = window.innerWidth - anchorRect.right;
|
|
1209
|
+
const right = Math.min(
|
|
1210
|
+
Math.max(idealRight, MARGIN),
|
|
1211
|
+
window.innerWidth - PANEL_W - MARGIN,
|
|
1212
|
+
);
|
|
1213
|
+
const top = anchorRect.bottom + MARGIN;
|
|
1214
|
+
const maxListHeight = Math.max(80, window.innerHeight - top - 80);
|
|
1215
|
+
setManagePanelPos({ top, right, maxListHeight });
|
|
1216
|
+
setManageAnchorId(colId);
|
|
1217
|
+
setManageOpen(true);
|
|
1218
|
+
},
|
|
1219
|
+
[],
|
|
1220
|
+
);
|
|
1221
|
+
|
|
1222
|
+
// Close manage panel on Escape
|
|
1223
|
+
useEffect(() => {
|
|
1224
|
+
if (!manageOpen) return;
|
|
1225
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1226
|
+
if (e.key === "Escape") setManageOpen(false);
|
|
1227
|
+
};
|
|
1228
|
+
document.addEventListener("keydown", onKey);
|
|
1229
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
1230
|
+
}, [manageOpen]);
|
|
1231
|
+
|
|
1232
|
+
// Sync controlled pagination (server mode)
|
|
1233
|
+
useEffect(() => {
|
|
1234
|
+
if (paginationMode === "server") {
|
|
1235
|
+
setPagination({
|
|
1236
|
+
pageIndex: controlledPageIndex ?? 0,
|
|
1237
|
+
pageSize: controlledPageSize ?? 10,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}, [paginationMode, controlledPageIndex, controlledPageSize]);
|
|
1241
|
+
|
|
1242
|
+
// ---- editing engine ----
|
|
1243
|
+
const editableColIds = React.useMemo(
|
|
1244
|
+
() =>
|
|
1245
|
+
editableColumnIdsProp ??
|
|
1246
|
+
(enableEditing
|
|
1247
|
+
? detectEditableColumnIds(columns as EditableColumnDef<TData>[])
|
|
1248
|
+
: []),
|
|
1249
|
+
[enableEditing, editableColumnIdsProp, columns],
|
|
1250
|
+
);
|
|
1251
|
+
|
|
1252
|
+
const editingState = useDataTableEditing<TData>({
|
|
1253
|
+
enabled: enableEditing,
|
|
1254
|
+
editDisplayMode,
|
|
1255
|
+
editTrigger,
|
|
1256
|
+
editableColumnIds: editableColIds,
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
// ---- build final columns ----
|
|
1260
|
+
const finalColumns = React.useMemo<ColumnDef<TData, unknown>[]>(() => {
|
|
1261
|
+
let cols: ColumnDef<TData, unknown>[];
|
|
1262
|
+
if (enableEditing) {
|
|
1263
|
+
cols = resolveEditableColumns(columns as EditableColumnDef<TData>[], {
|
|
1264
|
+
editing: editingState,
|
|
1265
|
+
onCellCommit: onCellCommit as
|
|
1266
|
+
| ((rowId: string, columnId: string, patch: Partial<TData>) => void)
|
|
1267
|
+
| undefined,
|
|
1268
|
+
alwaysEditing: alwaysEditing as
|
|
1269
|
+
| ((row: Row<TData>) => boolean)
|
|
1270
|
+
| undefined,
|
|
1271
|
+
enableCellTabTraversal: enableCellTabTraversal ?? true,
|
|
1272
|
+
editableColumnIds: editableColIds,
|
|
1273
|
+
});
|
|
1274
|
+
} else {
|
|
1275
|
+
cols = [...(columns as ColumnDef<TData, unknown>[])];
|
|
1276
|
+
}
|
|
1277
|
+
if (reorderable) cols.unshift(buildReorderColumn<TData>());
|
|
1278
|
+
if (selectable) cols.unshift(buildCheckboxColumn<TData>());
|
|
1279
|
+
if (rowActions) cols.push(buildActionsColumn<TData>(rowActions));
|
|
1280
|
+
return cols;
|
|
1281
|
+
}, [
|
|
56
1282
|
columns,
|
|
1283
|
+
selectable,
|
|
1284
|
+
reorderable,
|
|
1285
|
+
rowActions,
|
|
1286
|
+
enableEditing,
|
|
1287
|
+
editingState,
|
|
1288
|
+
onCellCommit,
|
|
1289
|
+
alwaysEditing,
|
|
1290
|
+
enableCellTabTraversal,
|
|
1291
|
+
]);
|
|
1292
|
+
|
|
1293
|
+
// ---- table instance ----
|
|
1294
|
+
const isPaginated =
|
|
1295
|
+
paginationMode === "client" || paginationMode === "server";
|
|
1296
|
+
|
|
1297
|
+
const table = useReactTable<TData>({
|
|
1298
|
+
data,
|
|
1299
|
+
columns: finalColumns,
|
|
1300
|
+
columnResizeMode: "onChange",
|
|
1301
|
+
defaultColumn: {
|
|
1302
|
+
minSize: columnMinSize,
|
|
1303
|
+
maxSize: columnMaxSize,
|
|
1304
|
+
},
|
|
1305
|
+
...(getRowIdProp
|
|
1306
|
+
? { getRowId: (row) => getRowIdProp(row) }
|
|
1307
|
+
: { getRowId: (row) => (row as Record<string, unknown>).id as string }),
|
|
57
1308
|
manualSorting,
|
|
1309
|
+
manualPagination: paginationMode === "server",
|
|
1310
|
+
rowCount:
|
|
1311
|
+
paginationMode === "server" ? totalCount ?? data.length : undefined,
|
|
1312
|
+
getSubRows,
|
|
58
1313
|
onSortingChange: setSorting,
|
|
59
1314
|
onColumnFiltersChange: setColumnFilters,
|
|
1315
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
1316
|
+
onColumnOrderChange: setColumnOrder,
|
|
1317
|
+
onColumnSizingChange: setColumnSizing,
|
|
1318
|
+
onColumnSizingInfoChange: setColumnSizingInfo,
|
|
1319
|
+
onRowSelectionChange: setRowSelection,
|
|
1320
|
+
onExpandedChange: (updater) => {
|
|
1321
|
+
const next = typeof updater === "function" ? updater(expanded) : updater;
|
|
1322
|
+
if (!isExpandedControlled) setInternalExpanded(next);
|
|
1323
|
+
onExpandedChange?.(next);
|
|
1324
|
+
},
|
|
1325
|
+
onPaginationChange: (updater) => {
|
|
1326
|
+
const next =
|
|
1327
|
+
typeof updater === "function" ? updater(pagination) : updater;
|
|
1328
|
+
setPagination(next);
|
|
1329
|
+
onPaginationChange?.(next);
|
|
1330
|
+
},
|
|
60
1331
|
getCoreRowModel: getCoreRowModel(),
|
|
61
|
-
// getPaginationRowModel: getPaginationRowModel(),
|
|
62
1332
|
getSortedRowModel: getSortedRowModel(),
|
|
63
1333
|
getFilteredRowModel: getFilteredRowModel(),
|
|
64
|
-
|
|
65
|
-
|
|
1334
|
+
getExpandedRowModel: getExpandedRowModel(),
|
|
1335
|
+
...(isPaginated ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
|
1336
|
+
enableRowSelection: selectable,
|
|
1337
|
+
enableColumnResizing: resizable,
|
|
66
1338
|
state: {
|
|
67
1339
|
sorting,
|
|
68
1340
|
columnFilters,
|
|
69
1341
|
columnVisibility,
|
|
1342
|
+
columnOrder,
|
|
1343
|
+
columnSizing,
|
|
1344
|
+
columnSizingInfo,
|
|
70
1345
|
rowSelection,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// pageIndex: 0,
|
|
74
|
-
// },
|
|
1346
|
+
expanded,
|
|
1347
|
+
...(isPaginated ? { pagination } : {}),
|
|
75
1348
|
},
|
|
76
1349
|
});
|
|
77
1350
|
|
|
1351
|
+
/** Body row order: sortable rows follow header sort; locked rows stay at the bottom. */
|
|
1352
|
+
const tableRowsForView = React.useMemo(() => {
|
|
1353
|
+
const rows = table.getRowModel().rows;
|
|
1354
|
+
if (!isRowReorderLocked) return rows;
|
|
1355
|
+
const locked = rows.filter((r) => isRowReorderLocked(r));
|
|
1356
|
+
const unlocked = rows.filter((r) => !isRowReorderLocked(r));
|
|
1357
|
+
if (locked.length === 0) return rows;
|
|
1358
|
+
const order = new Map(
|
|
1359
|
+
data.map((row, index) => [
|
|
1360
|
+
getRowIdProp
|
|
1361
|
+
? getRowIdProp(row)
|
|
1362
|
+
: String((row as Record<string, unknown>).id),
|
|
1363
|
+
index,
|
|
1364
|
+
]),
|
|
1365
|
+
);
|
|
1366
|
+
locked.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
|
1367
|
+
return [...unlocked, ...locked];
|
|
1368
|
+
}, [table.getRowModel().rows, isRowReorderLocked, data, getRowIdProp]);
|
|
1369
|
+
|
|
1370
|
+
// ---- side-effects ----
|
|
78
1371
|
useEffect(() => {
|
|
79
1372
|
onSorting?.(sorting);
|
|
80
1373
|
}, [sorting, onSorting]);
|
|
81
1374
|
|
|
82
1375
|
useEffect(() => {
|
|
1376
|
+
onRowSelectionChange?.(rowSelection);
|
|
1377
|
+
}, [rowSelection, onRowSelectionChange]);
|
|
1378
|
+
|
|
1379
|
+
// Infinite scroll — listener on the wrapper div
|
|
1380
|
+
useEffect(() => {
|
|
1381
|
+
if (paginationMode !== "infinite") return;
|
|
1382
|
+
const el = scrollRef.current;
|
|
1383
|
+
if (!el) return;
|
|
83
1384
|
const handleScroll = () => {
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
1385
|
+
if (programmaticScrollLockRef.current > 0) return;
|
|
1386
|
+
// Do not set userInteractingRef here: programmatic highlight scroll (often
|
|
1387
|
+
// `behavior: "smooth"`) emits many scroll events after the programmatic
|
|
1388
|
+
// lock unlocks, which would flip userInteracting and block the next follow.
|
|
1389
|
+
// Pause is handled only via wheel / pointer when scrollToHighlightOnMouseLeave.
|
|
1390
|
+
const { scrollTop, scrollHeight, clientHeight } = el;
|
|
1391
|
+
if (fetchingMore || loading) return;
|
|
1392
|
+
const offset = typeof fetchMoreOffset === "number" ? fetchMoreOffset : 10;
|
|
1393
|
+
if (scrollTop + clientHeight >= scrollHeight - offset) {
|
|
1394
|
+
fetchMoreData?.();
|
|
89
1395
|
}
|
|
90
1396
|
};
|
|
1397
|
+
el.addEventListener("scroll", handleScroll);
|
|
1398
|
+
return () => el.removeEventListener("scroll", handleScroll);
|
|
1399
|
+
}, [paginationMode, fetchMoreData, fetchingMore, loading, fetchMoreOffset]);
|
|
91
1400
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
1401
|
+
const isEmpty = tableRowsForView.length === 0;
|
|
1402
|
+
|
|
1403
|
+
// Virtualizer row offsets must match scroll coordinates: tbody starts below <thead>.
|
|
1404
|
+
useLayoutEffect(() => {
|
|
1405
|
+
if (!virtualized) return;
|
|
1406
|
+
const el = tableHeaderRef.current;
|
|
1407
|
+
if (!el) return;
|
|
1408
|
+
const update = () => {
|
|
1409
|
+
setStickyHeaderHeight(Math.round(el.getBoundingClientRect().height));
|
|
1410
|
+
};
|
|
1411
|
+
update();
|
|
1412
|
+
const ro = new ResizeObserver(update);
|
|
1413
|
+
ro.observe(el);
|
|
1414
|
+
return () => ro.disconnect();
|
|
1415
|
+
}, [virtualized]);
|
|
1416
|
+
|
|
1417
|
+
const scrollHighlightIntoViewRef = useRef<
|
|
1418
|
+
(behavior?: ScrollBehavior) => void
|
|
1419
|
+
>(() => {});
|
|
96
1420
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
1421
|
+
const hadVirtualScrollSizeRef = useRef(false);
|
|
1422
|
+
|
|
1423
|
+
const rowVirtualizer = useVirtualizer({
|
|
1424
|
+
count: virtualized ? tableRowsForView.length : 0,
|
|
1425
|
+
getScrollElement: () => scrollRef.current,
|
|
1426
|
+
estimateSize: () => virtualRowEstimate ?? 40,
|
|
1427
|
+
overscan: 4,
|
|
1428
|
+
// Offsets must include sticky thead height so item.start matches document offsets inside the scrollport.
|
|
1429
|
+
paddingStart: virtualized ? stickyHeaderHeight : 0,
|
|
1430
|
+
scrollPaddingStart: 0,
|
|
1431
|
+
measureElement: virtualMeasureRowElement,
|
|
1432
|
+
onChange: () => {
|
|
1433
|
+
if (!virtualized || !highlightRowId) return;
|
|
1434
|
+
const h = scrollRef.current?.clientHeight ?? 0;
|
|
1435
|
+
if (h < 1) {
|
|
1436
|
+
hadVirtualScrollSizeRef.current = false;
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (hadVirtualScrollSizeRef.current) return;
|
|
1440
|
+
hadVirtualScrollSizeRef.current = true;
|
|
1441
|
+
requestAnimationFrame(() => {
|
|
1442
|
+
if (userInteractingRef.current) return;
|
|
1443
|
+
scrollHighlightIntoViewRef.current("auto");
|
|
1444
|
+
});
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
const scrollHighlightIntoView = useCallback(
|
|
1449
|
+
(behavior: ScrollBehavior = "smooth") => {
|
|
1450
|
+
if (!highlightRowId) return;
|
|
1451
|
+
if (userInteractingRef.current) return;
|
|
1452
|
+
const container = scrollRef.current;
|
|
1453
|
+
if (!container) return;
|
|
1454
|
+
const ids = Array.isArray(highlightRowId)
|
|
1455
|
+
? highlightRowId
|
|
1456
|
+
: [highlightRowId];
|
|
1457
|
+
|
|
1458
|
+
// Virtualized: off-screen rows are not in the DOM, so querySelector fails.
|
|
1459
|
+
// Drive scroll via the virtualizer so the target row mounts, then DOM path can fine-tune if needed.
|
|
1460
|
+
if (virtualized) {
|
|
1461
|
+
void rowVirtualizer.getTotalSize();
|
|
1462
|
+
for (const id of ids) {
|
|
1463
|
+
const idx = tableRowsForView.findIndex((r) => r.id === id);
|
|
1464
|
+
if (idx !== -1) {
|
|
1465
|
+
// `scrollToIndex({ align: "center" })` aligns item *start* to viewport center
|
|
1466
|
+
// in TanStack Virtual; also thead was throwing off scroll coords — use
|
|
1467
|
+
// paddingStart + explicit center of row in the scrollport.
|
|
1468
|
+
const estimate = virtualRowEstimate ?? 40;
|
|
1469
|
+
const startOffset = rowVirtualizer.getOffsetForIndex(idx, "start");
|
|
1470
|
+
if (!startOffset) continue;
|
|
1471
|
+
const [scrollTopAlignStart] = startOffset;
|
|
1472
|
+
const vzWithCache = rowVirtualizer as unknown as {
|
|
1473
|
+
measurementsCache?: { size: number }[];
|
|
1474
|
+
};
|
|
1475
|
+
const measured = vzWithCache.measurementsCache?.[idx]?.size;
|
|
1476
|
+
const rowH = typeof measured === "number" ? measured : estimate;
|
|
1477
|
+
const target =
|
|
1478
|
+
scrollTopAlignStart + rowH / 2 - container.clientHeight / 2;
|
|
1479
|
+
programmaticScrollLockRef.current += 1;
|
|
1480
|
+
scheduleProgrammaticScrollEnd();
|
|
1481
|
+
rowVirtualizer.scrollToOffset(Math.max(0, target), {
|
|
1482
|
+
align: "start",
|
|
1483
|
+
behavior: behavior === "smooth" ? "smooth" : "auto",
|
|
1484
|
+
});
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
return;
|
|
100
1489
|
}
|
|
1490
|
+
|
|
1491
|
+
for (const id of ids) {
|
|
1492
|
+
const rowEl = container.querySelector<HTMLElement>(
|
|
1493
|
+
`[data-row-id="${id}"]`,
|
|
1494
|
+
);
|
|
1495
|
+
if (rowEl) {
|
|
1496
|
+
programmaticScrollLockRef.current += 1;
|
|
1497
|
+
scheduleProgrammaticScrollEnd();
|
|
1498
|
+
rowEl.scrollIntoView({ block: "center", behavior });
|
|
1499
|
+
break;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
},
|
|
1503
|
+
[
|
|
1504
|
+
highlightRowId,
|
|
1505
|
+
tableRowsForView,
|
|
1506
|
+
virtualized,
|
|
1507
|
+
rowVirtualizer,
|
|
1508
|
+
virtualRowEstimate,
|
|
1509
|
+
stickyHeaderHeight,
|
|
1510
|
+
scheduleProgrammaticScrollEnd,
|
|
1511
|
+
],
|
|
1512
|
+
);
|
|
1513
|
+
|
|
1514
|
+
scrollHighlightIntoViewRef.current = scrollHighlightIntoView;
|
|
1515
|
+
|
|
1516
|
+
// Allow one onChange retry when the highlight target or virtualization toggles
|
|
1517
|
+
// (e.g. scrollport height was 0 on first layout).
|
|
1518
|
+
useLayoutEffect(() => {
|
|
1519
|
+
hadVirtualScrollSizeRef.current = false;
|
|
1520
|
+
}, [virtualized, highlightRowId]);
|
|
1521
|
+
|
|
1522
|
+
// Scroll to highlighted row(s) when the value changes (e.g. user picks one).
|
|
1523
|
+
// When `scrollToHighlightOnMouseLeave` is set, auto-scroll is skipped while
|
|
1524
|
+
// `userInteractingRef` is true (wheel / pointer on table only; not raw scroll).
|
|
1525
|
+
useLayoutEffect(() => {
|
|
1526
|
+
scrollHighlightIntoView("smooth");
|
|
1527
|
+
}, [virtualized, scrollHighlightIntoView, highlightRowId]);
|
|
1528
|
+
|
|
1529
|
+
// ---- pagination bar props ----
|
|
1530
|
+
const paginationBarProps: TablePaginationProps = {
|
|
1531
|
+
pageIndex: pagination.pageIndex,
|
|
1532
|
+
pageSize: pagination.pageSize,
|
|
1533
|
+
totalCount:
|
|
1534
|
+
paginationMode === "server"
|
|
1535
|
+
? totalCount ?? data.length
|
|
1536
|
+
: table.getFilteredRowModel().rows.length,
|
|
1537
|
+
pageSizeOptions,
|
|
1538
|
+
onPageChange: (idx) =>
|
|
1539
|
+
table.setPagination((prev) => ({ ...prev, pageIndex: idx })),
|
|
1540
|
+
onPageSizeChange: (size) =>
|
|
1541
|
+
table.setPagination({ pageIndex: 0, pageSize: size }),
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
const computedFlexWidth = useFlexColumnWidth(table);
|
|
1545
|
+
const flexWidth = tableLayout === "equal" ? computedFlexWidth : undefined;
|
|
1546
|
+
|
|
1547
|
+
const fixedColStyles = React.useMemo<Map<
|
|
1548
|
+
string,
|
|
1549
|
+
React.CSSProperties
|
|
1550
|
+
> | null>(() => {
|
|
1551
|
+
if (tableLayout !== "fixed") return null;
|
|
1552
|
+
const cols = table.getVisibleLeafColumns();
|
|
1553
|
+
|
|
1554
|
+
const isPxExactCol = (col: (typeof cols)[number]) => {
|
|
1555
|
+
const ew = (
|
|
1556
|
+
col.columnDef.meta as { exactWidth?: number | string } | undefined
|
|
1557
|
+
)?.exactWidth;
|
|
1558
|
+
return exactWidthToPx(ew) != null;
|
|
101
1559
|
};
|
|
102
|
-
}, [fetchMoreData]);
|
|
103
1560
|
|
|
104
|
-
|
|
1561
|
+
// Full-width table + honour `size` as px: lock every non-exact column to
|
|
1562
|
+
// its `size`, except the last “flex” column which absorbs leftover width
|
|
1563
|
+
// (avoids `calc()` on every <col>/<th> — browsers often distribute badly).
|
|
1564
|
+
const flexColIndices = cols
|
|
1565
|
+
.map((c, i) => (!isPxExactCol(c) ? i : -1))
|
|
1566
|
+
.filter((i) => i >= 0);
|
|
1567
|
+
const growColIndex =
|
|
1568
|
+
flexColIndices.length > 0
|
|
1569
|
+
? flexColIndices[flexColIndices.length - 1]
|
|
1570
|
+
: -1;
|
|
1571
|
+
|
|
1572
|
+
const map = new Map<string, React.CSSProperties>();
|
|
1573
|
+
cols.forEach((col, index) => {
|
|
1574
|
+
if (isPxExactCol(col)) return;
|
|
1575
|
+
|
|
1576
|
+
if (index === growColIndex) {
|
|
1577
|
+
// No entry → <col> has no width; cell styles omit width — column grows.
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
const colSize = col.columnDef.size ?? col.getSize?.() ?? 150;
|
|
1582
|
+
const px =
|
|
1583
|
+
typeof colSize === "number"
|
|
1584
|
+
? colSize
|
|
1585
|
+
: Number.isFinite(parseFloat(String(colSize)))
|
|
1586
|
+
? parseFloat(String(colSize))
|
|
1587
|
+
: 150;
|
|
1588
|
+
|
|
1589
|
+
map.set(col.id, {
|
|
1590
|
+
width: `${px}px`,
|
|
1591
|
+
minWidth: `${px}px`,
|
|
1592
|
+
maxWidth: `${px}px`,
|
|
1593
|
+
overflow: "hidden",
|
|
1594
|
+
boxSizing: "border-box" as const,
|
|
1595
|
+
});
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
return map;
|
|
1599
|
+
}, [tableLayout, table.getVisibleLeafColumns()]);
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* When `resizable` + `table-layout: fixed`, the table is often wider than
|
|
1603
|
+
* `getTotalSize()` (`max(100%, …)`). Browsers then redistribute slack across
|
|
1604
|
+
* *all* columns — even `<col>` + `th` locked to 42px can visually grow.
|
|
1605
|
+
* Pick one non-exact column to absorb slack (empty `<col>`, no pixel width on
|
|
1606
|
+
* cells) like the non-resizable `fixed` “grow” column. Not used for `equal`
|
|
1607
|
+
* — there we leave non-exact `<col>`s empty and use `flexWidth` on cells only.
|
|
1608
|
+
*/
|
|
1609
|
+
const resizableSlackGrowColId = React.useMemo(() => {
|
|
1610
|
+
if (!resizable || tableLayout === "equal") return null;
|
|
1611
|
+
const cols = table.getVisibleLeafColumns();
|
|
1612
|
+
const flexIndices = cols
|
|
1613
|
+
.map((c, i) => {
|
|
1614
|
+
const ew = (
|
|
1615
|
+
c.columnDef.meta as { exactWidth?: number | string } | undefined
|
|
1616
|
+
)?.exactWidth;
|
|
1617
|
+
return exactWidthToPx(ew) == null ? i : -1;
|
|
1618
|
+
})
|
|
1619
|
+
.filter((i) => i >= 0);
|
|
1620
|
+
if (flexIndices.length === 0) return null;
|
|
1621
|
+
return cols[flexIndices[flexIndices.length - 1]]?.id ?? null;
|
|
1622
|
+
}, [resizable, tableLayout, table.getVisibleLeafColumns()]);
|
|
1623
|
+
|
|
1624
|
+
// Helper: find the first data cell (not checkbox/actions) for expand toggle
|
|
1625
|
+
const firstDataCellId = (row: Row<TData>) =>
|
|
1626
|
+
row
|
|
1627
|
+
.getVisibleCells()
|
|
1628
|
+
.find(
|
|
1629
|
+
(c) =>
|
|
1630
|
+
c.column.id !== "__select__" &&
|
|
1631
|
+
c.column.id !== "__actions__" &&
|
|
1632
|
+
c.column.id !== "__reorder__",
|
|
1633
|
+
)?.id;
|
|
1634
|
+
|
|
1635
|
+
// ---- row reorder (drag-and-drop) ----
|
|
1636
|
+
const rowReorderSensors = useSensors(
|
|
1637
|
+
useSensor(PointerSensor),
|
|
1638
|
+
useSensor(KeyboardSensor, {
|
|
1639
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
1640
|
+
}),
|
|
1641
|
+
);
|
|
1642
|
+
|
|
1643
|
+
const rowIds = React.useMemo(
|
|
1644
|
+
() =>
|
|
1645
|
+
tableRowsForView.filter((r) => !isRowReorderLocked?.(r)).map((r) => r.id),
|
|
1646
|
+
[tableRowsForView, isRowReorderLocked],
|
|
1647
|
+
);
|
|
1648
|
+
|
|
1649
|
+
const handleRowDragEnd = (event: DragEndEvent) => {
|
|
1650
|
+
const { active, over } = event;
|
|
1651
|
+
if (!over || active.id === over.id || !onRowReorder) return;
|
|
1652
|
+
|
|
1653
|
+
const oldIndex = data.findIndex(
|
|
1654
|
+
(item) =>
|
|
1655
|
+
(getRowIdProp
|
|
1656
|
+
? getRowIdProp(item)
|
|
1657
|
+
: (item as Record<string, unknown>).id) === active.id,
|
|
1658
|
+
);
|
|
1659
|
+
const newIndex = data.findIndex(
|
|
1660
|
+
(item) =>
|
|
1661
|
+
(getRowIdProp
|
|
1662
|
+
? getRowIdProp(item)
|
|
1663
|
+
: (item as Record<string, unknown>).id) === over.id,
|
|
1664
|
+
);
|
|
1665
|
+
if (oldIndex === -1 || newIndex === -1) return;
|
|
1666
|
+
|
|
1667
|
+
onRowReorder(arrayMove([...data], oldIndex, newIndex));
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
// `resizable` uses `table-layout: fixed`; without min/max on `meta.exactWidth`
|
|
1671
|
+
// (e.g. __select__), those columns absorb extra table width even when inline
|
|
1672
|
+
// `width: 42px` is set. Lock px-based exactWidth whenever fixed layout applies.
|
|
1673
|
+
const lockPxExactWidth = tableLayout === "fixed" || resizable;
|
|
1674
|
+
|
|
1675
|
+
const showColumnMenuVisibility =
|
|
1676
|
+
columnManagement && columnManagementOptions.visibility !== false;
|
|
1677
|
+
const visibleHideableLeafCount = columnManagement
|
|
1678
|
+
? table
|
|
1679
|
+
.getAllLeafColumns()
|
|
1680
|
+
.filter((c) => c.getCanHide() && c.getIsVisible()).length
|
|
1681
|
+
: 0;
|
|
1682
|
+
|
|
1683
|
+
// ---- render ----
|
|
1684
|
+
const editContextValue = enableEditing ? editingState : null;
|
|
105
1685
|
|
|
106
1686
|
return (
|
|
107
|
-
<
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
1687
|
+
<EditContext.Provider value={editContextValue}>
|
|
1688
|
+
<div
|
|
1689
|
+
className={cn(
|
|
1690
|
+
"flex flex-col w-full h-full min-h-0",
|
|
1691
|
+
bordered && "overflow-hidden rounded-md border border-table-c-border",
|
|
1692
|
+
!bordered && "overflow-hidden rounded-none",
|
|
1693
|
+
className,
|
|
1694
|
+
)}
|
|
1695
|
+
data-surface={surface === "panel" ? "panel" : undefined}
|
|
1696
|
+
data-testid={testId}
|
|
1697
|
+
>
|
|
1698
|
+
{/* Scrollable area: header sticks, body scrolls */}
|
|
1699
|
+
<div
|
|
1700
|
+
ref={scrollRef}
|
|
1701
|
+
className={cn(
|
|
1702
|
+
"relative min-h-0 overflow-auto",
|
|
1703
|
+
"ui-scrollbar ui-scrollbar-x-m ui-scrollbar-y-s",
|
|
1704
|
+
isPaginated ? "flex-1" : "h-full",
|
|
1705
|
+
)}
|
|
1706
|
+
onWheel={
|
|
1707
|
+
scrollToHighlightOnMouseLeave
|
|
1708
|
+
? () => {
|
|
1709
|
+
userInteractingRef.current = true;
|
|
1710
|
+
}
|
|
1711
|
+
: undefined
|
|
1712
|
+
}
|
|
1713
|
+
onPointerDownCapture={
|
|
1714
|
+
scrollToHighlightOnMouseLeave
|
|
1715
|
+
? () => {
|
|
1716
|
+
userInteractingRef.current = true;
|
|
1717
|
+
}
|
|
1718
|
+
: undefined
|
|
1719
|
+
}
|
|
1720
|
+
onMouseLeave={
|
|
1721
|
+
scrollToHighlightOnMouseLeave
|
|
1722
|
+
? () => {
|
|
1723
|
+
userInteractingRef.current = false;
|
|
1724
|
+
scrollHighlightIntoView("smooth");
|
|
1725
|
+
}
|
|
1726
|
+
: undefined
|
|
1727
|
+
}
|
|
1728
|
+
>
|
|
1729
|
+
<Table
|
|
1730
|
+
scrollableWrapper={false}
|
|
1731
|
+
className={cn(
|
|
1732
|
+
(tableLayout === "fixed" ||
|
|
1733
|
+
tableLayout === "equal" ||
|
|
1734
|
+
resizable) &&
|
|
1735
|
+
"table-fixed",
|
|
1736
|
+
isEmpty && "h-full",
|
|
1737
|
+
)}
|
|
1738
|
+
style={
|
|
1739
|
+
resizable
|
|
1740
|
+
? {
|
|
1741
|
+
// At least full scroll width, but grow when column sum exceeds it.
|
|
1742
|
+
width: `max(100%, ${table.getTotalSize()}px)`,
|
|
1743
|
+
}
|
|
1744
|
+
: { width: "100%" }
|
|
1745
|
+
}
|
|
1746
|
+
>
|
|
1747
|
+
{tableLayout === "fixed" && !resizable && (
|
|
1748
|
+
<colgroup>
|
|
1749
|
+
{table.getVisibleLeafColumns().map((col) => {
|
|
1750
|
+
const colDef = col.columnDef as ColumnDef<unknown, unknown>;
|
|
1751
|
+
const exact = getExactWidthStyle(colDef, lockPxExactWidth);
|
|
1752
|
+
const style = exact ?? fixedColStyles?.get(col.id);
|
|
1753
|
+
return <col key={col.id} style={style} />;
|
|
1754
|
+
})}
|
|
1755
|
+
</colgroup>
|
|
1756
|
+
)}
|
|
1757
|
+
{resizable && (
|
|
1758
|
+
<colgroup>
|
|
1759
|
+
{table.getVisibleLeafColumns().map((col) => {
|
|
1760
|
+
const colDef = col.columnDef as ColumnDef<unknown, unknown>;
|
|
1761
|
+
const exact = getExactWidthStyle(colDef, lockPxExactWidth);
|
|
1762
|
+
if (exact) return <col key={col.id} style={exact} />;
|
|
1763
|
+
// Equal: only lock exact cols; flex columns share via flexWidth on th/td.
|
|
1764
|
+
if (tableLayout === "equal") return <col key={col.id} />;
|
|
1765
|
+
if (
|
|
1766
|
+
resizableSlackGrowColId != null &&
|
|
1767
|
+
col.id === resizableSlackGrowColId
|
|
1768
|
+
) {
|
|
1769
|
+
return <col key={col.id} />;
|
|
1770
|
+
}
|
|
1771
|
+
return <col key={col.id} style={{ width: col.getSize() }} />;
|
|
1772
|
+
})}
|
|
1773
|
+
</colgroup>
|
|
1774
|
+
)}
|
|
1775
|
+
{/* Sticky header */}
|
|
1776
|
+
<TableHeader
|
|
1777
|
+
ref={tableHeaderRef}
|
|
1778
|
+
className={cn("sticky top-0 z-10", headerClassName)}
|
|
1779
|
+
data-testid={testId ? `${testId}-thead` : undefined}
|
|
1780
|
+
>
|
|
1781
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
1782
|
+
<TableRow
|
|
1783
|
+
key={headerGroup.id}
|
|
1784
|
+
divided={false}
|
|
1785
|
+
colDivided={divided}
|
|
1786
|
+
className={cn("hover:bg-transparent", headerRowClassName)}
|
|
1787
|
+
>
|
|
1788
|
+
{headerGroup.headers.map((header) => {
|
|
1789
|
+
const canSort = header.column.getCanSort();
|
|
1790
|
+
const isSorted = header.column.getIsSorted();
|
|
1791
|
+
const showManage =
|
|
1792
|
+
columnManagement && header.column.getCanHide();
|
|
1793
|
+
const canResize =
|
|
1794
|
+
resizable &&
|
|
1795
|
+
tableLayout !== "equal" &&
|
|
1796
|
+
header.column.getCanResize();
|
|
1797
|
+
const isResizing = header.column.getIsResizing();
|
|
1798
|
+
const fixedFallback =
|
|
1799
|
+
!resizable && fixedColStyles
|
|
1800
|
+
? fixedColStyles.get(header.column.id)
|
|
1801
|
+
: undefined;
|
|
1802
|
+
|
|
1803
|
+
const usePixelResizeWidth =
|
|
1804
|
+
resizable &&
|
|
1805
|
+
tableLayout !== "equal" &&
|
|
1806
|
+
!(
|
|
1807
|
+
resizableSlackGrowColId != null &&
|
|
1808
|
+
header.column.id === resizableSlackGrowColId
|
|
1809
|
+
);
|
|
1810
|
+
|
|
1811
|
+
const headerStyle = resolveHeaderWidthStyle(
|
|
1812
|
+
header.column.columnDef as ColumnDef<unknown, unknown>,
|
|
1813
|
+
flexWidth,
|
|
1814
|
+
fixedFallback,
|
|
1815
|
+
lockPxExactWidth,
|
|
1816
|
+
usePixelResizeWidth ? header.getSize() : undefined,
|
|
1817
|
+
);
|
|
1818
|
+
|
|
1819
|
+
return (
|
|
1820
|
+
<TableHead
|
|
1821
|
+
key={header.id}
|
|
1822
|
+
colSpan={header.colSpan}
|
|
1823
|
+
style={headerStyle}
|
|
1824
|
+
className={cn(
|
|
1825
|
+
"relative overflow-visible group/col",
|
|
1826
|
+
isResizing && "select-none",
|
|
1827
|
+
header.column.columnDef.meta?.headerCellClassName,
|
|
1828
|
+
headerCellClassName?.(header),
|
|
1829
|
+
)}
|
|
1830
|
+
>
|
|
1831
|
+
<div className="flex flex-row items-center gap-1 group/header">
|
|
1832
|
+
{/* Column label — flex-1 pushes sort + ⋮ to the right */}
|
|
1833
|
+
<div
|
|
1834
|
+
className={cn(
|
|
1835
|
+
"flex flex-1 flex-row items-center overflow-hidden",
|
|
1836
|
+
canSort && "cursor-pointer select-none",
|
|
1837
|
+
columnMetaAlignClass(
|
|
1838
|
+
header.column.columnDef.meta,
|
|
1839
|
+
),
|
|
1840
|
+
)}
|
|
1841
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
1842
|
+
>
|
|
1843
|
+
{header.isPlaceholder
|
|
1844
|
+
? null
|
|
1845
|
+
: flexRender(
|
|
1846
|
+
header.column.columnDef.header,
|
|
1847
|
+
header.getContext(),
|
|
1848
|
+
)}
|
|
1849
|
+
</div>
|
|
1850
|
+
|
|
1851
|
+
{/* Sort button — visible when sorted; shown on hover when unsorted */}
|
|
1852
|
+
{canSort && (
|
|
1853
|
+
<ActionButton
|
|
1854
|
+
variant="icon"
|
|
1855
|
+
size="sm"
|
|
1856
|
+
className={cn(
|
|
1857
|
+
"shrink-0 transition-opacity",
|
|
1858
|
+
isSorted
|
|
1859
|
+
? "opacity-100"
|
|
1860
|
+
: sortIndicatorVisibility === "always"
|
|
1861
|
+
? "opacity-100"
|
|
1862
|
+
: "opacity-0 group-hover/header:opacity-100",
|
|
1863
|
+
)}
|
|
1864
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
1865
|
+
aria-label={
|
|
1866
|
+
isSorted === "asc"
|
|
1867
|
+
? "Sorted ascending — click to sort descending"
|
|
1868
|
+
: isSorted === "desc"
|
|
1869
|
+
? "Sorted descending — click to clear sort"
|
|
1870
|
+
: "Sort column"
|
|
1871
|
+
}
|
|
1872
|
+
>
|
|
1873
|
+
{isSorted === "asc" ? (
|
|
1874
|
+
<ArrowUp className="!size-4" />
|
|
1875
|
+
) : isSorted === "desc" ? (
|
|
1876
|
+
<ArrowDown className="!size-4" />
|
|
1877
|
+
) : (
|
|
1878
|
+
<ArrowUpDown className="!size-4 text-text-g-contrast-medium" />
|
|
1879
|
+
)}
|
|
1880
|
+
</ActionButton>
|
|
1881
|
+
)}
|
|
1882
|
+
|
|
1883
|
+
{/* ⋮ column menu — hide column + open manage panel */}
|
|
1884
|
+
{showManage && (
|
|
1885
|
+
<DropdownMenu
|
|
1886
|
+
open={columnManageMenuOpenId === header.column.id}
|
|
1887
|
+
onOpenChange={(open) =>
|
|
1888
|
+
setColumnManageMenuOpenId(
|
|
1889
|
+
open ? header.column.id : null,
|
|
1890
|
+
)
|
|
1891
|
+
}
|
|
1892
|
+
modal={false}
|
|
1893
|
+
>
|
|
1894
|
+
<DropdownMenuTrigger asChild>
|
|
1895
|
+
<ActionButton
|
|
1896
|
+
variant="icon"
|
|
1897
|
+
size="sm"
|
|
1898
|
+
active={
|
|
1899
|
+
(manageOpen &&
|
|
1900
|
+
manageAnchorId === header.column.id) ||
|
|
1901
|
+
columnManageMenuOpenId === header.column.id
|
|
1902
|
+
}
|
|
1903
|
+
aria-haspopup="menu"
|
|
1904
|
+
aria-expanded={
|
|
1905
|
+
columnManageMenuOpenId === header.column.id
|
|
1906
|
+
}
|
|
1907
|
+
aria-label="Column options"
|
|
1908
|
+
className="shrink-0"
|
|
1909
|
+
ref={(el) => {
|
|
1910
|
+
if (el) {
|
|
1911
|
+
manageMenuTriggerRef.current.set(
|
|
1912
|
+
header.column.id,
|
|
1913
|
+
el,
|
|
1914
|
+
);
|
|
1915
|
+
} else {
|
|
1916
|
+
manageMenuTriggerRef.current.delete(
|
|
1917
|
+
header.column.id,
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
}}
|
|
1921
|
+
onClick={(e) => e.stopPropagation()}
|
|
1922
|
+
>
|
|
1923
|
+
<EllipsisVertical className="!size-4" />
|
|
1924
|
+
</ActionButton>
|
|
1925
|
+
</DropdownMenuTrigger>
|
|
1926
|
+
<DropdownMenuContent
|
|
1927
|
+
align="end"
|
|
1928
|
+
sideOffset={4}
|
|
1929
|
+
className="min-w-[220px] py-1"
|
|
1930
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
1931
|
+
>
|
|
1932
|
+
{showColumnMenuVisibility && (
|
|
1933
|
+
<DropdownMenuItem
|
|
1934
|
+
className="py-3"
|
|
1935
|
+
disabled={
|
|
1936
|
+
header.column.getIsVisible() &&
|
|
1937
|
+
visibleHideableLeafCount <= 1
|
|
1938
|
+
}
|
|
1939
|
+
icon={
|
|
1940
|
+
<EyeOff className="size-4" aria-hidden />
|
|
1941
|
+
}
|
|
1942
|
+
onSelect={() => {
|
|
1943
|
+
header.column.toggleVisibility(false);
|
|
1944
|
+
}}
|
|
1945
|
+
>
|
|
1946
|
+
Hide {manageColumnHeaderLabel(header)}{" "}
|
|
1947
|
+
column
|
|
1948
|
+
</DropdownMenuItem>
|
|
1949
|
+
)}
|
|
1950
|
+
<DropdownMenuItem
|
|
1951
|
+
className="py-3"
|
|
1952
|
+
icon={
|
|
1953
|
+
<Columns3 className="size-4" aria-hidden />
|
|
1954
|
+
}
|
|
1955
|
+
onSelect={() => {
|
|
1956
|
+
requestAnimationFrame(() => {
|
|
1957
|
+
const el =
|
|
1958
|
+
manageMenuTriggerRef.current.get(
|
|
1959
|
+
header.column.id,
|
|
1960
|
+
);
|
|
1961
|
+
if (el) {
|
|
1962
|
+
openManagePanelAt(
|
|
1963
|
+
header.column.id,
|
|
1964
|
+
el.getBoundingClientRect(),
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
});
|
|
1968
|
+
}}
|
|
1969
|
+
>
|
|
1970
|
+
Manage columns
|
|
1971
|
+
</DropdownMenuItem>
|
|
1972
|
+
</DropdownMenuContent>
|
|
1973
|
+
</DropdownMenu>
|
|
1974
|
+
)}
|
|
1975
|
+
</div>
|
|
1976
|
+
|
|
1977
|
+
{/* Resize handle — draggable right edge */}
|
|
1978
|
+
{canResize && (
|
|
1979
|
+
<div
|
|
1980
|
+
onMouseDown={header.getResizeHandler(document)}
|
|
1981
|
+
onTouchStart={header.getResizeHandler(document)}
|
|
1982
|
+
onClick={(e) => e.stopPropagation()}
|
|
1983
|
+
className={cn(
|
|
1984
|
+
"absolute right-0 top-0 h-full w-[5px] z-10",
|
|
1985
|
+
"cursor-col-resize select-none touch-none",
|
|
1986
|
+
"flex items-center justify-center group/resize",
|
|
1987
|
+
)}
|
|
1988
|
+
>
|
|
1989
|
+
<div
|
|
1990
|
+
className={cn(
|
|
1991
|
+
"h-4/5 w-1.5 rounded-full transition-all duration-150",
|
|
1992
|
+
isResizing
|
|
1993
|
+
? "opacity-100 w-0.5 bg-primary-500"
|
|
1994
|
+
: "opacity-0 bg-table-c-col-line group-hover/col:opacity-100 group-hover/resize:bg-primary-400",
|
|
1995
|
+
)}
|
|
1996
|
+
/>
|
|
1997
|
+
</div>
|
|
1998
|
+
)}
|
|
1999
|
+
</TableHead>
|
|
2000
|
+
);
|
|
2001
|
+
})}
|
|
2002
|
+
</TableRow>
|
|
2003
|
+
))}
|
|
2004
|
+
</TableHeader>
|
|
2005
|
+
|
|
2006
|
+
{/*
|
|
2007
|
+
* Body rendering rules:
|
|
2008
|
+
*
|
|
2009
|
+
* Striped (striped=true):
|
|
2010
|
+
* - TableBody CSS alternates bg-a / bg-b via nth-child on every <tr>
|
|
2011
|
+
* - Row borders suppressed (handled by alternating bg)
|
|
2012
|
+
*
|
|
2013
|
+
* Expandable tree, non-striped (getSubRows + striped=false):
|
|
2014
|
+
* - All rows: bg-table-bg-a (solid, no alternating)
|
|
2015
|
+
* - Every row has border-b separator
|
|
2016
|
+
*
|
|
2017
|
+
* Expandable tree, striped (getSubRows + striped=true):
|
|
2018
|
+
* - TableBody nth-child alternation applies across all rows (parents + children)
|
|
2019
|
+
* - Row borders suppressed
|
|
2020
|
+
*/}
|
|
2021
|
+
<TableBody striped={striped} data-testid={testId ? `${testId}-tbody` : undefined}>
|
|
2022
|
+
{!isEmpty ? (
|
|
2023
|
+
virtualized ? (
|
|
2024
|
+
(() => {
|
|
2025
|
+
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
2026
|
+
if (!virtualItems.length) return null;
|
|
2027
|
+
// paddingStart models thead height in *scroll* coords; tbody spacers are only
|
|
2028
|
+
// for rows inside tbody — subtract it or the first row is pushed down twice.
|
|
2029
|
+
const top = Math.max(
|
|
2030
|
+
0,
|
|
2031
|
+
virtualItems[0]!.start - stickyHeaderHeight,
|
|
2032
|
+
);
|
|
2033
|
+
const bottom =
|
|
2034
|
+
rowVirtualizer.getTotalSize() -
|
|
2035
|
+
(virtualItems[virtualItems.length - 1]!.end ?? 0);
|
|
2036
|
+
|
|
2037
|
+
return (
|
|
2038
|
+
<>
|
|
2039
|
+
{top > 0 && (
|
|
2040
|
+
<tr aria-hidden>
|
|
2041
|
+
<td
|
|
2042
|
+
colSpan={finalColumns.length}
|
|
2043
|
+
style={{ height: top }}
|
|
2044
|
+
/>
|
|
2045
|
+
</tr>
|
|
2046
|
+
)}
|
|
2047
|
+
{virtualItems.map((item) => {
|
|
2048
|
+
const row = tableRowsForView[item.index]!;
|
|
2049
|
+
const isExpandable = Boolean(getSubRows);
|
|
2050
|
+
const rowBg =
|
|
2051
|
+
isExpandable && !striped
|
|
2052
|
+
? "bg-table-bg-a"
|
|
2053
|
+
: undefined;
|
|
2054
|
+
const isHighlighted = Array.isArray(highlightRowId)
|
|
2055
|
+
? highlightRowId.includes(row.id)
|
|
2056
|
+
: highlightRowId === row.id;
|
|
2057
|
+
const rowProps = {
|
|
2058
|
+
divided: isExpandable && !striped ? true : !striped,
|
|
2059
|
+
colDivided: divided,
|
|
2060
|
+
"data-state": row.getIsSelected()
|
|
2061
|
+
? ("selected" as const)
|
|
2062
|
+
: undefined,
|
|
2063
|
+
"data-highlighted": isHighlighted
|
|
2064
|
+
? "true"
|
|
2065
|
+
: undefined,
|
|
2066
|
+
className: cn(
|
|
2067
|
+
rowBg,
|
|
2068
|
+
onRowClick && "cursor-pointer",
|
|
2069
|
+
rowClassName?.(row, item.index),
|
|
2070
|
+
),
|
|
2071
|
+
onClick: onRowClick
|
|
2072
|
+
? (e: React.MouseEvent<HTMLTableRowElement>) =>
|
|
2073
|
+
onRowClick(row, e)
|
|
2074
|
+
: undefined,
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2077
|
+
return (
|
|
2078
|
+
<TableRow
|
|
2079
|
+
key={row.id}
|
|
2080
|
+
ref={rowVirtualizer.measureElement}
|
|
2081
|
+
data-index={item.index}
|
|
2082
|
+
data-row-id={row.id}
|
|
2083
|
+
{...rowProps}
|
|
2084
|
+
>
|
|
2085
|
+
{renderDataTableBodyCells({
|
|
2086
|
+
row,
|
|
2087
|
+
getSubRows,
|
|
2088
|
+
firstDataCellId,
|
|
2089
|
+
fixedColStyles,
|
|
2090
|
+
flexWidth,
|
|
2091
|
+
lockPxExactWidth,
|
|
2092
|
+
resizable,
|
|
2093
|
+
tableLayout,
|
|
2094
|
+
resizableSlackGrowColId,
|
|
2095
|
+
cellClassName,
|
|
2096
|
+
onCellClick,
|
|
2097
|
+
})}
|
|
2098
|
+
</TableRow>
|
|
2099
|
+
);
|
|
2100
|
+
})}
|
|
2101
|
+
{bottom > 0 && (
|
|
2102
|
+
<tr aria-hidden>
|
|
2103
|
+
<td
|
|
2104
|
+
colSpan={finalColumns.length}
|
|
2105
|
+
style={{ height: bottom }}
|
|
2106
|
+
/>
|
|
2107
|
+
</tr>
|
|
2108
|
+
)}
|
|
2109
|
+
</>
|
|
2110
|
+
);
|
|
2111
|
+
})()
|
|
2112
|
+
) : reorderable ? (
|
|
2113
|
+
<DndContext
|
|
2114
|
+
sensors={rowReorderSensors}
|
|
2115
|
+
collisionDetection={closestCenter}
|
|
2116
|
+
modifiers={[restrictToVerticalAxis]}
|
|
2117
|
+
onDragEnd={handleRowDragEnd}
|
|
2118
|
+
>
|
|
2119
|
+
<SortableContext
|
|
2120
|
+
items={rowIds}
|
|
2121
|
+
strategy={verticalListSortingStrategy}
|
|
2122
|
+
>
|
|
2123
|
+
{tableRowsForView.map((row, rowIndex) => {
|
|
2124
|
+
const isExpandable = Boolean(getSubRows);
|
|
2125
|
+
const rowBg =
|
|
2126
|
+
isExpandable && !striped
|
|
2127
|
+
? "bg-table-bg-a"
|
|
2128
|
+
: undefined;
|
|
2129
|
+
const locked = isRowReorderLocked?.(row);
|
|
2130
|
+
const isHighlighted = Array.isArray(highlightRowId)
|
|
2131
|
+
? highlightRowId.includes(row.id)
|
|
2132
|
+
: highlightRowId === row.id;
|
|
2133
|
+
const rowProps = {
|
|
2134
|
+
divided: isExpandable && !striped ? true : !striped,
|
|
2135
|
+
colDivided: divided,
|
|
2136
|
+
"data-state": row.getIsSelected()
|
|
2137
|
+
? ("selected" as const)
|
|
2138
|
+
: undefined,
|
|
2139
|
+
"data-highlighted": isHighlighted
|
|
2140
|
+
? "true"
|
|
2141
|
+
: undefined,
|
|
2142
|
+
className: cn(
|
|
2143
|
+
rowBg,
|
|
2144
|
+
onRowClick && "cursor-pointer",
|
|
2145
|
+
rowClassName?.(row, rowIndex),
|
|
2146
|
+
),
|
|
2147
|
+
onClick: onRowClick
|
|
2148
|
+
? (e: React.MouseEvent<HTMLTableRowElement>) =>
|
|
2149
|
+
onRowClick(row, e)
|
|
2150
|
+
: undefined,
|
|
2151
|
+
};
|
|
2152
|
+
|
|
2153
|
+
const cells = renderDataTableBodyCells({
|
|
2154
|
+
row,
|
|
2155
|
+
getSubRows,
|
|
2156
|
+
firstDataCellId,
|
|
2157
|
+
fixedColStyles,
|
|
2158
|
+
flexWidth,
|
|
2159
|
+
lockPxExactWidth,
|
|
2160
|
+
resizable,
|
|
2161
|
+
tableLayout,
|
|
2162
|
+
resizableSlackGrowColId,
|
|
2163
|
+
cellClassName,
|
|
2164
|
+
onCellClick,
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
return locked ? (
|
|
2168
|
+
<TableRow
|
|
2169
|
+
key={row.id}
|
|
2170
|
+
data-row-id={row.id}
|
|
2171
|
+
{...rowProps}
|
|
2172
|
+
>
|
|
2173
|
+
{cells}
|
|
2174
|
+
</TableRow>
|
|
2175
|
+
) : (
|
|
2176
|
+
<SortableTableRow
|
|
2177
|
+
key={row.id}
|
|
2178
|
+
id={row.id}
|
|
2179
|
+
data-row-id={row.id}
|
|
2180
|
+
{...rowProps}
|
|
2181
|
+
>
|
|
2182
|
+
{cells}
|
|
2183
|
+
</SortableTableRow>
|
|
2184
|
+
);
|
|
2185
|
+
})}
|
|
2186
|
+
</SortableContext>
|
|
2187
|
+
</DndContext>
|
|
2188
|
+
) : (
|
|
2189
|
+
tableRowsForView.map((row, rowIndex) => {
|
|
2190
|
+
const isExpandable = Boolean(getSubRows);
|
|
2191
|
+
|
|
2192
|
+
// Non-striped expandable: solid bg-a on every row
|
|
2193
|
+
const rowBg =
|
|
2194
|
+
isExpandable && !striped ? "bg-table-bg-a" : undefined;
|
|
2195
|
+
const isHighlighted = Array.isArray(highlightRowId)
|
|
2196
|
+
? highlightRowId.includes(row.id)
|
|
2197
|
+
: highlightRowId === row.id;
|
|
2198
|
+
|
|
2199
|
+
return (
|
|
2200
|
+
<TableRow
|
|
2201
|
+
key={row.id}
|
|
2202
|
+
divided={isExpandable && !striped ? true : !striped}
|
|
2203
|
+
colDivided={divided}
|
|
2204
|
+
data-state={
|
|
2205
|
+
row.getIsSelected() ? "selected" : undefined
|
|
2206
|
+
}
|
|
2207
|
+
className={cn(
|
|
2208
|
+
rowBg,
|
|
2209
|
+
onRowClick && "cursor-pointer",
|
|
2210
|
+
rowClassName?.(row, rowIndex),
|
|
2211
|
+
)}
|
|
2212
|
+
data-highlighted={isHighlighted ? "true" : undefined}
|
|
2213
|
+
onClick={
|
|
2214
|
+
onRowClick
|
|
2215
|
+
? (e: React.MouseEvent) => onRowClick(row, e)
|
|
2216
|
+
: undefined
|
|
2217
|
+
}
|
|
2218
|
+
data-row-id={row.id}
|
|
2219
|
+
>
|
|
2220
|
+
{renderDataTableBodyCells({
|
|
2221
|
+
row,
|
|
2222
|
+
getSubRows,
|
|
2223
|
+
firstDataCellId,
|
|
2224
|
+
fixedColStyles,
|
|
2225
|
+
flexWidth,
|
|
2226
|
+
lockPxExactWidth,
|
|
2227
|
+
resizable,
|
|
2228
|
+
tableLayout,
|
|
2229
|
+
resizableSlackGrowColId,
|
|
2230
|
+
cellClassName,
|
|
2231
|
+
onCellClick,
|
|
2232
|
+
})}
|
|
2233
|
+
</TableRow>
|
|
2234
|
+
);
|
|
2235
|
+
})
|
|
2236
|
+
)
|
|
2237
|
+
) : loading ? (
|
|
2238
|
+
<TableRow
|
|
2239
|
+
className="h-full hover:bg-transparent"
|
|
2240
|
+
divided={false}
|
|
2241
|
+
>
|
|
2242
|
+
<TableCell
|
|
2243
|
+
colSpan={finalColumns.length}
|
|
2244
|
+
className="typography-body1 text-text-g-contrast-medium text-center h-full min-h-[200px]"
|
|
2245
|
+
>
|
|
115
2246
|
<div
|
|
116
|
-
className="flex flex-
|
|
117
|
-
|
|
2247
|
+
className="flex flex-1 min-h-[200px] h-full flex-col items-center justify-center gap-3 py-16"
|
|
2248
|
+
role="status"
|
|
2249
|
+
aria-live="polite"
|
|
2250
|
+
aria-busy="true"
|
|
118
2251
|
>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)}
|
|
125
|
-
{{
|
|
126
|
-
asc: <ArrowUpIcon className="ml-3 h-4 w-4" />,
|
|
127
|
-
desc: <ArrowDownIcon className="ml-3 h-4 w-4" />,
|
|
128
|
-
}[header.column.getIsSorted() as string] ??
|
|
129
|
-
(header.column.getCanSort() ? (
|
|
130
|
-
<ArrowsUpDownIcon className="ml-3 h-4 w-4 text-text-g-contrast-high" />
|
|
131
|
-
) : null)}
|
|
2252
|
+
<Loader2
|
|
2253
|
+
className="size-8 shrink-0 animate-spin text-secondary-120"
|
|
2254
|
+
aria-hidden
|
|
2255
|
+
/>
|
|
2256
|
+
{loadingLabel}
|
|
132
2257
|
</div>
|
|
133
|
-
</TableHead>
|
|
134
|
-
);
|
|
135
|
-
})}
|
|
136
|
-
</TableRow>
|
|
137
|
-
))}
|
|
138
|
-
</TableHeader>
|
|
139
|
-
<TableBody className="overflow-y-scroll">
|
|
140
|
-
{!isEmpty ? (
|
|
141
|
-
table.getRowModel().rows.map((row) => (
|
|
142
|
-
<TableRow
|
|
143
|
-
key={row.id}
|
|
144
|
-
data-state={row.getIsSelected() && "selected"}
|
|
145
|
-
className=""
|
|
146
|
-
>
|
|
147
|
-
{row.getVisibleCells().map((cell) => (
|
|
148
|
-
<TableCell key={cell.id}>
|
|
149
|
-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
150
2258
|
</TableCell>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
2259
|
+
</TableRow>
|
|
2260
|
+
) : (
|
|
2261
|
+
<TableRow
|
|
2262
|
+
className="h-full hover:bg-transparent"
|
|
2263
|
+
divided={false}
|
|
2264
|
+
>
|
|
2265
|
+
<TableCell
|
|
2266
|
+
colSpan={finalColumns.length}
|
|
2267
|
+
className="typography-body1 text-text-g-contrast-medium text-center h-full"
|
|
2268
|
+
>
|
|
2269
|
+
<div className="flex flex-1 h-full flex-col items-center justify-center gap-3 py-16">
|
|
2270
|
+
<ClipboardList className="w-8 text-secondary-120" />
|
|
2271
|
+
There is no information yet.
|
|
2272
|
+
</div>
|
|
2273
|
+
</TableCell>
|
|
2274
|
+
</TableRow>
|
|
2275
|
+
)}
|
|
2276
|
+
</TableBody>
|
|
2277
|
+
{paginationMode === "infinite" &&
|
|
2278
|
+
fetchingMore &&
|
|
2279
|
+
!isEmpty &&
|
|
2280
|
+
table.getVisibleLeafColumns().length > 0 && (
|
|
2281
|
+
<tfoot className="[&_tr]:bg-table-c-row-bg">
|
|
2282
|
+
<TableRow
|
|
2283
|
+
divided={false}
|
|
2284
|
+
className="hover:!bg-table-c-row-bg border-t border-t-table-c-row-line"
|
|
2285
|
+
>
|
|
2286
|
+
<TableCell
|
|
2287
|
+
colSpan={table.getVisibleLeafColumns().length}
|
|
2288
|
+
className="py-3 text-center typography-body3 text-text-g-contrast-medium bg-inherit"
|
|
2289
|
+
>
|
|
2290
|
+
<span
|
|
2291
|
+
role="status"
|
|
2292
|
+
aria-live="polite"
|
|
2293
|
+
className="inline-flex items-center justify-center gap-2"
|
|
2294
|
+
>
|
|
2295
|
+
<Loader2
|
|
2296
|
+
className="size-4 shrink-0 animate-spin"
|
|
2297
|
+
aria-hidden
|
|
2298
|
+
/>
|
|
2299
|
+
{fetchingMoreLabel}
|
|
2300
|
+
</span>
|
|
2301
|
+
</TableCell>
|
|
2302
|
+
</TableRow>
|
|
2303
|
+
</tfoot>
|
|
2304
|
+
)}
|
|
2305
|
+
</Table>
|
|
2306
|
+
</div>
|
|
2307
|
+
|
|
2308
|
+
{isPaginated && <TablePagination {...paginationBarProps} />}
|
|
2309
|
+
|
|
2310
|
+
{/* Manage-column panel — portal so it survives column hide/reorder */}
|
|
2311
|
+
{columnManagement && manageOpen && (
|
|
2312
|
+
<Portal.Root>
|
|
2313
|
+
<div
|
|
2314
|
+
className="fixed inset-0 z-40"
|
|
2315
|
+
onClick={() => setManageOpen(false)}
|
|
2316
|
+
/>
|
|
2317
|
+
<div
|
|
2318
|
+
className="fixed z-50 w-[460px] rounded-lg bg-modal-surface shadow-[0px_12px_24px_-4px_rgba(0,0,0,0.12)] overflow-hidden"
|
|
2319
|
+
style={{
|
|
2320
|
+
top: managePanelPos.top,
|
|
2321
|
+
right: managePanelPos.right,
|
|
2322
|
+
}}
|
|
2323
|
+
onClick={(e) => e.stopPropagation()}
|
|
2324
|
+
>
|
|
2325
|
+
<ManageColumnPanel
|
|
2326
|
+
table={table}
|
|
2327
|
+
onClose={() => setManageOpen(false)}
|
|
2328
|
+
maxListHeight={managePanelPos.maxListHeight}
|
|
2329
|
+
options={columnManagementOptions}
|
|
2330
|
+
/>
|
|
2331
|
+
</div>
|
|
2332
|
+
</Portal.Root>
|
|
2333
|
+
)}
|
|
2334
|
+
</div>
|
|
2335
|
+
</EditContext.Provider>
|
|
170
2336
|
);
|
|
171
2337
|
}
|