aplance-ui 1.0.1

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