@rovula/ui 0.1.28 → 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.
Files changed (65) hide show
  1. package/dist/cjs/bundle.css +501 -67
  2. package/dist/cjs/bundle.js +589 -589
  3. package/dist/cjs/bundle.js.map +1 -1
  4. package/dist/cjs/types/components/DataTable/DataTable.d.ts +195 -4
  5. package/dist/cjs/types/components/DataTable/DataTable.editing.d.ts +20 -0
  6. package/dist/cjs/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
  7. package/dist/cjs/types/components/DataTable/DataTable.stories.d.ts +268 -6
  8. package/dist/cjs/types/components/Dropdown/Dropdown.d.ts +22 -0
  9. package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
  10. package/dist/cjs/types/components/ScrollArea/ScrollArea.d.ts +3 -3
  11. package/dist/cjs/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
  12. package/dist/cjs/types/components/Table/Table.d.ts +33 -3
  13. package/dist/cjs/types/components/Table/Table.stories.d.ts +86 -4
  14. package/dist/cjs/types/components/TextInput/TextInput.stories.d.ts +8 -0
  15. package/dist/cjs/types/components/TextInput/TextInput.styles.d.ts +1 -0
  16. package/dist/components/DataTable/DataTable.editing.js +385 -0
  17. package/dist/components/DataTable/DataTable.editing.types.js +1 -0
  18. package/dist/components/DataTable/DataTable.js +983 -50
  19. package/dist/components/DataTable/DataTable.stories.js +1077 -25
  20. package/dist/components/Dropdown/Dropdown.js +8 -6
  21. package/dist/components/ScrollArea/ScrollArea.js +2 -2
  22. package/dist/components/ScrollArea/ScrollArea.stories.js +68 -2
  23. package/dist/components/Table/Table.js +103 -13
  24. package/dist/components/Table/Table.stories.js +226 -9
  25. package/dist/components/TextInput/TextInput.js +6 -4
  26. package/dist/components/TextInput/TextInput.stories.js +8 -0
  27. package/dist/components/TextInput/TextInput.styles.js +7 -1
  28. package/dist/esm/bundle.css +501 -67
  29. package/dist/esm/bundle.js +1545 -1545
  30. package/dist/esm/bundle.js.map +1 -1
  31. package/dist/esm/types/components/DataTable/DataTable.d.ts +195 -4
  32. package/dist/esm/types/components/DataTable/DataTable.editing.d.ts +20 -0
  33. package/dist/esm/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
  34. package/dist/esm/types/components/DataTable/DataTable.stories.d.ts +268 -6
  35. package/dist/esm/types/components/Dropdown/Dropdown.d.ts +22 -0
  36. package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
  37. package/dist/esm/types/components/ScrollArea/ScrollArea.d.ts +3 -3
  38. package/dist/esm/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
  39. package/dist/esm/types/components/Table/Table.d.ts +33 -3
  40. package/dist/esm/types/components/Table/Table.stories.d.ts +86 -4
  41. package/dist/esm/types/components/TextInput/TextInput.stories.d.ts +8 -0
  42. package/dist/esm/types/components/TextInput/TextInput.styles.d.ts +1 -0
  43. package/dist/index.d.ts +493 -122
  44. package/dist/src/theme/global.css +747 -96
  45. package/package.json +14 -2
  46. package/src/components/DataTable/DataTable.editing.tsx +861 -0
  47. package/src/components/DataTable/DataTable.editing.types.ts +192 -0
  48. package/src/components/DataTable/DataTable.stories.tsx +2169 -31
  49. package/src/components/DataTable/DataTable.test.tsx +696 -0
  50. package/src/components/DataTable/DataTable.tsx +2260 -94
  51. package/src/components/Dropdown/Dropdown.tsx +22 -6
  52. package/src/components/ScrollArea/ScrollArea.stories.tsx +146 -3
  53. package/src/components/ScrollArea/ScrollArea.tsx +6 -6
  54. package/src/components/Table/Table.stories.tsx +789 -44
  55. package/src/components/Table/Table.tsx +294 -28
  56. package/src/components/TextInput/TextInput.stories.tsx +80 -0
  57. package/src/components/TextInput/TextInput.styles.ts +7 -1
  58. package/src/components/TextInput/TextInput.tsx +21 -14
  59. package/src/test/setup.ts +50 -0
  60. package/src/theme/global.css +81 -42
  61. package/src/theme/presets/colors.js +12 -0
  62. package/src/theme/themes/variable.css +27 -28
  63. package/src/theme/tokens/baseline.css +2 -1
  64. package/src/theme/tokens/components/scrollbar.css +9 -4
  65. 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
- OnChangeFn,
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
- // getPaginationRowModel,
21
+ getPaginationRowModel,
11
22
  getSortedRowModel,
12
23
  useReactTable,
13
24
  } from "@tanstack/react-table";
14
- import React, { useEffect, useRef, useState } from "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
- ArrowDownIcon,
18
- ArrowUpIcon,
19
- ArrowsUpDownIcon,
20
- ClipboardDocumentListIcon,
21
- } from "@heroicons/react/16/solid";
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 interface DataTableProps<TData, TValue> {
33
- columns: ColumnDef<TData, TValue>[];
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
- const tableBodyRef = useRef<HTMLTableSectionElement>(null);
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 [rowSelection, setRowSelection] = useState({});
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 table = useReactTable({
55
- data,
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
- onColumnVisibilityChange: setColumnVisibility,
65
- onRowSelectionChange: setRowSelection,
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
- // pagination: {
72
- // pageSize: 100,
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 (tableBodyRef.current) {
85
- const { scrollTop, scrollHeight, clientHeight } = tableBodyRef.current;
86
- if (scrollTop + clientHeight >= scrollHeight - 10) {
87
- fetchMoreData?.();
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
- const tableBodyElement = tableBodyRef.current;
93
- if (tableBodyElement) {
94
- tableBodyElement.addEventListener("scroll", handleScroll);
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
- return () => {
98
- if (tableBodyElement) {
99
- tableBodyElement.removeEventListener("scroll", handleScroll);
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
- const isEmpty = table.getRowModel().rows?.length === 0;
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
- <div className="flex w-full h-full rounded-xl overflow-hidden border border-primary-10">
108
- <Table className={isEmpty ? "h-full" : ""} rootRef={tableBodyRef}>
109
- <TableHeader className="sticky top-0">
110
- {table.getHeaderGroups().map((headerGroup) => (
111
- <TableRow key={headerGroup.id} className="">
112
- {headerGroup.headers.map((header, i) => {
113
- return (
114
- <TableHead key={header.id}>
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-row items-center cursor-pointer"
117
- onClick={header.column.getToggleSortingHandler()}
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
- {header.isPlaceholder
120
- ? null
121
- : flexRender(
122
- header.column.columnDef.header,
123
- header.getContext()
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
- </TableRow>
153
- ))
154
- ) : (
155
- <TableRow className="h-full self-stretch">
156
- <TableCell
157
- colSpan={columns.length}
158
- className="typography-body1 text-text-g-contrast-medium text-center h-full"
159
- >
160
- <div className="flex flex-1 h-full flex-col items-center justify-center gap-3">
161
- <ClipboardDocumentListIcon className="w-8 text-secondary-120" />
162
- There is no information yet.
163
- </div>
164
- </TableCell>
165
- </TableRow>
166
- )}
167
- </TableBody>
168
- </Table>
169
- </div>
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
  }