aplance-ui 1.4.0 → 1.5.0

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 (38) hide show
  1. package/package.json +1 -1
  2. package/registry/components/primitives/icon/icon.tsx +4 -6
  3. package/registry/components/primitives/modal/modal.module.css +5 -0
  4. package/registry/components/primitives/modal/modal.tsx +32 -34
  5. package/registry/components/primitives/popover/popover.module.css +4 -2
  6. package/registry/components/primitives/popover/popover.tsx +16 -17
  7. package/registry/components/ui/accordion/accordion.module.css +52 -0
  8. package/registry/components/ui/accordion/accordion.tsx +97 -25
  9. package/registry/components/ui/avatar/avatar.tsx +2 -2
  10. package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
  11. package/registry/components/ui/button/button.module.css +43 -5
  12. package/registry/components/ui/button/button.tsx +2 -1
  13. package/registry/components/ui/copy-button/copy-button.module.css +2 -0
  14. package/registry/components/ui/data-table/data-table.module.css +68 -25
  15. package/registry/components/ui/data-table/data-table.tsx +1238 -1229
  16. package/registry/components/ui/data-table/filters.tsx +21 -0
  17. package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
  18. package/registry/components/ui/data-table/types.ts +11 -1
  19. package/registry/components/ui/data-table/useDataTable.tsx +1 -2
  20. package/registry/components/ui/data-table/utils.ts +4 -3
  21. package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
  22. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
  23. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +115 -84
  24. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +63 -11
  25. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +51 -55
  26. package/registry/components/ui/input-fields/input/input.tsx +348 -348
  27. package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
  28. package/registry/components/ui/input-fields/media-input/types.ts +8 -2
  29. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
  30. package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
  31. package/registry/components/ui/scroll-rail/scroll-rail.module.css +104 -0
  32. package/registry/components/ui/scroll-rail/scroll-rail.tsx +159 -0
  33. package/registry/components/ui/tabs/tabs.module.css +8 -12
  34. package/registry/components/ui/tabs/tabs.tsx +75 -44
  35. package/registry/components/ui/tooltip/tooltip.module.css +8 -3
  36. package/registry/components/ui/tooltip/tooltip.tsx +16 -3
  37. package/registry/lib/string.ts +3 -3
  38. package/registry/registry.json +33 -3
