@rovula/ui 0.1.28 → 0.1.30

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