@@ -1,1229 +1,1238 @@
1
- import {
2
- flexRender,
3
- getCoreRowModel,
4
- getExpandedRowModel,
5
- getGroupedRowModel,
6
- getPaginationRowModel,
7
- getSortedRowModel,
8
- useReactTable,
9
- type Column,
10
- type ColumnPinningState,
11
- type PaginationState as TanstackPaginationState,
12
- type Updater,
13
- } from "@tanstack/react-table";
14
- import {
15
- useCallback,
16
- useEffect,
17
- useLayoutEffect,
18
- useMemo,
19
- useRef,
20
- useState,
21
- type CSSProperties,
22
- type DragEvent as ReactDragEvent,
23
- type ReactNode,
24
- } from "react";
25
- import clsx from "clsx";
26
- import { FilterBar } from "./filter-bar";
27
- import { FilterModal } from "./filter-modal";
28
- import { PaginationBar } from "./pagination-bar";
29
- import { Loader } from "../loader/loader";
30
- import type {
31
- BodyCellProps,
32
- BodyRowProps,
33
- DataTableMeta,
34
- DataTableProps,
35
- HeaderActionButtonProps,
36
- HeaderCellProps,
37
- } from "./types";
38
- import {
39
- columnId,
40
- formatAggregated,
41
- getColumnStyle,
42
- isLastPinned,
43
- normalizeColumnPinningState,
44
- reorderColumnOrder,
45
- resolveUpdater,
46
- SELECT_COLUMN_ID,
47
- } from "./utils";
48
- import s from "./data-table.module.css";
49
- import { Tooltip } from "../tooltip/tooltip";
50
- import {
51
- IconArrowsUpDown,
52
- IconArrowUp,
53
- IconArrowDown,
54
- IconChevronRight,
55
- IconGripVertical,
56
- IconStack2,
57
- IconStack3,
58
- IconPinnedOff,
59
- IconArrowLeft,
60
- IconArrowRight,
61
- } from "@tabler/icons-react";
62
- import { getScrollbarWidth } from "../../../lib/dom";
63
- import { Button } from "../button/button";
64
- import { useMediaQuery } from "../../../hooks/useMediaQuery";
65
-
66
- // Below this width the inline filter row can't fit its fields (matches the
67
- // `--md` custom-media token in styles/media.css); the table collapses filters
68
- // into a single "Filters" button + modal instead of stacking them full-height.
69
- const FILTER_COMPACT_QUERY = "(max-width: 900px)";
70
-
71
- const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50];
72
- const DEFAULT_PAGINATION = { pageIndex: 0, pageSize: 10 };
73
- // Stable refs for optional handlers. Inline `= () => {}` defaults allocate a
74
- // fresh function each render, invalidating any memo that depends on them
75
- // (notably `meta` below, which passes `onUpdateData` through tanstack).
76
- const NOOP_FILTER = () => {};
77
- const NOOP_PAGINATION = () => {};
78
- const NOOP_UPDATE = () => {};
79
- const POINTER_STYLE = { cursor: "pointer" } as const;
80
- const EMPTY_FILTER_VALUES: Record<string, string> = {};
81
- const ICON_SIZE = 15;
82
- const SORT_ICON_SIZE = 13;
83
- const ICON_STROKE = 1.6;
84
- const EMPTY_COLUMN_EXTRA_WIDTHS: Record<string, number> = {};
85
- // Grace period after `isLoading` flips false before committing to the empty
86
- // state. Eliminates the flash when consumers set isLoading=false a render
87
- // before populating rows, without making genuinely-empty results feel sluggish.
88
- const EMPTY_STATE_GRACE_MS = 150;
89
-
90
- const MIN_SKELETON_ROWS = 1;
91
- const useIsomorphicLayoutEffect =
92
- typeof window === "undefined" ? useEffect : useLayoutEffect;
93
-
94
- export const DataTable = <TData extends { id: string }>({
95
- data,
96
- columns,
97
- isLoading = false,
98
- emptyMessage = "No results found.",
99
-
100
- filters = [],
101
- filterValues = EMPTY_FILTER_VALUES,
102
- onFilterChange = NOOP_FILTER,
103
-
104
- sorting,
105
- onSortingChange,
106
- manualSorting = false,
107
-
108
- showPagination = true,
109
- pagination = DEFAULT_PAGINATION,
110
- onPaginationChange = NOOP_PAGINATION,
111
- totalPages,
112
- totalCount,
113
- pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
114
- showPageSizeSelect = true,
115
- rowLabel = "row",
116
-
117
- rowsSelectable = false,
118
- getRowCanSelect,
119
- rowSelection = {},
120
- onRowSelectionChange,
121
-
122
- editingRowId = null,
123
- onUpdateData = NOOP_UPDATE,
124
-
125
- enableColumnOrdering = false,
126
- columnOrder,
127
- onColumnOrderChange,
128
- enableColumnResizing = false,
129
- columnSizing,
130
- onColumnSizingChange,
131
- enableColumnPinning = false,
132
- columnPinning,
133
- onColumnPinningChange,
134
- enableRowOrdering = false,
135
- onRowOrderChange,
136
- columnVisibility,
137
- onColumnVisibilityChange,
138
-
139
- enableGrouping = false,
140
- grouping,
141
- onGroupingChange,
142
- expanded,
143
- onExpandedChange,
144
-
145
- defaultColumnPinning,
146
-
147
- showVerticalBorders = false,
148
-
149
- onRow,
150
- paginationExtra,
151
- className,
152
- }: DataTableProps<TData>) => {
153
- // Collapse the filter row into a button + modal once it no longer fits inline.
154
- const filtersCompact = useMediaQuery(FILTER_COMPACT_QUERY);
155
-
156
- // ── Table meta ──────────────────────────────────────────────────────────────
157
- const meta: DataTableMeta<TData> = useMemo(
158
- () => ({ editingRowId, updateData: onUpdateData }),
159
- [editingRowId, onUpdateData],
160
- );
161
-
162
- const shouldPinSelectionColumn =
163
- rowsSelectable &&
164
- columns.some((columnDef) => columnId(columnDef) === SELECT_COLUMN_ID);
165
-
166
- const resolvedControlledColumnPinning = useMemo(
167
- () =>
168
- columnPinning === undefined
169
- ? undefined
170
- : normalizeColumnPinningState(
171
- columnPinning,
172
- shouldPinSelectionColumn,
173
- defaultColumnPinning,
174
- ),
175
- [columnPinning, shouldPinSelectionColumn, defaultColumnPinning],
176
- );
177
-
178
- const initialColumnPinning = useMemo(
179
- () =>
180
- enableColumnPinning && columnPinning === undefined
181
- ? normalizeColumnPinningState(
182
- undefined,
183
- shouldPinSelectionColumn,
184
- defaultColumnPinning,
185
- )
186
- : undefined,
187
- [
188
- enableColumnPinning,
189
- columnPinning,
190
- shouldPinSelectionColumn,
191
- defaultColumnPinning,
192
- ],
193
- );
194
-
195
- const handleColumnPinningChange = useCallback(
196
- (updater: Updater<ColumnPinningState>) => {
197
- if (!onColumnPinningChange) return;
198
-
199
- onColumnPinningChange((prev) => {
200
- const normalizedPrev =
201
- normalizeColumnPinningState(
202
- prev,
203
- shouldPinSelectionColumn,
204
- defaultColumnPinning,
205
- ) ?? {};
206
- const next = resolveUpdater(updater, normalizedPrev);
207
-
208
- // Newly right-pinned columns are inserted on the inner edge (closer
209
- // to the center) so the recently-pinned column visually slides in
210
- // next to the body, leaving previously-pinned columns flush against
211
- // the viewport edge.
212
- const prevRight = normalizedPrev.right ?? [];
213
- const nextRight = next.right ?? [];
214
- const addedToRight = nextRight.filter((id) => !prevRight.includes(id));
215
- const keptInRight = nextRight.filter((id) => prevRight.includes(id));
216
-
217
- return (
218
- normalizeColumnPinningState(
219
- { left: next.left, right: [...addedToRight, ...keptInRight] },
220
- shouldPinSelectionColumn,
221
- defaultColumnPinning,
222
- ) ?? {}
223
- );
224
- });
225
- },
226
- [onColumnPinningChange, shouldPinSelectionColumn, defaultColumnPinning],
227
- );
228
-
229
- // ── TanStack table instance ─────────────────────────────────────────────────
230
- // Filtering is delegated to the consumer (see `useDataTable.filterFn`), so we
231
- // don't wire any tanstack filter row-model here - rows arrive pre-filtered.
232
- // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table relies on function-returning APIs by design.
233
- const table = useReactTable<TData>({
234
- data,
235
- columns,
236
- enableRowSelection: rowsSelectable ? (getRowCanSelect ?? true) : false,
237
- enableColumnResizing,
238
- enableColumnPinning,
239
- enableGrouping,
240
- columnResizeMode: "onChange",
241
- state: {
242
- sorting,
243
- ...(showPagination && {
244
- pagination: pagination as TanstackPaginationState,
245
- }),
246
- ...(rowsSelectable && { rowSelection }),
247
- ...(enableColumnOrdering && columnOrder && { columnOrder }),
248
- ...(enableColumnResizing && columnSizing && { columnSizing }),
249
- ...(enableColumnPinning &&
250
- resolvedControlledColumnPinning && {
251
- columnPinning: resolvedControlledColumnPinning,
252
- }),
253
- ...(columnVisibility && { columnVisibility }),
254
- ...(enableGrouping && grouping && { grouping }),
255
- ...(enableGrouping && expanded !== undefined && { expanded }),
256
- },
257
- initialState: {
258
- ...(enableColumnPinning &&
259
- initialColumnPinning && {
260
- columnPinning: initialColumnPinning,
261
- }),
262
- },
263
- onSortingChange,
264
- ...(showPagination && {
265
- onPaginationChange: (updater) => {
266
- const cur = pagination as TanstackPaginationState;
267
- const next = typeof updater === "function" ? updater(cur) : updater;
268
- onPaginationChange(next);
269
- },
270
- }),
271
- ...(rowsSelectable && onRowSelectionChange && { onRowSelectionChange }),
272
- ...(enableColumnOrdering && onColumnOrderChange && { onColumnOrderChange }),
273
- ...(enableColumnResizing &&
274
- onColumnSizingChange && { onColumnSizingChange }),
275
- ...(enableColumnPinning &&
276
- onColumnPinningChange && {
277
- onColumnPinningChange: handleColumnPinningChange,
278
- }),
279
- ...(onColumnVisibilityChange && { onColumnVisibilityChange }),
280
- ...(enableGrouping && onGroupingChange && { onGroupingChange }),
281
- ...(enableGrouping && onExpandedChange && { onExpandedChange }),
282
- getRowId: (row) => row.id,
283
- getCoreRowModel: getCoreRowModel(),
284
- ...(!manualSorting && { getSortedRowModel: getSortedRowModel() }),
285
- ...(showPagination && {
286
- getPaginationRowModel: getPaginationRowModel(),
287
- pageCount: totalPages ?? -1,
288
- }),
289
- ...(enableGrouping && {
290
- // Keep grouped columns in place so header controls stay spatially stable.
291
- groupedColumnMode: false,
292
- getGroupedRowModel: getGroupedRowModel(),
293
- getExpandedRowModel: getExpandedRowModel(),
294
- }),
295
- manualSorting,
296
- manualPagination: showPagination,
297
- meta,
298
- });
299
-
300
- // ── Column drag-and-drop reorder (HTML5 native, no extra deps) ──────────────
301
- const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
302
- const [dropTargetColumnId, setDropTargetColumnId] = useState<string | null>(
303
- null,
304
- );
305
-
306
- const handleColumnDrop = useCallback(
307
- (sourceId: string, targetId: string) => {
308
- if (sourceId === targetId) return;
309
- const targetCol = table.getColumn(targetId);
310
- // Refuse drops onto fixed columns (e.g. the row-select column) so they
311
- // stay anchored in their position.
312
- if (targetCol && targetCol.getCanHide() === false) return;
313
- const current =
314
- columnOrder && columnOrder.length
315
- ? columnOrder
316
- : table.getAllLeafColumns().map((c) => c.id);
317
- const next = reorderColumnOrder(current, sourceId, targetId);
318
- table.setColumnOrder(next);
319
- },
320
- [columnOrder, table],
321
- );
322
-
323
- const handleDragEnd = useCallback(() => {
324
- setDraggedColumnId(null);
325
- setDropTargetColumnId(null);
326
- }, []);
327
-
328
- const [draggedRowId, setDraggedRowId] = useState<string | null>(null);
329
- const [dropTargetRowId, setDropTargetRowId] = useState<string | null>(null);
330
-
331
- const handleDragStartRow = useCallback((id: string) => {
332
- setDraggedRowId(id);
333
- }, []);
334
-
335
- const handleDragOverRow = useCallback((id: string | null) => {
336
- setDropTargetRowId(id);
337
- }, []);
338
-
339
- const handleDragEndRow = useCallback(() => {
340
- setDraggedRowId(null);
341
- setDropTargetRowId(null);
342
- }, []);
343
-
344
- const handleDropRow = useCallback(
345
- (draggedId: string, targetId: string) => {
346
- const draggedRow = table.getRow(draggedId);
347
- const targetRow = table.getRow(targetId);
348
- if (draggedRow && targetRow) {
349
- onRowOrderChange?.(draggedRow.original, targetRow.original);
350
- }
351
- },
352
- [onRowOrderChange, table],
353
- );
354
-
355
- // ── Sync header/body scrolling ──────────────────────────────────────────────
356
- // The header lives in its own `overflow:hidden` container; we drive its
357
- // scrollLeft from the body's scrollLeft on every scroll event so pinned
358
- // columns stay perfectly aligned with their body counterparts.
359
- const headerRef = useRef<HTMLDivElement>(null);
360
- const bodyRef = useRef<HTMLDivElement>(null);
361
- const bodyTableRef = useRef<HTMLTableElement>(null);
362
- const skeletonRowRef = useRef<HTMLTableRowElement>(null);
363
- const rows = table.getRowModel().rows;
364
- const headerCount = table.getFlatHeaders().length;
365
- const totalWidth = table.getTotalSize();
366
- const tableState = table.getState();
367
- const tableColumnPinning = tableState.columnPinning;
368
- const tableColumnSizing = tableState.columnSizing;
369
- const tableColumnOrder = tableState.columnOrder;
370
- const tableColumnVisibility = tableState.columnVisibility;
371
- const tableGrouping = tableState.grouping;
372
- const [bodyViewportWidth, setBodyViewportWidth] = useState(0);
373
- const bodyViewportWidthRef = useRef(0);
374
- // Body wrap height tracked separately so the loading skeleton can render
375
- // exactly the count of rows that fits the visible body. Avoids the
376
- // "skeleton overflows + body wrap scrolls" feel during cold load.
377
- const [bodyViewportHeight, setBodyViewportHeight] = useState(0);
378
- const bodyViewportHeightRef = useRef(0);
379
- const [skeletonRowHeight, setSkeletonRowHeight] = useState(0);
380
- const skeletonRowHeightRef = useRef(0);
381
-
382
- const commitBodyViewportWidth = useCallback((nextWidth: number) => {
383
- if (bodyViewportWidthRef.current === nextWidth) return;
384
- bodyViewportWidthRef.current = nextWidth;
385
- setBodyViewportWidth(nextWidth);
386
- }, []);
387
-
388
- const commitBodyViewportHeight = useCallback((nextHeight: number) => {
389
- if (bodyViewportHeightRef.current === nextHeight) return;
390
- bodyViewportHeightRef.current = nextHeight;
391
- setBodyViewportHeight(nextHeight);
392
- }, []);
393
-
394
- const commitSkeletonRowHeight = useCallback((nextHeight: number) => {
395
- if (nextHeight <= 0 || skeletonRowHeightRef.current === nextHeight) return;
396
- skeletonRowHeightRef.current = nextHeight;
397
- setSkeletonRowHeight(nextHeight);
398
- }, []);
399
-
400
- const syncHeaderMetrics = useCallback(() => {
401
- const body = bodyRef.current;
402
- const header = headerRef.current;
403
- if (!body || !header) return;
404
-
405
- const scrollbarW = getScrollbarWidth();
406
- const hasScroll = body.scrollHeight > body.clientHeight;
407
- const paddingRight = hasScroll ? `${scrollbarW}px` : "0px";
408
-
409
- header.style.paddingRight = paddingRight;
410
- header.parentElement?.style.setProperty(
411
- "--header-padding-right",
412
- paddingRight,
413
- );
414
- header.parentElement?.setAttribute("data-has-scroll", hasScroll.toString());
415
- // Mirror onto the body wrap so CSS can release the reserved scrollbar gutter
416
- // when there's no vertical overflow (no blank strip on a few-row table).
417
- body.setAttribute("data-has-scroll", hasScroll.toString());
418
-
419
- // Re-sync horizontal scroll position after width changes so pinned
420
- // columns stay perfectly aligned with their body counterparts.
421
- header.scrollLeft = body.scrollLeft;
422
- commitBodyViewportWidth(body.clientWidth);
423
- commitBodyViewportHeight(body.clientHeight);
424
- }, [commitBodyViewportWidth, commitBodyViewportHeight]);
425
-
426
- useIsomorphicLayoutEffect(() => {
427
- syncHeaderMetrics();
428
- }, [
429
- rows.length,
430
- headerCount,
431
- totalWidth,
432
- tableColumnPinning,
433
- tableColumnSizing,
434
- tableColumnOrder,
435
- tableColumnVisibility,
436
- tableGrouping,
437
- syncHeaderMetrics,
438
- ]);
439
-
440
- useIsomorphicLayoutEffect(() => {
441
- const body = bodyRef.current;
442
- if (!body) return;
443
- if (typeof ResizeObserver === "undefined") return;
444
-
445
- const observer = new ResizeObserver(syncHeaderMetrics);
446
- observer.observe(body);
447
- if (bodyTableRef.current) observer.observe(bodyTableRef.current);
448
-
449
- return () => {
450
- observer.disconnect();
451
- };
452
- }, [syncHeaderMetrics, rows.length, isLoading]);
453
-
454
- useEffect(() => {
455
- const body = bodyRef.current;
456
- const header = headerRef.current;
457
- if (!body || !header) return;
458
-
459
- const syncFromBody = () => {
460
- if (header.scrollLeft !== body.scrollLeft) {
461
- header.scrollLeft = body.scrollLeft;
462
- }
463
- };
464
-
465
- const syncFromHeader = () => {
466
- if (body.scrollLeft !== header.scrollLeft) {
467
- body.scrollLeft = header.scrollLeft;
468
- }
469
- };
470
-
471
- body.addEventListener("scroll", syncFromBody, { passive: true });
472
- header.addEventListener("scroll", syncFromHeader, { passive: true });
473
-
474
- return () => {
475
- body.removeEventListener("scroll", syncFromBody);
476
- header.removeEventListener("scroll", syncFromHeader);
477
- };
478
- }, []);
479
-
480
- // ── Derived UI state ────────────────────────────────────────────────────────
481
- // Defer the empty state by a tick after a loading-to-idle transition so the
482
- // loader doesn't flicker into "no results" for one paint before rows land.
483
- // Genuinely empty results (no in-flight load) commit immediately on mount.
484
- const isRowsEmpty = rows.length === 0;
485
- const [showEmptyState, setShowEmptyState] = useState(
486
- () => !isLoading && isRowsEmpty,
487
- );
488
- const wasLoadingRef = useRef(isLoading);
489
-
490
- // wasLoadingRef is intentionally omitted from the dep array: it's a ref used
491
- // to track the previous value of isLoading across renders without triggering
492
- // re-runs. React guarantees ref objects are stable across the component lifetime.
493
- useEffect(() => {
494
- const wasLoading = wasLoadingRef.current;
495
- wasLoadingRef.current = isLoading;
496
-
497
- if (isLoading || !isRowsEmpty) {
498
- setShowEmptyState(false);
499
- return;
500
- }
501
- if (!wasLoading) {
502
- setShowEmptyState(true);
503
- return;
504
- }
505
- const id = window.setTimeout(
506
- () => setShowEmptyState(true),
507
- EMPTY_STATE_GRACE_MS,
508
- );
509
- return () => window.clearTimeout(id);
510
- }, [isLoading, isRowsEmpty]);
511
-
512
- // Single tab-like discriminator drives the body - loader, empty, or content.
513
- // While loading OR while bridging the loading-to-empty grace window, the
514
- // loader stays visible to avoid the brief "no results" flash.
515
- const view: "loader" | "empty" | "content" =
516
- isLoading || (isRowsEmpty && !showEmptyState)
517
- ? "loader"
518
- : isRowsEmpty
519
- ? "empty"
520
- : "content";
521
-
522
- useIsomorphicLayoutEffect(() => {
523
- if (view !== "loader") return;
524
- const row = skeletonRowRef.current;
525
- if (!row) return;
526
-
527
- const measureRow = () => {
528
- commitSkeletonRowHeight(Math.ceil(row.getBoundingClientRect().height));
529
- };
530
-
531
- measureRow();
532
- if (typeof ResizeObserver === "undefined") return;
533
-
534
- const observer = new ResizeObserver(measureRow);
535
- observer.observe(row);
536
-
537
- return () => {
538
- observer.disconnect();
539
- };
540
- }, [commitSkeletonRowHeight, view]);
541
-
542
- const selectedCount = rowsSelectable
543
- ? table.getSelectedRowModel().flatRows.length
544
- : 0;
545
- const headerGroups = table.getHeaderGroups();
546
- const visibleLeafColumns = table.getVisibleLeafColumns();
547
-
548
- const skeletonRowCount =
549
- bodyViewportHeight > 0 && skeletonRowHeight > 0
550
- ? Math.max(
551
- MIN_SKELETON_ROWS,
552
- Math.floor(bodyViewportHeight / skeletonRowHeight),
553
- )
554
- : MIN_SKELETON_ROWS;
555
-
556
- const columnExtraWidths = useMemo<Record<string, number>>(() => {
557
- const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
558
- if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
559
-
560
- // Never add responsive width onto pinned columns because tanstack computes
561
- // sticky offsets from base sizes; changing pinned visual width would desync
562
- // left/right offsets and reintroduce overlap.
563
- const growColumns = visibleLeafColumns.filter(
564
- (column) => !column.getIsPinned() && column.id !== SELECT_COLUMN_ID,
565
- );
566
- if (growColumns.length === 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
567
-
568
- const baseExtra = Math.floor(extraSpace / growColumns.length);
569
- let remainder = extraSpace - baseExtra * growColumns.length;
570
-
571
- const next: Record<string, number> = {};
572
- for (const column of growColumns) {
573
- const grow = baseExtra + (remainder > 0 ? 1 : 0);
574
- if (grow > 0) {
575
- next[column.id] = grow;
576
- }
577
- if (remainder > 0) {
578
- remainder -= 1;
579
- }
580
- }
581
-
582
- return next;
583
- }, [bodyViewportWidth, totalWidth, visibleLeafColumns]);
584
-
585
- const tableWidth = Math.max(totalWidth, bodyViewportWidth);
586
-
587
- // Match the table element width to the measured column widths. This keeps
588
- // header, body, and skeleton rows on the same sizing contract during resize.
589
- const tableSizingStyle: CSSProperties = useMemo(
590
- () => ({ width: `${tableWidth}px`, minWidth: `${totalWidth}px` }),
591
- [tableWidth, totalWidth],
592
- );
593
-
594
- const bodySkeletonStyle: CSSProperties = {
595
- height: "var(--spacing-9)",
596
- };
597
-
598
- const selectSkeletonStyle: CSSProperties = {
599
- width: "var(--data-table-selection-control-size, var(--spacing-9))",
600
- height: "var(--data-table-selection-control-size, var(--spacing-9))",
601
- };
602
-
603
- const tableClass = clsx(
604
- s.table,
605
- showVerticalBorders && s.table_vertical_borders,
606
- );
607
-
608
- return (
609
- <div className={clsx(s.root, className)}>
610
- {/* Filters - inline row on desktop; a "Filters" button + modal once the
611
- row no longer fits (so phones aren't buried under stacked fields). */}
612
- {filters.length > 0 &&
613
- (filtersCompact ? (
614
- <FilterModal
615
- fields={filters}
616
- values={filterValues}
617
- onChange={onFilterChange}
618
- />
619
- ) : (
620
- <FilterBar
621
- fields={filters}
622
- values={filterValues}
623
- onChange={onFilterChange}
624
- />
625
- ))}
626
-
627
- {/* Content wrap - grows to fill remaining height.
628
- table_shell fills it; pagination pins to the bottom via margin-top:auto */}
629
- <div className={s.content_wrap}>
630
- {/* Table: detached header pill + scrollable body */}
631
- <div
632
- className={clsx(
633
- s.table_shell,
634
- !showPagination && s.table_shell_no_pagination,
635
- )}
636
- >
637
- {/* Pinned header pill - scroll synced to body via JS */}
638
- <div className={s.table_header_container}>
639
- <div className={s.table_header_wrap} ref={headerRef}>
640
- <table className={tableClass} style={tableSizingStyle}>
641
- <thead>
642
- {headerGroups.map((hg) => (
643
- <tr key={hg.id}>
644
- {hg.headers.map((header) => (
645
- <HeaderCell<TData>
646
- key={header.id}
647
- header={header}
648
- extraWidth={columnExtraWidths[header.column.id] ?? 0}
649
- enableColumnOrdering={enableColumnOrdering}
650
- enableColumnResizing={enableColumnResizing}
651
- enableColumnPinning={enableColumnPinning}
652
- enableGrouping={enableGrouping}
653
- draggedColumnId={draggedColumnId}
654
- dropTargetColumnId={dropTargetColumnId}
655
- onDragStartCol={setDraggedColumnId}
656
- onDragEndCol={handleDragEnd}
657
- onDragOverCol={setDropTargetColumnId}
658
- onDropCol={handleColumnDrop}
659
- />
660
- ))}
661
- </tr>
662
- ))}
663
- </thead>
664
- </table>
665
- </div>
666
- <div className={s.header_padding_mask} />
667
- </div>
668
-
669
- {/* Scrollable body - only this element scrolls. The body is a
670
- single switch over `view`: at most one branch ever renders, the
671
- other two are unmounted entirely. */}
672
- <div
673
- className={s.table_body_wrap}
674
- style={{ pointerEvents: isLoading ? "none" : undefined }}
675
- ref={bodyRef}
676
- >
677
- {view === "loader" && (
678
- <table className={tableClass} style={tableSizingStyle}>
679
- <tbody>
680
- {Array.from({ length: skeletonRowCount }).map(
681
- (_, rowIndex) => (
682
- <tr
683
- key={`skeleton-${rowIndex}`}
684
- ref={rowIndex === 0 ? skeletonRowRef : undefined}
685
- >
686
- {visibleLeafColumns.map((column) => {
687
- const isPinned = column.getIsPinned();
688
- const isLastPinnedCol =
689
- isPinned && isLastPinned(column);
690
- const isSelectColumn = column.id === SELECT_COLUMN_ID;
691
-
692
- return (
693
- <td
694
- key={column.id}
695
- className={clsx(
696
- isSelectColumn && s.col_select,
697
- isPinned === "left" && s.col_pinned_left,
698
- isPinned === "right" && s.col_pinned_right,
699
- isLastPinnedCol && s.col_pinned_last,
700
- )}
701
- style={getColumnStyle(
702
- column,
703
- column.getSize(),
704
- columnExtraWidths[column.id] ?? 0,
705
- )}
706
- >
707
- <div className={s.td_inner}>
708
- <Loader
709
- variant="skeleton"
710
- className={
711
- isSelectColumn
712
- ? s.loading_select_skeleton
713
- : undefined
714
- }
715
- style={
716
- isSelectColumn
717
- ? selectSkeletonStyle
718
- : bodySkeletonStyle
719
- }
720
- />
721
- </div>
722
- </td>
723
- );
724
- })}
725
- </tr>
726
- ),
727
- )}
728
- </tbody>
729
- </table>
730
- )}
731
- {view === "empty" && (
732
- <div className={s.state_overlay}>
733
- <p className={s.empty_msg}>{emptyMessage}</p>
734
- </div>
735
- )}
736
- {view === "content" && (
737
- <table
738
- className={tableClass}
739
- style={tableSizingStyle}
740
- ref={bodyTableRef}
741
- >
742
- <tbody>
743
- {rows.map((row) => (
744
- <BodyRow<TData>
745
- key={row.id}
746
- row={row}
747
- editingRowId={editingRowId}
748
- onRow={onRow}
749
- enableGrouping={enableGrouping}
750
- columnExtraWidths={columnExtraWidths}
751
- enableRowOrdering={enableRowOrdering}
752
- draggedRowId={draggedRowId}
753
- dropTargetRowId={dropTargetRowId}
754
- onDragStartRow={handleDragStartRow}
755
- onDragOverRow={handleDragOverRow}
756
- onDragEndRow={handleDragEndRow}
757
- onDropRow={handleDropRow}
758
- />
759
- ))}
760
- </tbody>
761
- </table>
762
- )}
763
- </div>
764
- </div>
765
-
766
- {/* Pagination - margin-top:auto pins it to the bottom of content_wrap */}
767
- {showPagination && (
768
- <PaginationBar
769
- table={table}
770
- pagination={pagination}
771
- onPaginationChange={onPaginationChange}
772
- pageSizeOptions={pageSizeOptions}
773
- showPageSizeSelect={showPageSizeSelect}
774
- isLoading={view === "loader"}
775
- totalCount={totalCount}
776
- rowLabel={rowLabel}
777
- rowsSelectable={rowsSelectable}
778
- selectedCount={selectedCount}
779
- extra={paginationExtra}
780
- />
781
- )}
782
- </div>
783
- </div>
784
- );
785
- };
786
-
787
- // ─────────────────────────────────────────────────────────────────────────────
788
- // HeaderActionButton - local compact icon button for the table header.
789
- // Keeps the header tight (no library Button padding) without depending on the
790
- // Button component while still mirroring its hover/focus visuals.
791
- // ─────────────────────────────────────────────────────────────────────────────
792
-
793
- const HeaderActionButton = ({
794
- onClick,
795
- ariaLabel,
796
- ariaPressed,
797
- active = false,
798
- children,
799
- }: HeaderActionButtonProps) => {
800
- return (
801
- <Button
802
- type="button"
803
- variant="ghost"
804
- size="sm"
805
- layout="box"
806
- className={s.icon_btn}
807
- onClick={onClick}
808
- aria-label={ariaLabel}
809
- aria-pressed={ariaPressed}
810
- draggable={false}
811
- isActive={active}
812
- >
813
- {children}
814
- </Button>
815
- );
816
- };
817
-
818
- // ─────────────────────────────────────────────────────────────────────────────
819
- // HeaderCell - one <th> with sort, group, pin, drag, and resize affordances.
820
- // ─────────────────────────────────────────────────────────────────────────────
821
-
822
- const HeaderCell = <TData,>({
823
- header,
824
- extraWidth,
825
- enableColumnOrdering,
826
- enableColumnResizing,
827
- enableColumnPinning,
828
- enableGrouping,
829
- draggedColumnId,
830
- dropTargetColumnId,
831
- onDragStartCol,
832
- onDragEndCol,
833
- onDragOverCol,
834
- onDropCol,
835
- }: HeaderCellProps<TData>) => {
836
- const column = header.column;
837
- const colMeta = column.columnDef.meta;
838
- const align = colMeta?.align ?? "left";
839
- // Fixed columns (e.g. row-select) opt out of hide-ability AND of being a
840
- // drag source / drop target so they stay anchored in their position.
841
- const isFixed = column.getCanHide() === false;
842
- const canDrag = enableColumnOrdering && !isFixed;
843
- const canResize = enableColumnResizing && column.getCanResize();
844
- const canPin = enableColumnPinning && column.getCanPin() && !isFixed;
845
- const canGroup = enableGrouping && column.getCanGroup() && !isFixed;
846
- const canSort = column.getCanSort();
847
- const isPinned = column.getIsPinned();
848
- const isLastPinnedCol = isPinned && isLastPinned(column);
849
- const isBeingDragged = draggedColumnId === column.id;
850
- const isDropTarget =
851
- dropTargetColumnId === column.id &&
852
- draggedColumnId !== column.id &&
853
- !isFixed;
854
- const isGrouped = enableGrouping && column.getIsGrouped();
855
- const isSelectColumn = column.id === SELECT_COLUMN_ID;
856
- const sortState = column.getIsSorted();
857
- const hasHeaderActions = canSort || canGroup || canPin;
858
- const sortTooltip =
859
- sortState === "asc"
860
- ? "Sort descending"
861
- : sortState === "desc"
862
- ? "Clear sorting"
863
- : "Sort ascending";
864
-
865
- const handleDragStart = (e: ReactDragEvent<HTMLTableCellElement>) => {
866
- e.dataTransfer.effectAllowed = "move";
867
- e.dataTransfer.setData("text/plain", column.id);
868
- onDragStartCol(column.id);
869
- };
870
-
871
- const handleDragOver = (e: ReactDragEvent<HTMLTableCellElement>) => {
872
- if (!draggedColumnId || draggedColumnId === column.id || isFixed) return;
873
- e.preventDefault();
874
- e.dataTransfer.dropEffect = "move";
875
- if (dropTargetColumnId !== column.id) onDragOverCol(column.id);
876
- };
877
-
878
- const handleDragLeave = () => {
879
- if (dropTargetColumnId === column.id) onDragOverCol(null);
880
- };
881
-
882
- const handleDrop = (e: ReactDragEvent<HTMLTableCellElement>) => {
883
- e.preventDefault();
884
- if (!draggedColumnId || isFixed) return;
885
- onDropCol(draggedColumnId, column.id);
886
- onDragEndCol();
887
- };
888
-
889
- const sortIcon = !sortState ? (
890
- <IconArrowsUpDown size={SORT_ICON_SIZE} stroke={ICON_STROKE} />
891
- ) : sortState === "asc" ? (
892
- <IconArrowUp size={SORT_ICON_SIZE} stroke={ICON_STROKE} />
893
- ) : (
894
- <IconArrowDown size={SORT_ICON_SIZE} stroke={ICON_STROKE} />
895
- );
896
-
897
- return (
898
- <th
899
- colSpan={header.colSpan}
900
- onDragOver={canDrag ? handleDragOver : undefined}
901
- onDragLeave={canDrag ? handleDragLeave : undefined}
902
- onDrop={canDrag ? handleDrop : undefined}
903
- className={clsx(
904
- colMeta?.headerClassName,
905
- s[`align_${align}`],
906
- isSelectColumn && s.col_select,
907
- isPinned === "left" && s.col_pinned_left,
908
- isPinned === "right" && s.col_pinned_right,
909
- isLastPinnedCol && s.col_pinned_last,
910
- isBeingDragged && s.col_dragging,
911
- isDropTarget && s.col_drop_target,
912
- )}
913
- style={getColumnStyle(column, header.getSize(), extraWidth)}
914
- >
915
- {!header.isPlaceholder && (
916
- <div className={s.th_inner}>
917
- {canDrag && (
918
- <span
919
- className={s.drag_handle}
920
- aria-hidden
921
- draggable
922
- onDragStart={handleDragStart}
923
- onDragEnd={onDragEndCol}
924
- >
925
- <IconGripVertical size={ICON_SIZE} stroke={ICON_STROKE} />
926
- </span>
927
- )}
928
- {isSelectColumn ? (
929
- flexRender(column.columnDef.header, header.getContext())
930
- ) : (
931
- <span className={s.th_label}>
932
- {flexRender(column.columnDef.header, header.getContext())}
933
- </span>
934
- )}
935
- {hasHeaderActions && (
936
- <span className={s.th_actions}>
937
- {canSort && (
938
- <Tooltip content={sortTooltip}>
939
- <HeaderActionButton
940
- onClick={column.getToggleSortingHandler()}
941
- ariaLabel={`Sort by ${column.id}`}
942
- ariaPressed={!!sortState}
943
- active={!!sortState}
944
- >
945
- {sortIcon}
946
- </HeaderActionButton>
947
- </Tooltip>
948
- )}
949
- {canGroup && (
950
- <Tooltip
951
- content={isGrouped ? "Ungroup column" : "Group by column"}
952
- >
953
- <HeaderActionButton
954
- onClick={column.getToggleGroupingHandler()}
955
- ariaLabel={
956
- isGrouped ? "Ungroup column" : "Group by this column"
957
- }
958
- ariaPressed={isGrouped}
959
- active={isGrouped}
960
- >
961
- {isGrouped ? (
962
- <IconStack3 size={ICON_SIZE} stroke={ICON_STROKE} />
963
- ) : (
964
- <IconStack2 size={ICON_SIZE} stroke={ICON_STROKE} />
965
- )}
966
- </HeaderActionButton>
967
- </Tooltip>
968
- )}
969
- {canPin && !isPinned && (
970
- <>
971
- <Tooltip content="Pin left">
972
- <HeaderActionButton
973
- onClick={() => column.pin("left")}
974
- ariaLabel="Pin left"
975
- >
976
- <IconArrowLeft size={ICON_SIZE} stroke={ICON_STROKE} />
977
- </HeaderActionButton>
978
- </Tooltip>
979
- <Tooltip content="Pin right">
980
- <HeaderActionButton
981
- onClick={() => column.pin("right")}
982
- ariaLabel="Pin right"
983
- >
984
- <IconArrowRight size={ICON_SIZE} stroke={ICON_STROKE} />
985
- </HeaderActionButton>
986
- </Tooltip>
987
- </>
988
- )}
989
- {canPin && isPinned && (
990
- <Tooltip content="Unpin column">
991
- <HeaderActionButton
992
- onClick={() => column.pin(false)}
993
- ariaLabel="Unpin column"
994
- ariaPressed
995
- active
996
- >
997
- <IconPinnedOff size={ICON_SIZE} stroke={ICON_STROKE} />
998
- </HeaderActionButton>
999
- </Tooltip>
1000
- )}
1001
- </span>
1002
- )}
1003
- {canResize && (
1004
- <span
1005
- role="separator"
1006
- aria-orientation="vertical"
1007
- className={clsx(
1008
- s.resize_handle,
1009
- column.getIsResizing() && s.resize_handle_active,
1010
- )}
1011
- onMouseDown={header.getResizeHandler()}
1012
- onTouchStart={header.getResizeHandler()}
1013
- onClick={(e) => e.stopPropagation()}
1014
- onDoubleClick={() => column.resetSize()}
1015
- draggable={false}
1016
- />
1017
- )}
1018
- </div>
1019
- )}
1020
- </th>
1021
- );
1022
- };
1023
-
1024
- // ─────────────────────────────────────────────────────────────────────────────
1025
- // BodyRow - one <tr> + its visible cells.
1026
- // ─────────────────────────────────────────────────────────────────────────────
1027
-
1028
- const BodyRow = <TData extends { id: string }>({
1029
- row,
1030
- editingRowId,
1031
- onRow,
1032
- enableGrouping,
1033
- columnExtraWidths,
1034
- enableRowOrdering,
1035
- draggedRowId,
1036
- dropTargetRowId,
1037
- onDragStartRow,
1038
- onDragOverRow,
1039
- onDragEndRow,
1040
- onDropRow,
1041
- }: BodyRowProps<TData>) => {
1042
- const isGroup = enableGrouping && row.getIsGrouped();
1043
- const visibleCells = row.getVisibleCells();
1044
- const groupSourceCell = isGroup
1045
- ? visibleCells.find((cell) => cell.getIsGrouped())
1046
- : undefined;
1047
- const groupAnchorCellId = isGroup
1048
- ? (visibleCells.find((cell) => cell.column.id !== SELECT_COLUMN_ID)?.id ??
1049
- groupSourceCell?.id ??
1050
- null)
1051
- : null;
1052
- const isEditingThisRow =
1053
- !isGroup && editingRowId !== null && row.original.id === editingRowId;
1054
-
1055
- // Editing rows host inputs/selects; their clicks must not bubble to row click.
1056
- const handleRowClick =
1057
- !isGroup && !isEditingThisRow && onRow
1058
- ? () => onRow(row.original)
1059
- : undefined;
1060
-
1061
- const canDrag = enableRowOrdering && !isGroup && !isEditingThisRow;
1062
- const isBeingDragged = draggedRowId === row.id;
1063
- const isDropTarget =
1064
- dropTargetRowId === row.id && draggedRowId !== row.id && !isGroup;
1065
-
1066
- return (
1067
- <tr
1068
- draggable={canDrag}
1069
- onDragStart={(e) => {
1070
- if (!canDrag) return;
1071
- e.dataTransfer.effectAllowed = "move";
1072
- e.dataTransfer.setData("text/plain", row.id);
1073
- onDragStartRow?.(row.id);
1074
- }}
1075
- onDragOver={(e) => {
1076
- if (!canDrag || !draggedRowId || draggedRowId === row.id) return;
1077
- e.preventDefault();
1078
- e.dataTransfer.dropEffect = "move";
1079
- if (dropTargetRowId !== row.id) onDragOverRow?.(row.id);
1080
- }}
1081
- onDragLeave={() => {
1082
- if (!canDrag) return;
1083
- if (dropTargetRowId === row.id) onDragOverRow?.(null);
1084
- }}
1085
- onDragEnd={() => {
1086
- if (!canDrag) return;
1087
- onDragEndRow?.();
1088
- }}
1089
- onDrop={(e) => {
1090
- if (!canDrag) return;
1091
- e.preventDefault();
1092
- if (!draggedRowId || draggedRowId === row.id) return;
1093
- onDropRow?.(draggedRowId, row.id);
1094
- onDragEndRow?.();
1095
- }}
1096
- className={clsx(
1097
- row.getIsSelected() && s.row_selected,
1098
- isEditingThisRow && s.row_editing,
1099
- isGroup && s.row_group,
1100
- isBeingDragged && s.row_dragging,
1101
- isDropTarget && s.row_drop_target,
1102
- )}
1103
- onClick={handleRowClick}
1104
- style={handleRowClick ? POINTER_STYLE : undefined}
1105
- >
1106
- {visibleCells.map((cell) => (
1107
- <BodyCell<TData>
1108
- key={cell.id}
1109
- cell={cell}
1110
- extraWidth={columnExtraWidths[cell.column.id] ?? 0}
1111
- groupSourceCell={groupSourceCell}
1112
- isGroupAnchorCell={groupAnchorCellId === cell.id}
1113
- />
1114
- ))}
1115
- </tr>
1116
- );
1117
- };
1118
-
1119
- // ─────────────────────────────────────────────────────────────────────────────
1120
- // BodyCell - one <td>. Routes to the right body for grouped/aggregated/normal
1121
- // cells and applies meta-driven align + truncate + pin classes.
1122
- // ─────────────────────────────────────────────────────────────────────────────
1123
-
1124
- const BodyCell = <TData,>({
1125
- cell,
1126
- extraWidth,
1127
- groupSourceCell,
1128
- isGroupAnchorCell,
1129
- }: BodyCellProps<TData>) => {
1130
- const column: Column<TData, unknown> = cell.column;
1131
- const colMeta = column.columnDef.meta;
1132
- const align = colMeta?.align ?? "left";
1133
- const truncate = colMeta?.truncate ?? true;
1134
- const isPinned = column.getIsPinned();
1135
- const isLastPinnedCol = isPinned && isLastPinned(column);
1136
- const isSelectColumn = column.id === SELECT_COLUMN_ID;
1137
- const groupedRenderCell =
1138
- groupSourceCell ?? (cell.getIsGrouped() ? cell : undefined);
1139
- const groupDepth = groupedRenderCell?.row.depth ?? 0;
1140
-
1141
- let body: ReactNode;
1142
- if (groupedRenderCell && isGroupAnchorCell) {
1143
- const groupedColumn = groupedRenderCell.column;
1144
- body = (
1145
- <button
1146
- type="button"
1147
- className={s.group_toggle}
1148
- onClick={groupedRenderCell.row.getToggleExpandedHandler()}
1149
- aria-expanded={groupedRenderCell.row.getIsExpanded()}
1150
- aria-label={
1151
- groupedRenderCell.row.getIsExpanded()
1152
- ? "Collapse group"
1153
- : "Expand group"
1154
- }
1155
- style={
1156
- groupDepth > 0
1157
- ? { paddingInlineStart: `calc(${groupDepth} * var(--spacing-4))` }
1158
- : undefined
1159
- }
1160
- >
1161
- <IconChevronRight
1162
- size={ICON_SIZE}
1163
- stroke={ICON_STROKE}
1164
- className={clsx(
1165
- s.group_chevron,
1166
- groupedRenderCell.row.getIsExpanded() && s.group_chevron_open,
1167
- )}
1168
- />
1169
- <span className={s.group_value}>
1170
- {flexRender(
1171
- groupedColumn.columnDef.cell,
1172
- groupedRenderCell.getContext(),
1173
- )}
1174
- </span>
1175
- <span className={s.group_count}>
1176
- ({groupedRenderCell.row.subRows.length})
1177
- </span>
1178
- </button>
1179
- );
1180
- } else if (groupedRenderCell && groupedRenderCell.id === cell.id) {
1181
- body = null;
1182
- } else if (cell.getIsAggregated()) {
1183
- body = column.columnDef.aggregatedCell ? (
1184
- flexRender(column.columnDef.aggregatedCell, cell.getContext())
1185
- ) : column.columnDef.aggregationFn ? (
1186
- <span className={s.cell_aggregated}>
1187
- {formatAggregated(cell.getValue())}
1188
- </span>
1189
- ) : null;
1190
- } else if (cell.getIsPlaceholder()) {
1191
- body = null;
1192
- } else {
1193
- body = flexRender(column.columnDef.cell, cell.getContext());
1194
- }
1195
-
1196
- // Group rows are full-width summary rows; sticky pinning makes no sense
1197
- // on them. We strip BOTH the pinned CSS classes AND the left/right inline
1198
- // offsets. Without this, td:focus-within (which sets position:relative)
1199
- // would combine with left:Xpx from getColumnStyle and physically shift
1200
- // the cell when the user clicks the group-toggle chevron.
1201
- const isGroupRow = !!groupedRenderCell;
1202
- const baseStyle = getColumnStyle(column, column.getSize(), extraWidth);
1203
- const cellStyle: CSSProperties = isGroupRow
1204
- ? {
1205
- width: baseStyle.width,
1206
- minWidth: baseStyle.minWidth,
1207
- maxWidth: baseStyle.maxWidth,
1208
- }
1209
- : baseStyle;
1210
-
1211
- return (
1212
- <td
1213
- className={clsx(
1214
- colMeta?.cellClassName,
1215
- s[`align_${align}`],
1216
- isGroupAnchorCell && s.group_anchor_cell,
1217
- !isGroupRow && isSelectColumn && s.col_select,
1218
- !isGroupRow && isPinned === "left" && s.col_pinned_left,
1219
- !isGroupRow && isPinned === "right" && s.col_pinned_right,
1220
- !isGroupRow && isLastPinnedCol && s.col_pinned_last,
1221
- )}
1222
- style={cellStyle}
1223
- >
1224
- <div className={clsx(s.td_inner, !truncate && s.td_inner_no_truncate)}>
1225
- {body}
1226
- </div>
1227
- </td>
1228
- );
1229
- };
1
+ import {
2
+ flexRender,
3
+ getCoreRowModel,
4
+ getExpandedRowModel,
5
+ getGroupedRowModel,
6
+ getPaginationRowModel,
7
+ getSortedRowModel,
8
+ useReactTable,
9
+ type Column,
10
+ type ColumnPinningState,
11
+ type PaginationState as TanstackPaginationState,
12
+ type Updater,
13
+ } from "@tanstack/react-table";
14
+ import {
15
+ useCallback,
16
+ useEffect,
17
+ useLayoutEffect,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ type CSSProperties,
22
+ type DragEvent as ReactDragEvent,
23
+ type MouseEvent as ReactMouseEvent,
24
+ type ReactNode,
25
+ } from "react";
26
+ import clsx from "clsx";
27
+ import { Filters } from "./filters";
28
+ import { PaginationBar } from "./pagination-bar";
29
+ import { Loader } from "../loader/loader";
30
+ import type {
31
+ BodyCellProps,
32
+ BodyRowProps,
33
+ DataTableMeta,
34
+ DataTableProps,
35
+ HeaderActionButtonProps,
36
+ HeaderCellProps,
37
+ } from "./types";
38
+ import {
39
+ columnId,
40
+ formatAggregated,
41
+ getColumnStyle,
42
+ isLastPinned,
43
+ normalizeColumnPinningState,
44
+ reorderColumnOrder,
45
+ resolveUpdater,
46
+ SELECT_COLUMN_ID,
47
+ } from "./utils";
48
+ import s from "./data-table.module.css";
49
+ import { Tooltip } from "../tooltip/tooltip";
50
+ import {
51
+ IconArrowsUpDown,
52
+ IconArrowUp,
53
+ IconArrowDown,
54
+ IconChevronRight,
55
+ IconGripVertical,
56
+ IconStack2,
57
+ IconStack3,
58
+ IconPinnedOff,
59
+ IconArrowLeft,
60
+ IconArrowRight,
61
+ } from "@tabler/icons-react";
62
+ import { getScrollbarWidth } from "../../../lib/dom";
63
+ import { Button } from "../button/button";
64
+
65
+ const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
66
+ const DEFAULT_PAGINATION = { pageIndex: 0, pageSize: 20 };
67
+ // Stable refs for optional handlers. Inline `= () => {}` defaults allocate a
68
+ // fresh function each render, invalidating any memo that depends on them
69
+ // (notably `meta` below, which passes `onUpdateData` through tanstack).
70
+ const NOOP_FILTER = () => {};
71
+ const NOOP_PAGINATION = () => {};
72
+ const NOOP_UPDATE = () => {};
73
+ const POINTER_STYLE = { cursor: "pointer" } as const;
74
+ // Controls a row can hold. A click on one is that control's, not the row's.
75
+ const ROW_CONTROLS =
76
+ 'a, button, input, select, textarea, label, summary, [contenteditable]:not([contenteditable="false"]), [role="button"], [role="link"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [role="option"], [role="tab"]';
77
+ const EMPTY_FILTER_VALUES: Record<string, string> = {};
78
+ const ICON_SIZE = 15;
79
+ const SORT_ICON_SIZE = 13;
80
+ const ICON_STROKE = 1.6;
81
+ const EMPTY_COLUMN_EXTRA_WIDTHS: Record<string, number> = {};
82
+ // Grace period after `isLoading` flips false before committing to the empty
83
+ // state. Eliminates the flash when consumers set isLoading=false a render
84
+ // before populating rows, without making genuinely-empty results feel sluggish.
85
+ const EMPTY_STATE_GRACE_MS = 150;
86
+
87
+ const MIN_SKELETON_ROWS = 1;
88
+ const useIsomorphicLayoutEffect =
89
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
90
+
91
+ export const DataTable = <TData extends { id: string }>({
92
+ data,
93
+ columns,
94
+ isLoading = false,
95
+ emptyMessage = "No results found.",
96
+
97
+ filters = [],
98
+ filterValues = EMPTY_FILTER_VALUES,
99
+ onFilterChange = NOOP_FILTER,
100
+
101
+ sorting,
102
+ onSortingChange,
103
+ manualSorting = false,
104
+
105
+ showPagination = true,
106
+ pagination = DEFAULT_PAGINATION,
107
+ onPaginationChange = NOOP_PAGINATION,
108
+ totalPages,
109
+ totalCount,
110
+ pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
111
+ showPageSizeSelect = true,
112
+ rowLabel = "row",
113
+
114
+ rowsSelectable = false,
115
+ getRowCanSelect,
116
+ rowSelection = {},
117
+ onRowSelectionChange,
118
+
119
+ editingRowId = null,
120
+ onUpdateData = NOOP_UPDATE,
121
+
122
+ enableColumnOrdering = false,
123
+ columnOrder,
124
+ onColumnOrderChange,
125
+ enableColumnResizing = false,
126
+ columnSizing,
127
+ onColumnSizingChange,
128
+ enableColumnPinning = false,
129
+ columnPinning,
130
+ onColumnPinningChange,
131
+ enableRowOrdering = false,
132
+ onRowOrderChange,
133
+ columnVisibility,
134
+ onColumnVisibilityChange,
135
+
136
+ enableGrouping = false,
137
+ grouping,
138
+ onGroupingChange,
139
+ expanded,
140
+ onExpandedChange,
141
+
142
+ defaultColumnPinning,
143
+
144
+ showVerticalBorders = false,
145
+
146
+ onRow,
147
+ paginationExtra,
148
+ paginationLabel,
149
+ className,
150
+ }: DataTableProps<TData>) => {
151
+ // Collapse the filter row into a button + modal once it no longer fits inline.
152
+
153
+ // ── Table meta ──────────────────────────────────────────────────────────────
154
+ const meta: DataTableMeta<TData> = useMemo(
155
+ () => ({ editingRowId, updateData: onUpdateData }),
156
+ [editingRowId, onUpdateData],
157
+ );
158
+
159
+ const shouldPinSelectionColumn =
160
+ rowsSelectable &&
161
+ columns.some((columnDef) => columnId(columnDef) === SELECT_COLUMN_ID);
162
+
163
+ const resolvedControlledColumnPinning = useMemo(
164
+ () =>
165
+ columnPinning === undefined
166
+ ? undefined
167
+ : normalizeColumnPinningState(
168
+ columnPinning,
169
+ shouldPinSelectionColumn,
170
+ defaultColumnPinning,
171
+ ),
172
+ [columnPinning, shouldPinSelectionColumn, defaultColumnPinning],
173
+ );
174
+
175
+ const initialColumnPinning = useMemo(
176
+ () =>
177
+ enableColumnPinning && columnPinning === undefined
178
+ ? normalizeColumnPinningState(
179
+ undefined,
180
+ shouldPinSelectionColumn,
181
+ defaultColumnPinning,
182
+ )
183
+ : undefined,
184
+ [
185
+ enableColumnPinning,
186
+ columnPinning,
187
+ shouldPinSelectionColumn,
188
+ defaultColumnPinning,
189
+ ],
190
+ );
191
+
192
+ const handleColumnPinningChange = useCallback(
193
+ (updater: Updater<ColumnPinningState>) => {
194
+ if (!onColumnPinningChange) return;
195
+
196
+ onColumnPinningChange((prev) => {
197
+ const normalizedPrev =
198
+ normalizeColumnPinningState(
199
+ prev,
200
+ shouldPinSelectionColumn,
201
+ defaultColumnPinning,
202
+ ) ?? {};
203
+ const next = resolveUpdater(updater, normalizedPrev);
204
+
205
+ // Newly right-pinned columns are inserted on the inner edge (closer
206
+ // to the center) so the recently-pinned column visually slides in
207
+ // next to the body, leaving previously-pinned columns flush against
208
+ // the viewport edge.
209
+ const prevRight = normalizedPrev.right ?? [];
210
+ const nextRight = next.right ?? [];
211
+ const addedToRight = nextRight.filter((id) => !prevRight.includes(id));
212
+ const keptInRight = nextRight.filter((id) => prevRight.includes(id));
213
+
214
+ return (
215
+ normalizeColumnPinningState(
216
+ { left: next.left, right: [...addedToRight, ...keptInRight] },
217
+ shouldPinSelectionColumn,
218
+ defaultColumnPinning,
219
+ ) ?? {}
220
+ );
221
+ });
222
+ },
223
+ [onColumnPinningChange, shouldPinSelectionColumn, defaultColumnPinning],
224
+ );
225
+
226
+ // ── TanStack table instance ─────────────────────────────────────────────────
227
+ // Filtering is delegated to the consumer (see `useDataTable.filterFn`), so we
228
+ // don't wire any tanstack filter row-model here - rows arrive pre-filtered.
229
+ // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table relies on function-returning APIs by design.
230
+ const table = useReactTable<TData>({
231
+ data,
232
+ columns,
233
+ enableRowSelection: rowsSelectable ? (getRowCanSelect ?? true) : false,
234
+ enableColumnResizing,
235
+ enableColumnPinning,
236
+ enableGrouping,
237
+ columnResizeMode: "onChange",
238
+ state: {
239
+ sorting,
240
+ ...(showPagination && {
241
+ pagination: pagination as TanstackPaginationState,
242
+ }),
243
+ ...(rowsSelectable && { rowSelection }),
244
+ ...(enableColumnOrdering && columnOrder && { columnOrder }),
245
+ ...(enableColumnResizing && columnSizing && { columnSizing }),
246
+ ...(enableColumnPinning &&
247
+ resolvedControlledColumnPinning && {
248
+ columnPinning: resolvedControlledColumnPinning,
249
+ }),
250
+ ...(columnVisibility && { columnVisibility }),
251
+ ...(enableGrouping && grouping && { grouping }),
252
+ ...(enableGrouping && expanded !== undefined && { expanded }),
253
+ },
254
+ initialState: {
255
+ ...(enableColumnPinning &&
256
+ initialColumnPinning && {
257
+ columnPinning: initialColumnPinning,
258
+ }),
259
+ },
260
+ onSortingChange,
261
+ ...(showPagination && {
262
+ onPaginationChange: (updater) => {
263
+ const cur = pagination as TanstackPaginationState;
264
+ const next = typeof updater === "function" ? updater(cur) : updater;
265
+ onPaginationChange(next);
266
+ },
267
+ }),
268
+ ...(rowsSelectable && onRowSelectionChange && { onRowSelectionChange }),
269
+ ...(enableColumnOrdering && onColumnOrderChange && { onColumnOrderChange }),
270
+ ...(enableColumnResizing &&
271
+ onColumnSizingChange && { onColumnSizingChange }),
272
+ ...(enableColumnPinning &&
273
+ onColumnPinningChange && {
274
+ onColumnPinningChange: handleColumnPinningChange,
275
+ }),
276
+ ...(onColumnVisibilityChange && { onColumnVisibilityChange }),
277
+ ...(enableGrouping && onGroupingChange && { onGroupingChange }),
278
+ ...(enableGrouping && onExpandedChange && { onExpandedChange }),
279
+ getRowId: (row) => row.id,
280
+ getCoreRowModel: getCoreRowModel(),
281
+ ...(!manualSorting && { getSortedRowModel: getSortedRowModel() }),
282
+ ...(showPagination && {
283
+ getPaginationRowModel: getPaginationRowModel(),
284
+ pageCount: totalPages ?? -1,
285
+ }),
286
+ ...(enableGrouping && {
287
+ // Keep grouped columns in place so header controls stay spatially stable.
288
+ groupedColumnMode: false,
289
+ getGroupedRowModel: getGroupedRowModel(),
290
+ getExpandedRowModel: getExpandedRowModel(),
291
+ }),
292
+ manualSorting,
293
+ manualPagination: showPagination,
294
+ meta,
295
+ });
296
+
297
+ // ── Column drag-and-drop reorder (HTML5 native, no extra deps) ──────────────
298
+ const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
299
+ const [dropTargetColumnId, setDropTargetColumnId] = useState<string | null>(
300
+ null,
301
+ );
302
+
303
+ const handleColumnDrop = useCallback(
304
+ (sourceId: string, targetId: string) => {
305
+ if (sourceId === targetId) return;
306
+ const targetCol = table.getColumn(targetId);
307
+ // Refuse drops onto fixed columns (e.g. the row-select column) so they
308
+ // stay anchored in their position.
309
+ if (targetCol && targetCol.getCanHide() === false) return;
310
+ const current =
311
+ columnOrder && columnOrder.length
312
+ ? columnOrder
313
+ : table.getAllLeafColumns().map((c) => c.id);
314
+ const next = reorderColumnOrder(current, sourceId, targetId);
315
+ table.setColumnOrder(next);
316
+ },
317
+ [columnOrder, table],
318
+ );
319
+
320
+ const handleDragEnd = useCallback(() => {
321
+ setDraggedColumnId(null);
322
+ setDropTargetColumnId(null);
323
+ }, []);
324
+
325
+ const [draggedRowId, setDraggedRowId] = useState<string | null>(null);
326
+ const [dropTargetRowId, setDropTargetRowId] = useState<string | null>(null);
327
+
328
+ const handleDragStartRow = useCallback((id: string) => {
329
+ setDraggedRowId(id);
330
+ }, []);
331
+
332
+ const handleDragOverRow = useCallback((id: string | null) => {
333
+ setDropTargetRowId(id);
334
+ }, []);
335
+
336
+ const handleDragEndRow = useCallback(() => {
337
+ setDraggedRowId(null);
338
+ setDropTargetRowId(null);
339
+ }, []);
340
+
341
+ const handleDropRow = useCallback(
342
+ (draggedId: string, targetId: string) => {
343
+ const draggedRow = table.getRow(draggedId);
344
+ const targetRow = table.getRow(targetId);
345
+ if (draggedRow && targetRow) {
346
+ onRowOrderChange?.(draggedRow.original, targetRow.original);
347
+ }
348
+ },
349
+ [onRowOrderChange, table],
350
+ );
351
+
352
+ // ── Sync header/body scrolling ──────────────────────────────────────────────
353
+ // The header lives in its own `overflow:hidden` container; we drive its
354
+ // scrollLeft from the body's scrollLeft on every scroll event so pinned
355
+ // columns stay perfectly aligned with their body counterparts.
356
+ const headerRef = useRef<HTMLDivElement>(null);
357
+ const bodyRef = useRef<HTMLDivElement>(null);
358
+ const bodyTableRef = useRef<HTMLTableElement>(null);
359
+ const skeletonRowRef = useRef<HTMLTableRowElement>(null);
360
+ const rows = table.getRowModel().rows;
361
+ const headerCount = table.getFlatHeaders().length;
362
+ const totalWidth = table.getTotalSize();
363
+ const tableState = table.getState();
364
+ const tableColumnPinning = tableState.columnPinning;
365
+ const tableColumnSizing = tableState.columnSizing;
366
+ const tableColumnOrder = tableState.columnOrder;
367
+ const tableColumnVisibility = tableState.columnVisibility;
368
+ const tableGrouping = tableState.grouping;
369
+ const [bodyViewportWidth, setBodyViewportWidth] = useState(0);
370
+ const bodyViewportWidthRef = useRef(0);
371
+ // Body wrap height tracked separately so the loading skeleton can render
372
+ // exactly the count of rows that fits the visible body. Avoids the
373
+ // "skeleton overflows + body wrap scrolls" feel during cold load.
374
+ const [bodyViewportHeight, setBodyViewportHeight] = useState(0);
375
+ const bodyViewportHeightRef = useRef(0);
376
+ const [skeletonRowHeight, setSkeletonRowHeight] = useState(0);
377
+ const skeletonRowHeightRef = useRef(0);
378
+
379
+ const commitBodyViewportWidth = useCallback((nextWidth: number) => {
380
+ if (bodyViewportWidthRef.current === nextWidth) return;
381
+ bodyViewportWidthRef.current = nextWidth;
382
+ setBodyViewportWidth(nextWidth);
383
+ }, []);
384
+
385
+ const commitBodyViewportHeight = useCallback((nextHeight: number) => {
386
+ if (bodyViewportHeightRef.current === nextHeight) return;
387
+ bodyViewportHeightRef.current = nextHeight;
388
+ setBodyViewportHeight(nextHeight);
389
+ }, []);
390
+
391
+ const commitSkeletonRowHeight = useCallback((nextHeight: number) => {
392
+ if (nextHeight <= 0 || skeletonRowHeightRef.current === nextHeight) return;
393
+ skeletonRowHeightRef.current = nextHeight;
394
+ setSkeletonRowHeight(nextHeight);
395
+ }, []);
396
+
397
+ const syncHeaderMetrics = useCallback(() => {
398
+ const body = bodyRef.current;
399
+ const header = headerRef.current;
400
+ if (!body || !header) return;
401
+
402
+ const scrollbarW = getScrollbarWidth();
403
+ const hasScroll = body.scrollHeight > body.clientHeight;
404
+ const paddingRight = hasScroll ? `${scrollbarW}px` : "0px";
405
+
406
+ header.style.paddingRight = paddingRight;
407
+ header.parentElement?.style.setProperty(
408
+ "--header-padding-right",
409
+ paddingRight,
410
+ );
411
+ header.parentElement?.setAttribute("data-has-scroll", hasScroll.toString());
412
+ // Mirror onto the body wrap so CSS can release the reserved scrollbar gutter
413
+ // when there's no vertical overflow (no blank strip on a few-row table).
414
+ body.setAttribute("data-has-scroll", hasScroll.toString());
415
+
416
+ // Re-sync horizontal scroll position after width changes so pinned
417
+ // columns stay perfectly aligned with their body counterparts.
418
+ header.scrollLeft = body.scrollLeft;
419
+ commitBodyViewportWidth(body.clientWidth);
420
+ commitBodyViewportHeight(body.clientHeight);
421
+ }, [commitBodyViewportWidth, commitBodyViewportHeight]);
422
+
423
+ useIsomorphicLayoutEffect(() => {
424
+ syncHeaderMetrics();
425
+ }, [
426
+ rows.length,
427
+ headerCount,
428
+ totalWidth,
429
+ tableColumnPinning,
430
+ tableColumnSizing,
431
+ tableColumnOrder,
432
+ tableColumnVisibility,
433
+ tableGrouping,
434
+ syncHeaderMetrics,
435
+ ]);
436
+
437
+ useIsomorphicLayoutEffect(() => {
438
+ const body = bodyRef.current;
439
+ if (!body) return;
440
+ if (typeof ResizeObserver === "undefined") return;
441
+
442
+ const observer = new ResizeObserver(syncHeaderMetrics);
443
+ observer.observe(body);
444
+ if (bodyTableRef.current) observer.observe(bodyTableRef.current);
445
+
446
+ return () => {
447
+ observer.disconnect();
448
+ };
449
+ }, [syncHeaderMetrics, rows.length, isLoading]);
450
+
451
+ useEffect(() => {
452
+ const body = bodyRef.current;
453
+ const header = headerRef.current;
454
+ if (!body || !header) return;
455
+
456
+ const syncFromBody = () => {
457
+ if (header.scrollLeft !== body.scrollLeft) {
458
+ header.scrollLeft = body.scrollLeft;
459
+ }
460
+ };
461
+
462
+ const syncFromHeader = () => {
463
+ if (body.scrollLeft !== header.scrollLeft) {
464
+ body.scrollLeft = header.scrollLeft;
465
+ }
466
+ };
467
+
468
+ body.addEventListener("scroll", syncFromBody, { passive: true });
469
+ header.addEventListener("scroll", syncFromHeader, { passive: true });
470
+
471
+ return () => {
472
+ body.removeEventListener("scroll", syncFromBody);
473
+ header.removeEventListener("scroll", syncFromHeader);
474
+ };
475
+ }, []);
476
+
477
+ // ── Derived UI state ────────────────────────────────────────────────────────
478
+ // Defer the empty state by a tick after a loading-to-idle transition so the
479
+ // loader doesn't flicker into "no results" for one paint before rows land.
480
+ // Genuinely empty results (no in-flight load) commit immediately on mount.
481
+ const isRowsEmpty = rows.length === 0;
482
+ const [showEmptyState, setShowEmptyState] = useState(
483
+ () => !isLoading && isRowsEmpty,
484
+ );
485
+ const wasLoadingRef = useRef(isLoading);
486
+
487
+ // wasLoadingRef is intentionally omitted from the dep array: it's a ref used
488
+ // to track the previous value of isLoading across renders without triggering
489
+ // re-runs. React guarantees ref objects are stable across the component lifetime.
490
+ useEffect(() => {
491
+ const wasLoading = wasLoadingRef.current;
492
+ wasLoadingRef.current = isLoading;
493
+
494
+ if (isLoading || !isRowsEmpty) {
495
+ setShowEmptyState(false);
496
+ return;
497
+ }
498
+ if (!wasLoading) {
499
+ setShowEmptyState(true);
500
+ return;
501
+ }
502
+ const id = window.setTimeout(
503
+ () => setShowEmptyState(true),
504
+ EMPTY_STATE_GRACE_MS,
505
+ );
506
+ return () => window.clearTimeout(id);
507
+ }, [isLoading, isRowsEmpty]);
508
+
509
+ // Single tab-like discriminator drives the body - loader, empty, or content.
510
+ // While loading OR while bridging the loading-to-empty grace window, the
511
+ // loader stays visible to avoid the brief "no results" flash.
512
+ const view: "loader" | "empty" | "content" =
513
+ isLoading || (isRowsEmpty && !showEmptyState)
514
+ ? "loader"
515
+ : isRowsEmpty
516
+ ? "empty"
517
+ : "content";
518
+
519
+ useIsomorphicLayoutEffect(() => {
520
+ if (view !== "loader") return;
521
+ const row = skeletonRowRef.current;
522
+ if (!row) return;
523
+
524
+ const measureRow = () => {
525
+ commitSkeletonRowHeight(Math.ceil(row.getBoundingClientRect().height));
526
+ };
527
+
528
+ measureRow();
529
+ if (typeof ResizeObserver === "undefined") return;
530
+
531
+ const observer = new ResizeObserver(measureRow);
532
+ observer.observe(row);
533
+
534
+ return () => {
535
+ observer.disconnect();
536
+ };
537
+ }, [commitSkeletonRowHeight, view]);
538
+
539
+ const selectedCount = rowsSelectable
540
+ ? table.getSelectedRowModel().flatRows.length
541
+ : 0;
542
+ const headerGroups = table.getHeaderGroups();
543
+ const visibleLeafColumns = table.getVisibleLeafColumns();
544
+
545
+ const skeletonRowCount =
546
+ bodyViewportHeight > 0 && skeletonRowHeight > 0
547
+ ? Math.max(
548
+ MIN_SKELETON_ROWS,
549
+ Math.floor(bodyViewportHeight / skeletonRowHeight),
550
+ )
551
+ : MIN_SKELETON_ROWS;
552
+
553
+ const columnExtraWidths = useMemo<Record<string, number>>(() => {
554
+ const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
555
+ if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
556
+
557
+ // Never add responsive width onto pinned columns because tanstack computes
558
+ // sticky offsets from base sizes; changing pinned visual width would desync
559
+ // left/right offsets and reintroduce overlap.
560
+ const growColumns = visibleLeafColumns.filter(
561
+ (column) => !column.getIsPinned() && column.id !== SELECT_COLUMN_ID,
562
+ );
563
+ if (growColumns.length === 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
564
+
565
+ const baseExtra = Math.floor(extraSpace / growColumns.length);
566
+ let remainder = extraSpace - baseExtra * growColumns.length;
567
+
568
+ const next: Record<string, number> = {};
569
+ for (const column of growColumns) {
570
+ const grow = baseExtra + (remainder > 0 ? 1 : 0);
571
+ if (grow > 0) {
572
+ next[column.id] = grow;
573
+ }
574
+ if (remainder > 0) {
575
+ remainder -= 1;
576
+ }
577
+ }
578
+
579
+ return next;
580
+ }, [bodyViewportWidth, totalWidth, visibleLeafColumns]);
581
+
582
+ const tableWidth = Math.max(totalWidth, bodyViewportWidth);
583
+
584
+ // Columns that fit the body never scroll it sideways. The body's width is
585
+ // read from clientWidth, which rounds to the nearest pixel, so on a
586
+ // fractional layout (page zoom, display scaling) the table can overhang the
587
+ // body by under a pixel. Left to `overflow-x: auto`, that sliver shows a
588
+ // horizontal scrollbar, which shortens the body, flips its scrollbar gutter
589
+ // and starts another resize.
590
+ const columnsFit = bodyViewportWidth > 0 && totalWidth <= bodyViewportWidth;
591
+
592
+ // Match the table element width to the measured column widths. This keeps
593
+ // header, body, and skeleton rows on the same sizing contract during resize.
594
+ const tableSizingStyle: CSSProperties = useMemo(
595
+ () => ({ width: `${tableWidth}px`, minWidth: `${totalWidth}px` }),
596
+ [tableWidth, totalWidth],
597
+ );
598
+
599
+ const bodySkeletonStyle: CSSProperties = {
600
+ height: "var(--spacing-9)",
601
+ };
602
+
603
+ const selectSkeletonStyle: CSSProperties = {
604
+ width: "var(--data-table-selection-control-size, var(--spacing-9))",
605
+ height: "var(--data-table-selection-control-size, var(--spacing-9))",
606
+ };
607
+
608
+ const tableClass = clsx(
609
+ s.table,
610
+ showVerticalBorders && s.table_vertical_borders,
611
+ );
612
+
613
+ return (
614
+ <div className={clsx(s.root, className)}>
615
+ {/* Filters - inline row on desktop; a "Filters" button + modal once the
616
+ row no longer fits (so phones aren't buried under stacked fields). */}
617
+ {filters.length > 0 && (
618
+ <Filters
619
+ fields={filters}
620
+ values={filterValues}
621
+ onChange={onFilterChange}
622
+ />
623
+ )}
624
+
625
+ {/* Content wrap - grows to fill remaining height.
626
+ table_shell fills it; pagination pins to the bottom via margin-top:auto */}
627
+ <div className={s.content_wrap}>
628
+ {/* Table: detached header pill + scrollable body */}
629
+ <div
630
+ className={clsx(
631
+ s.table_shell,
632
+ !showPagination && s.table_shell_no_pagination,
633
+ )}
634
+ >
635
+ {/* Pinned header pill - scroll synced to body via JS */}
636
+ <div className={s.table_header_container}>
637
+ <div className={s.table_header_wrap} ref={headerRef}>
638
+ <table className={tableClass} style={tableSizingStyle}>
639
+ <thead>
640
+ {headerGroups.map((hg) => (
641
+ <tr key={hg.id}>
642
+ {hg.headers.map((header) => (
643
+ <HeaderCell<TData>
644
+ key={header.id}
645
+ header={header}
646
+ extraWidth={columnExtraWidths[header.column.id] ?? 0}
647
+ enableColumnOrdering={enableColumnOrdering}
648
+ enableColumnResizing={enableColumnResizing}
649
+ enableColumnPinning={enableColumnPinning}
650
+ enableGrouping={enableGrouping}
651
+ draggedColumnId={draggedColumnId}
652
+ dropTargetColumnId={dropTargetColumnId}
653
+ onDragStartCol={setDraggedColumnId}
654
+ onDragEndCol={handleDragEnd}
655
+ onDragOverCol={setDropTargetColumnId}
656
+ onDropCol={handleColumnDrop}
657
+ />
658
+ ))}
659
+ </tr>
660
+ ))}
661
+ </thead>
662
+ </table>
663
+ </div>
664
+ <div className={s.header_padding_mask} />
665
+ </div>
666
+
667
+ {/* Scrollable body - only this element scrolls. The body is a
668
+ single switch over `view`: at most one branch ever renders, the
669
+ other two are unmounted entirely. */}
670
+ <div
671
+ className={s.table_body_wrap}
672
+ style={{ pointerEvents: isLoading ? "none" : undefined }}
673
+ data-fits={columnsFit}
674
+ ref={bodyRef}
675
+ >
676
+ {view === "loader" && (
677
+ <table className={tableClass} style={tableSizingStyle}>
678
+ <tbody>
679
+ {Array.from({ length: skeletonRowCount }).map(
680
+ (_, rowIndex) => (
681
+ <tr
682
+ key={`skeleton-${rowIndex}`}
683
+ ref={rowIndex === 0 ? skeletonRowRef : undefined}
684
+ >
685
+ {visibleLeafColumns.map((column) => {
686
+ const isPinned = column.getIsPinned();
687
+ const isLastPinnedCol =
688
+ isPinned && isLastPinned(column);
689
+ const isSelectColumn = column.id === SELECT_COLUMN_ID;
690
+
691
+ return (
692
+ <td
693
+ key={column.id}
694
+ className={clsx(
695
+ isSelectColumn && s.col_select,
696
+ isPinned === "left" && s.col_pinned_left,
697
+ isPinned === "right" && s.col_pinned_right,
698
+ isLastPinnedCol && s.col_pinned_last,
699
+ )}
700
+ style={getColumnStyle(
701
+ column,
702
+ column.getSize(),
703
+ columnExtraWidths[column.id] ?? 0,
704
+ )}
705
+ >
706
+ <div className={s.td_inner}>
707
+ <Loader
708
+ variant="skeleton"
709
+ className={
710
+ isSelectColumn
711
+ ? s.loading_select_skeleton
712
+ : undefined
713
+ }
714
+ style={
715
+ isSelectColumn
716
+ ? selectSkeletonStyle
717
+ : bodySkeletonStyle
718
+ }
719
+ />
720
+ </div>
721
+ </td>
722
+ );
723
+ })}
724
+ </tr>
725
+ ),
726
+ )}
727
+ </tbody>
728
+ </table>
729
+ )}
730
+ {view === "empty" && (
731
+ <div className={s.state_overlay}>
732
+ <p className={s.empty_msg}>{emptyMessage}</p>
733
+ </div>
734
+ )}
735
+ {view === "content" && (
736
+ <table
737
+ className={tableClass}
738
+ style={tableSizingStyle}
739
+ ref={bodyTableRef}
740
+ >
741
+ <tbody>
742
+ {rows.map((row) => (
743
+ <BodyRow<TData>
744
+ key={row.id}
745
+ row={row}
746
+ editingRowId={editingRowId}
747
+ onRow={onRow}
748
+ enableGrouping={enableGrouping}
749
+ columnExtraWidths={columnExtraWidths}
750
+ enableRowOrdering={enableRowOrdering}
751
+ draggedRowId={draggedRowId}
752
+ dropTargetRowId={dropTargetRowId}
753
+ onDragStartRow={handleDragStartRow}
754
+ onDragOverRow={handleDragOverRow}
755
+ onDragEndRow={handleDragEndRow}
756
+ onDropRow={handleDropRow}
757
+ />
758
+ ))}
759
+ </tbody>
760
+ </table>
761
+ )}
762
+ </div>
763
+ </div>
764
+
765
+ {/* Pagination - margin-top:auto pins it to the bottom of content_wrap */}
766
+ {showPagination && (
767
+ <PaginationBar
768
+ table={table}
769
+ pagination={pagination}
770
+ onPaginationChange={onPaginationChange}
771
+ pageSizeOptions={pageSizeOptions}
772
+ showPageSizeSelect={showPageSizeSelect}
773
+ isLoading={view === "loader"}
774
+ totalCount={totalCount}
775
+ rowLabel={rowLabel}
776
+ rowsSelectable={rowsSelectable}
777
+ selectedCount={selectedCount}
778
+ label={paginationLabel}
779
+ extra={paginationExtra}
780
+ />
781
+ )}
782
+ </div>
783
+ </div>
784
+ );
785
+ };
786
+
787
+ // ─────────────────────────────────────────────────────────────────────────────
788
+ // HeaderActionButton - local compact icon button for the table header.
789
+ // Keeps the header tight (no library Button padding) without depending on the
790
+ // Button component while still mirroring its hover/focus visuals.
791
+ // ─────────────────────────────────────────────────────────────────────────────
792
+
793
+ const HeaderActionButton = ({
794
+ onClick,
795
+ ariaLabel,
796
+ ariaPressed,
797
+ active = false,
798
+ children,
799
+ }: HeaderActionButtonProps) => {
800
+ return (
801
+ <Button
802
+ type="button"
803
+ variant="ghost"
804
+ size="sm"
805
+ layout="box"
806
+ className={s.icon_btn}
807
+ onClick={onClick}
808
+ aria-label={ariaLabel}
809
+ aria-pressed={ariaPressed}
810
+ draggable={false}
811
+ isActive={active}
812
+ >
813
+ {children}
814
+ </Button>
815
+ );
816
+ };
817
+
818
+ // ─────────────────────────────────────────────────────────────────────────────
819
+ // HeaderCell - one <th> with sort, group, pin, drag, and resize affordances.
820
+ // ─────────────────────────────────────────────────────────────────────────────
821
+
822
+ const HeaderCell = <TData,>({
823
+ header,
824
+ extraWidth,
825
+ enableColumnOrdering,
826
+ enableColumnResizing,
827
+ enableColumnPinning,
828
+ enableGrouping,
829
+ draggedColumnId,
830
+ dropTargetColumnId,
831
+ onDragStartCol,
832
+ onDragEndCol,
833
+ onDragOverCol,
834
+ onDropCol,
835
+ }: HeaderCellProps<TData>) => {
836
+ const column = header.column;
837
+ const colMeta = column.columnDef.meta;
838
+ const align = colMeta?.align ?? "left";
839
+ // Fixed columns (e.g. row-select) opt out of hide-ability AND of being a
840
+ // drag source / drop target so they stay anchored in their position.
841
+ const isFixed = column.getCanHide() === false;
842
+ const canDrag = enableColumnOrdering && !isFixed;
843
+ const canResize = enableColumnResizing && column.getCanResize();
844
+ const canPin = enableColumnPinning && column.getCanPin() && !isFixed;
845
+ const canGroup = enableGrouping && column.getCanGroup() && !isFixed;
846
+ const canSort = column.getCanSort();
847
+ const isPinned = column.getIsPinned();
848
+ const isLastPinnedCol = isPinned && isLastPinned(column);
849
+ const isBeingDragged = draggedColumnId === column.id;
850
+ const isDropTarget =
851
+ dropTargetColumnId === column.id &&
852
+ draggedColumnId !== column.id &&
853
+ !isFixed;
854
+ const isGrouped = enableGrouping && column.getIsGrouped();
855
+ const isSelectColumn = column.id === SELECT_COLUMN_ID;
856
+ const sortState = column.getIsSorted();
857
+ const hasHeaderActions = canSort || canGroup || canPin;
858
+ const sortTooltip =
859
+ sortState === "asc"
860
+ ? "Sort descending"
861
+ : sortState === "desc"
862
+ ? "Clear sorting"
863
+ : "Sort ascending";
864
+
865
+ const handleDragStart = (e: ReactDragEvent<HTMLTableCellElement>) => {
866
+ e.dataTransfer.effectAllowed = "move";
867
+ e.dataTransfer.setData("text/plain", column.id);
868
+ onDragStartCol(column.id);
869
+ };
870
+
871
+ const handleDragOver = (e: ReactDragEvent<HTMLTableCellElement>) => {
872
+ if (!draggedColumnId || draggedColumnId === column.id || isFixed) return;
873
+ e.preventDefault();
874
+ e.dataTransfer.dropEffect = "move";
875
+ if (dropTargetColumnId !== column.id) onDragOverCol(column.id);
876
+ };
877
+
878
+ const handleDragLeave = () => {
879
+ if (dropTargetColumnId === column.id) onDragOverCol(null);
880
+ };
881
+
882
+ const handleDrop = (e: ReactDragEvent<HTMLTableCellElement>) => {
883
+ e.preventDefault();
884
+ if (!draggedColumnId || isFixed) return;
885
+ onDropCol(draggedColumnId, column.id);
886
+ onDragEndCol();
887
+ };
888
+
889
+ const sortIcon = !sortState ? (
890
+ <IconArrowsUpDown size={SORT_ICON_SIZE} stroke={ICON_STROKE} />
891
+ ) : sortState === "asc" ? (
892
+ <IconArrowUp size={SORT_ICON_SIZE} stroke={ICON_STROKE} />
893
+ ) : (
894
+ <IconArrowDown size={SORT_ICON_SIZE} stroke={ICON_STROKE} />
895
+ );
896
+
897
+ return (
898
+ <th
899
+ colSpan={header.colSpan}
900
+ onDragOver={canDrag ? handleDragOver : undefined}
901
+ onDragLeave={canDrag ? handleDragLeave : undefined}
902
+ onDrop={canDrag ? handleDrop : undefined}
903
+ className={clsx(
904
+ colMeta?.headerClassName,
905
+ s[`align_${align}`],
906
+ isSelectColumn && s.col_select,
907
+ isPinned === "left" && s.col_pinned_left,
908
+ isPinned === "right" && s.col_pinned_right,
909
+ isLastPinnedCol && s.col_pinned_last,
910
+ isBeingDragged && s.col_dragging,
911
+ isDropTarget && s.col_drop_target,
912
+ )}
913
+ style={getColumnStyle(column, header.getSize(), extraWidth)}
914
+ >
915
+ {!header.isPlaceholder && (
916
+ <div className={s.th_inner}>
917
+ {canDrag && (
918
+ <span
919
+ className={s.drag_handle}
920
+ aria-hidden
921
+ draggable
922
+ onDragStart={handleDragStart}
923
+ onDragEnd={onDragEndCol}
924
+ >
925
+ <IconGripVertical size={ICON_SIZE} stroke={ICON_STROKE} />
926
+ </span>
927
+ )}
928
+ {isSelectColumn ? (
929
+ flexRender(column.columnDef.header, header.getContext())
930
+ ) : (
931
+ <span className={s.th_label}>
932
+ {flexRender(column.columnDef.header, header.getContext())}
933
+ </span>
934
+ )}
935
+ {hasHeaderActions && (
936
+ <span className={s.th_actions}>
937
+ {canSort && (
938
+ <Tooltip content={sortTooltip}>
939
+ <HeaderActionButton
940
+ onClick={column.getToggleSortingHandler()}
941
+ ariaLabel={`Sort by ${column.id}`}
942
+ ariaPressed={!!sortState}
943
+ active={!!sortState}
944
+ >
945
+ {sortIcon}
946
+ </HeaderActionButton>
947
+ </Tooltip>
948
+ )}
949
+ {canGroup && (
950
+ <Tooltip
951
+ content={isGrouped ? "Ungroup column" : "Group by column"}
952
+ >
953
+ <HeaderActionButton
954
+ onClick={column.getToggleGroupingHandler()}
955
+ ariaLabel={
956
+ isGrouped ? "Ungroup column" : "Group by this column"
957
+ }
958
+ ariaPressed={isGrouped}
959
+ active={isGrouped}
960
+ >
961
+ {isGrouped ? (
962
+ <IconStack3 size={ICON_SIZE} stroke={ICON_STROKE} />
963
+ ) : (
964
+ <IconStack2 size={ICON_SIZE} stroke={ICON_STROKE} />
965
+ )}
966
+ </HeaderActionButton>
967
+ </Tooltip>
968
+ )}
969
+ {canPin && !isPinned && (
970
+ <>
971
+ <Tooltip content="Pin left">
972
+ <HeaderActionButton
973
+ onClick={() => column.pin("left")}
974
+ ariaLabel="Pin left"
975
+ >
976
+ <IconArrowLeft size={ICON_SIZE} stroke={ICON_STROKE} />
977
+ </HeaderActionButton>
978
+ </Tooltip>
979
+ <Tooltip content="Pin right">
980
+ <HeaderActionButton
981
+ onClick={() => column.pin("right")}
982
+ ariaLabel="Pin right"
983
+ >
984
+ <IconArrowRight size={ICON_SIZE} stroke={ICON_STROKE} />
985
+ </HeaderActionButton>
986
+ </Tooltip>
987
+ </>
988
+ )}
989
+ {canPin && isPinned && (
990
+ <Tooltip content="Unpin column">
991
+ <HeaderActionButton
992
+ onClick={() => column.pin(false)}
993
+ ariaLabel="Unpin column"
994
+ ariaPressed
995
+ active
996
+ >
997
+ <IconPinnedOff size={ICON_SIZE} stroke={ICON_STROKE} />
998
+ </HeaderActionButton>
999
+ </Tooltip>
1000
+ )}
1001
+ </span>
1002
+ )}
1003
+ {canResize && (
1004
+ <span
1005
+ role="separator"
1006
+ aria-orientation="vertical"
1007
+ className={clsx(
1008
+ s.resize_handle,
1009
+ column.getIsResizing() && s.resize_handle_active,
1010
+ )}
1011
+ onMouseDown={header.getResizeHandler()}
1012
+ onTouchStart={header.getResizeHandler()}
1013
+ onClick={(e) => e.stopPropagation()}
1014
+ onDoubleClick={() => column.resetSize()}
1015
+ draggable={false}
1016
+ />
1017
+ )}
1018
+ </div>
1019
+ )}
1020
+ </th>
1021
+ );
1022
+ };
1023
+
1024
+ // ─────────────────────────────────────────────────────────────────────────────
1025
+ // BodyRow - one <tr> + its visible cells.
1026
+ // ─────────────────────────────────────────────────────────────────────────────
1027
+
1028
+ const BodyRow = <TData extends { id: string }>({
1029
+ row,
1030
+ editingRowId,
1031
+ onRow,
1032
+ enableGrouping,
1033
+ columnExtraWidths,
1034
+ enableRowOrdering,
1035
+ draggedRowId,
1036
+ dropTargetRowId,
1037
+ onDragStartRow,
1038
+ onDragOverRow,
1039
+ onDragEndRow,
1040
+ onDropRow,
1041
+ }: BodyRowProps<TData>) => {
1042
+ const isGroup = enableGrouping && row.getIsGrouped();
1043
+ const visibleCells = row.getVisibleCells();
1044
+ const groupSourceCell = isGroup
1045
+ ? visibleCells.find((cell) => cell.getIsGrouped())
1046
+ : undefined;
1047
+ const groupAnchorCellId = isGroup
1048
+ ? (visibleCells.find((cell) => cell.column.id !== SELECT_COLUMN_ID)?.id ??
1049
+ groupSourceCell?.id ??
1050
+ null)
1051
+ : null;
1052
+ const isEditingThisRow =
1053
+ !isGroup && editingRowId !== null && row.original.id === editingRowId;
1054
+
1055
+ // Editing rows host inputs/selects; their clicks must not bubble to row click.
1056
+ // Only a click on the row itself opens it: not one on a control inside it
1057
+ // (a link, a copy button), nor one that reaches it through a portal (a row's
1058
+ // action menu), which React bubbles here from outside the row.
1059
+ const handleRowClick =
1060
+ !isGroup && !isEditingThisRow && onRow
1061
+ ? (event: ReactMouseEvent<HTMLTableRowElement>) => {
1062
+ const target = event.target as Element;
1063
+ if (!event.currentTarget.contains(target)) return;
1064
+ const control = target.closest(ROW_CONTROLS);
1065
+ if (control && event.currentTarget.contains(control)) return;
1066
+ onRow(row.original);
1067
+ }
1068
+ : undefined;
1069
+
1070
+ const canDrag = enableRowOrdering && !isGroup && !isEditingThisRow;
1071
+ const isBeingDragged = draggedRowId === row.id;
1072
+ const isDropTarget =
1073
+ dropTargetRowId === row.id && draggedRowId !== row.id && !isGroup;
1074
+
1075
+ return (
1076
+ <tr
1077
+ draggable={canDrag}
1078
+ onDragStart={(e) => {
1079
+ if (!canDrag) return;
1080
+ e.dataTransfer.effectAllowed = "move";
1081
+ e.dataTransfer.setData("text/plain", row.id);
1082
+ onDragStartRow?.(row.id);
1083
+ }}
1084
+ onDragOver={(e) => {
1085
+ if (!canDrag || !draggedRowId || draggedRowId === row.id) return;
1086
+ e.preventDefault();
1087
+ e.dataTransfer.dropEffect = "move";
1088
+ if (dropTargetRowId !== row.id) onDragOverRow?.(row.id);
1089
+ }}
1090
+ onDragLeave={() => {
1091
+ if (!canDrag) return;
1092
+ if (dropTargetRowId === row.id) onDragOverRow?.(null);
1093
+ }}
1094
+ onDragEnd={() => {
1095
+ if (!canDrag) return;
1096
+ onDragEndRow?.();
1097
+ }}
1098
+ onDrop={(e) => {
1099
+ if (!canDrag) return;
1100
+ e.preventDefault();
1101
+ if (!draggedRowId || draggedRowId === row.id) return;
1102
+ onDropRow?.(draggedRowId, row.id);
1103
+ onDragEndRow?.();
1104
+ }}
1105
+ className={clsx(
1106
+ row.getIsSelected() && s.row_selected,
1107
+ isEditingThisRow && s.row_editing,
1108
+ isGroup && s.row_group,
1109
+ isBeingDragged && s.row_dragging,
1110
+ isDropTarget && s.row_drop_target,
1111
+ )}
1112
+ onClick={handleRowClick}
1113
+ style={handleRowClick ? POINTER_STYLE : undefined}
1114
+ >
1115
+ {visibleCells.map((cell) => (
1116
+ <BodyCell<TData>
1117
+ key={cell.id}
1118
+ cell={cell}
1119
+ extraWidth={columnExtraWidths[cell.column.id] ?? 0}
1120
+ groupSourceCell={groupSourceCell}
1121
+ isGroupAnchorCell={groupAnchorCellId === cell.id}
1122
+ />
1123
+ ))}
1124
+ </tr>
1125
+ );
1126
+ };
1127
+
1128
+ // ─────────────────────────────────────────────────────────────────────────────
1129
+ // BodyCell - one <td>. Routes to the right body for grouped/aggregated/normal
1130
+ // cells and applies meta-driven align + truncate + pin classes.
1131
+ // ─────────────────────────────────────────────────────────────────────────────
1132
+
1133
+ const BodyCell = <TData,>({
1134
+ cell,
1135
+ extraWidth,
1136
+ groupSourceCell,
1137
+ isGroupAnchorCell,
1138
+ }: BodyCellProps<TData>) => {
1139
+ const column: Column<TData, unknown> = cell.column;
1140
+ const colMeta = column.columnDef.meta;
1141
+ const align = colMeta?.align ?? "left";
1142
+ const truncate = colMeta?.truncate ?? true;
1143
+ const isPinned = column.getIsPinned();
1144
+ const isLastPinnedCol = isPinned && isLastPinned(column);
1145
+ const isSelectColumn = column.id === SELECT_COLUMN_ID;
1146
+ const groupedRenderCell =
1147
+ groupSourceCell ?? (cell.getIsGrouped() ? cell : undefined);
1148
+ const groupDepth = groupedRenderCell?.row.depth ?? 0;
1149
+
1150
+ let body: ReactNode;
1151
+ if (groupedRenderCell && isGroupAnchorCell) {
1152
+ const groupedColumn = groupedRenderCell.column;
1153
+ body = (
1154
+ <button
1155
+ type="button"
1156
+ className={s.group_toggle}
1157
+ onClick={groupedRenderCell.row.getToggleExpandedHandler()}
1158
+ aria-expanded={groupedRenderCell.row.getIsExpanded()}
1159
+ aria-label={
1160
+ groupedRenderCell.row.getIsExpanded()
1161
+ ? "Collapse group"
1162
+ : "Expand group"
1163
+ }
1164
+ style={
1165
+ groupDepth > 0
1166
+ ? { paddingInlineStart: `calc(${groupDepth} * var(--spacing-4))` }
1167
+ : undefined
1168
+ }
1169
+ >
1170
+ <IconChevronRight
1171
+ size={ICON_SIZE}
1172
+ stroke={ICON_STROKE}
1173
+ className={clsx(
1174
+ s.group_chevron,
1175
+ groupedRenderCell.row.getIsExpanded() && s.group_chevron_open,
1176
+ )}
1177
+ />
1178
+ <span className={s.group_value}>
1179
+ {flexRender(
1180
+ groupedColumn.columnDef.cell,
1181
+ groupedRenderCell.getContext(),
1182
+ )}
1183
+ </span>
1184
+ <span className={s.group_count}>
1185
+ ({groupedRenderCell.row.subRows.length})
1186
+ </span>
1187
+ </button>
1188
+ );
1189
+ } else if (groupedRenderCell && groupedRenderCell.id === cell.id) {
1190
+ body = null;
1191
+ } else if (cell.getIsAggregated()) {
1192
+ body = column.columnDef.aggregatedCell ? (
1193
+ flexRender(column.columnDef.aggregatedCell, cell.getContext())
1194
+ ) : column.columnDef.aggregationFn ? (
1195
+ <span className={s.cell_aggregated}>
1196
+ {formatAggregated(cell.getValue())}
1197
+ </span>
1198
+ ) : null;
1199
+ } else if (cell.getIsPlaceholder()) {
1200
+ body = null;
1201
+ } else {
1202
+ body = flexRender(column.columnDef.cell, cell.getContext());
1203
+ }
1204
+
1205
+ // Group rows are full-width summary rows; sticky pinning makes no sense
1206
+ // on them. We strip BOTH the pinned CSS classes AND the left/right inline
1207
+ // offsets. Without this, td:focus-within (which sets position:relative)
1208
+ // would combine with left:Xpx from getColumnStyle and physically shift
1209
+ // the cell when the user clicks the group-toggle chevron.
1210
+ const isGroupRow = !!groupedRenderCell;
1211
+ const baseStyle = getColumnStyle(column, column.getSize(), extraWidth);
1212
+ const cellStyle: CSSProperties = isGroupRow
1213
+ ? {
1214
+ width: baseStyle.width,
1215
+ minWidth: baseStyle.minWidth,
1216
+ maxWidth: baseStyle.maxWidth,
1217
+ }
1218
+ : baseStyle;
1219
+
1220
+ return (
1221
+ <td
1222
+ className={clsx(
1223
+ colMeta?.cellClassName,
1224
+ s[`align_${align}`],
1225
+ isGroupAnchorCell && s.group_anchor_cell,
1226
+ !isGroupRow && isSelectColumn && s.col_select,
1227
+ !isGroupRow && isPinned === "left" && s.col_pinned_left,
1228
+ !isGroupRow && isPinned === "right" && s.col_pinned_right,
1229
+ !isGroupRow && isLastPinnedCol && s.col_pinned_last,
1230
+ )}
1231
+ style={cellStyle}
1232
+ >
1233
+ <div className={clsx(s.td_inner, !truncate && s.td_inner_no_truncate)}>
1234
+ {body}
1235
+ </div>
1236
+ </td>
1237
+ );
1238
+ };