@jielga/tmdatagrid 0.2.0 → 0.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 (50) hide show
  1. package/dist/index.d.ts +1337 -94
  2. package/dist/index.js +2812 -1189
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +2 -2
  6. package/skills/columns/SKILL.md +1 -1
  7. package/skills/features/SKILL.md +61 -11
  8. package/skills/getting-started/SKILL.md +41 -6
  9. package/skills/options/SKILL.md +12 -2
  10. package/skills/server-side/SKILL.md +1 -1
  11. package/src/tmdatagrid/TMDataGridContext.ts +26 -0
  12. package/src/tmdatagrid/components/TMDataGrid.module.css +21 -1
  13. package/src/tmdatagrid/components/TMDataGrid.tsx +29 -1
  14. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +12 -6
  15. package/src/tmdatagrid/components/TMDataGridDetailsColumn.module.css +33 -0
  16. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +160 -0
  17. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +15 -0
  18. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +192 -37
  19. package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
  20. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +136 -0
  21. package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
  22. package/src/tmdatagrid/components/TMDataGridFooter.tsx +63 -43
  23. package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
  24. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +159 -0
  25. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +18 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +249 -44
  27. package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
  28. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +103 -16
  29. package/src/tmdatagrid/components/TMDataGridTable.module.css +160 -5
  30. package/src/tmdatagrid/components/TMDataGridTable.tsx +1380 -43
  31. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +33 -7
  32. package/src/tmdatagrid/components/icons.ts +6 -0
  33. package/src/tmdatagrid/components/sticky.module.css +77 -16
  34. package/src/tmdatagrid/core/autosize.ts +125 -0
  35. package/src/tmdatagrid/core/capabilities.ts +122 -11
  36. package/src/tmdatagrid/core/cellExport.ts +321 -0
  37. package/src/tmdatagrid/core/cellNavigation.ts +103 -0
  38. package/src/tmdatagrid/core/cellRange.ts +118 -0
  39. package/src/tmdatagrid/core/columnOptions.ts +123 -0
  40. package/src/tmdatagrid/core/columnUtils.ts +18 -0
  41. package/src/tmdatagrid/core/expanding.ts +93 -0
  42. package/src/tmdatagrid/core/filterOperators.ts +198 -12
  43. package/src/tmdatagrid/core/grouping.ts +21 -0
  44. package/src/tmdatagrid/core/labels.ts +211 -0
  45. package/src/tmdatagrid/core/labelsSv.ts +108 -0
  46. package/src/tmdatagrid/core/persistence.ts +12 -0
  47. package/src/tmdatagrid/core/rowSelection.ts +202 -0
  48. package/src/tmdatagrid/core/summary.ts +35 -0
  49. package/src/tmdatagrid/index.ts +96 -2
  50. package/src/tmdatagrid/useTMDataGrid.tsx +558 -37
@@ -1,19 +1,53 @@
1
- import { Loader, Text } from "@mantine/core";
1
+ import { Checkbox, Loader, Menu, type MenuProps, Text } from "@mantine/core";
2
2
  import {
3
3
  type Cell,
4
4
  flexRender,
5
5
  type Row,
6
6
  type RowData,
7
+ type Table,
7
8
  } from "@tanstack/react-table";
8
9
  import { useSelector } from "@tanstack/react-store";
9
10
  import { useVirtualizer } from "@tanstack/react-virtual";
10
- import { useCallback, useEffect, useRef } from "react";
11
+ import {
12
+ type KeyboardEvent,
13
+ type MouseEvent,
14
+ type ReactNode,
15
+ useCallback,
16
+ useEffect,
17
+ useRef,
18
+ useState,
19
+ } from "react";
11
20
  import classes from "./TMDataGridTable.module.css";
12
21
  import sticky from "./sticky.module.css";
13
22
  import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
14
23
  import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
15
24
  import { TMDataGridHeaderCell } from "./TMDataGridHeaderCell";
16
- import { getColumnAlign } from "../core/columnUtils";
25
+ import { getColumnAlign, isControlColumn } from "../core/columnUtils";
26
+ import {
27
+ resolveCellMove,
28
+ type TMDataGridCellPosition,
29
+ } from "../core/cellNavigation";
30
+ import {
31
+ boundsCellCount,
32
+ boundsEdges,
33
+ isWithinBounds,
34
+ resolveRangeBounds,
35
+ } from "../core/cellRange";
36
+ import {
37
+ buildCellMatrix,
38
+ DEFAULT_CELL_EXPORT_OPTIONS,
39
+ downloadTextFile,
40
+ toClipboardText,
41
+ toExcelCsv,
42
+ writeClipboardText,
43
+ type TMDataGridCellExportOptions,
44
+ } from "../core/cellExport";
45
+ import { autosizeColumn } from "../core/autosize";
46
+ import {
47
+ getDisplayedRows,
48
+ getSelectableRowIds,
49
+ resolveRowSelectionClick,
50
+ } from "../core/rowSelection";
17
51
  import { SearchIcon } from "./icons";
18
52
  import type { TMDataGridFeatures } from "../useTMDataGrid";
19
53
 
@@ -32,19 +66,184 @@ const UNPINNED_LAYOUT: TMDataGridColumnLayout = {
32
66
  isBoundary: false,
33
67
  };
34
68
 
69
+ /**
70
+ * What Enter and F2 step into. Deliberately the plain list rather than a full
71
+ * tabbable audit: a cell holds a control or it doesn't, and anything exotic
72
+ * enough to fool this is exotic enough to handle its own keys.
73
+ */
74
+ const FOCUSABLE_IN_CELL = [
75
+ "button:not([disabled])",
76
+ "input:not([disabled])",
77
+ "select:not([disabled])",
78
+ "textarea:not([disabled])",
79
+ "a[href]",
80
+ '[tabindex]:not([tabindex="-1"])',
81
+ ].join(",");
82
+
83
+ /**
84
+ * The mounted element for a cell position, or `null` when its row is scrolled
85
+ * out — the normal case for a move that has to scroll before it can focus.
86
+ */
87
+ function findCellElement(
88
+ container: HTMLElement,
89
+ cell: TMDataGridCellPosition,
90
+ ): HTMLElement | null {
91
+ return container.querySelector<HTMLElement>(
92
+ `[data-cell="true"][data-row-id="${CSS.escape(cell.rowId)}"]` +
93
+ `[data-column-id="${CSS.escape(cell.columnId)}"]`,
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Scrolls sideways far enough to clear the pinned lanes.
99
+ *
100
+ * `scrollIntoView` cannot do this: the pinned columns are `position: sticky`,
101
+ * so as far as the browser is concerned a cell sliding underneath them is
102
+ * still perfectly visible, and it stops scrolling the moment the cell's edge
103
+ * reaches the container's. The visible edge is the inside of the pinned lane,
104
+ * which only this component knows — it laid the lanes out.
105
+ */
106
+ function keepColumnInView({
107
+ container,
108
+ cellElement,
109
+ leftPinnedWidth,
110
+ rightPinnedWidth,
111
+ }: {
112
+ container: HTMLElement;
113
+ cellElement: HTMLElement;
114
+ leftPinnedWidth: number;
115
+ rightPinnedWidth: number;
116
+ }): void {
117
+ const cellRect = cellElement.getBoundingClientRect();
118
+ const containerRect = container.getBoundingClientRect();
119
+ const leftBound = containerRect.left + leftPinnedWidth;
120
+ const rightBound = containerRect.right - rightPinnedWidth;
121
+
122
+ if (cellRect.left < leftBound) {
123
+ container.scrollLeft -= leftBound - cellRect.left;
124
+ } else if (cellRect.right > rightBound) {
125
+ container.scrollLeft += cellRect.right - rightBound;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * What a cell shows once grouping is in play.
131
+ *
132
+ * A group row carries a cell for every column, but only some of them have
133
+ * anything to say:
134
+ *
135
+ * | Cell | Renders |
136
+ * | ---- | ------- |
137
+ * | On a data row | the column's `cell`, as always |
138
+ * | Placeholder — a grouped column other than this row's own | nothing |
139
+ * | Aggregated, column declares `aggregatedCell` | that |
140
+ * | Aggregated, column declares an `aggregationFn` | the column's `cell`, over the aggregate |
141
+ * | Aggregated, column declares neither | nothing |
142
+ *
143
+ * That last row is the one worth spelling out. Without an aggregation function
144
+ * `getValue()` is `undefined` on a group row, so the column's own renderer
145
+ * would be handed nothing — and a renderer that formats what it gets
146
+ * (`value.toFixed(2)`) would throw. Blank is both the safe answer and the right
147
+ * one: a plain "group by" is a tree, not a summary, and a column only joins in
148
+ * once it has been told how.
149
+ */
150
+ function renderCellContent(
151
+ cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>,
152
+ ) {
153
+ if (cell.getIsPlaceholder()) return null;
154
+ if (!cell.getIsAggregated()) {
155
+ return flexRender(cell.column.columnDef.cell, cell.getContext());
156
+ }
157
+ const { aggregatedCell, aggregationFn } = cell.column.columnDef;
158
+ if (aggregatedCell !== undefined) {
159
+ return flexRender(aggregatedCell, cell.getContext());
160
+ }
161
+ if (aggregationFn === undefined) return null;
162
+ return flexRender(cell.column.columnDef.cell, cell.getContext());
163
+ }
164
+
165
+ /**
166
+ * What a cell needs to take part in cell selection, or `undefined` while the
167
+ * feature is off — which is what keeps a plain grid's cells exactly as they
168
+ * were: no tab stop, no `gridcell` role, no coordinates in the DOM.
169
+ *
170
+ * The coordinates are there for the delegated key handler, which reads them off
171
+ * `event.target` rather than closing over per-cell state, and for the effect
172
+ * that puts DOM focus where {@link TMDataGridUiState.focusedCell} says.
173
+ */
174
+ export type TMDataGridCellNav = {
175
+ rowId: string;
176
+ /** 1-based, for `aria-colindex`. */
177
+ columnIndex: number;
178
+ focused: boolean;
179
+ /** The roving tab stop: `0` on exactly one mounted cell, `-1` on the rest. */
180
+ tabIndex: 0 | -1;
181
+ /** Inside the selected rectangle. Always `false` under `"single"`. */
182
+ selected: boolean;
183
+ /**
184
+ * Which sides of the rectangle this cell is on, or `null` when it is not in
185
+ * one. The outline is drawn per cell — see {@link boundsEdges}.
186
+ */
187
+ edges: { top: boolean; bottom: boolean; left: boolean; right: boolean } | null;
188
+ };
189
+
35
190
  function TMDataGridBodyCell({
36
191
  cell,
37
192
  rowHeight,
38
193
  layout,
194
+ nav,
195
+ onFocus,
196
+ onMouseDown,
197
+ onMouseEnter,
198
+ onContextMenu,
39
199
  }: {
40
200
  cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
41
201
  rowHeight: number;
42
202
  layout: TMDataGridColumnLayout;
203
+ nav?: TMDataGridCellNav;
204
+ onFocus?: () => void;
205
+ onMouseDown?: (event: MouseEvent<HTMLDivElement>) => void;
206
+ onMouseEnter?: () => void;
207
+ onContextMenu?: (event: MouseEvent<HTMLDivElement>) => void;
43
208
  }) {
44
209
  return (
45
210
  <div
46
- role="cell"
211
+ // `gridcell` rather than `cell` is the whole promise of cell selection:
212
+ // it is what tells a screen reader the arrow keys move a cursor here, so
213
+ // it is set together with the tab stop rather than always.
214
+ role={nav ? "gridcell" : "cell"}
215
+ aria-colindex={nav?.columnIndex}
216
+ tabIndex={nav?.tabIndex}
217
+ // The marker the delegated key handler tests: a key event whose target is
218
+ // a cell is the grid's, one from inside a cell belongs to whatever holds
219
+ // the focus in there.
220
+ data-cell={nav ? true : undefined}
221
+ data-row-id={nav?.rowId}
222
+ // Always present, not only under cell selection: autosize finds a
223
+ // column's mounted cells by it. The cell-navigation selectors also
224
+ // require `data-cell`, so they are unaffected.
225
+ data-column-id={cell.column.id}
226
+ data-focused={nav?.focused}
227
+ aria-selected={nav?.selected}
228
+ data-selected={nav?.selected}
229
+ // One attribute per side rather than a class per combination: the
230
+ // stylesheet draws the outline edge by edge, and sixteen classes for the
231
+ // sixteen corners of a rectangle is not a stylesheet anyone can read.
232
+ data-edge-top={nav?.edges?.top}
233
+ data-edge-bottom={nav?.edges?.bottom}
234
+ data-edge-left={nav?.edges?.left}
235
+ data-edge-right={nav?.edges?.right}
236
+ // Bubbles, so focus landing on a control inside the cell counts as
237
+ // landing on the cell — the ring follows the user into the checkbox
238
+ // rather than being left behind on whichever cell they came from.
239
+ onFocus={onFocus}
240
+ onMouseDown={onMouseDown}
241
+ onMouseEnter={onMouseEnter}
47
242
  data-align={getColumnAlign(cell.column)}
243
+ // A control lane is a fixed track, so it cannot take the cell padding the
244
+ // scale grows for text. See isControlColumn.
245
+ data-control-column={isControlColumn(cell.column.id)}
246
+ onContextMenu={onContextMenu}
48
247
  className={[
49
248
  classes.bodyCell,
50
249
  layout.isBoundary && layout.pinnedAt === "left" ? sticky.stickyLeft : "",
@@ -58,23 +257,177 @@ function TMDataGridBodyCell({
58
257
  minHeight: rowHeight,
59
258
  left: layout.pinnedAt === "left" ? layout.offset : undefined,
60
259
  right: layout.pinnedAt === "right" ? layout.offset : undefined,
61
- position: layout.pinnedAt ? "sticky" : undefined,
62
- zIndex: layout.pinnedAt ? 2 : undefined,
260
+ // The focus ring is drawn inside the cell's own box, so the cell has to
261
+ // be able to stack above its neighbours — a plain one to lift the ring
262
+ // over the pinned lane it sits beside, a pinned one to keep the ring
263
+ // whole while the rest of the row slides under it. Values from the
264
+ // stacking ladder in TMDataGrid.module.css.
265
+ position: layout.pinnedAt
266
+ ? "sticky"
267
+ : nav?.focused
268
+ ? "relative"
269
+ : undefined,
270
+ zIndex: nav?.focused
271
+ ? "var(--dg-z-focused-cell, 3)"
272
+ : layout.pinnedAt
273
+ ? "var(--dg-z-pinned-cell, 2)"
274
+ : undefined,
63
275
  }}
64
276
  >
65
- <span className={classes.cellContent}>
66
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
67
- </span>
277
+ <span className={classes.cellContent}>{renderCellContent(cell)}</span>
68
278
  </div>
69
279
  );
70
280
  }
71
281
 
282
+ /**
283
+ * The virtualized body rows, wrapped in the single Menu that serves all of them
284
+ * when `rowContextMenu` is set.
285
+ *
286
+ * One instance for the whole body rather than one per row. A Mantine `Menu` is
287
+ * a `Popover`, and a closed Popover still runs its hooks on every render — and
288
+ * the body re-renders on every scroll frame, because that is how virtualization
289
+ * works. Wrapping each of ~20 mounted rows measured a fifth onto a body render;
290
+ * wrapping the group is the same behaviour for one instance.
291
+ *
292
+ * `display: contents` keeps the group out of the grid's layout while still
293
+ * taking the right-click, carrying `Menu.ContextMenu`'s handlers, and passing
294
+ * its inherited `user-select: none` down to the rows.
295
+ */
296
+ function TMDataGridBodyRowGroup({
297
+ enabled,
298
+ menuContent,
299
+ menuProps,
300
+ onContextMenu,
301
+ onClose,
302
+ children,
303
+ }: {
304
+ enabled: boolean;
305
+ menuContent: ReactNode;
306
+ menuProps?: Omit<MenuProps, "opened" | "onChange" | "children">;
307
+ onContextMenu: () => void;
308
+ onClose: () => void;
309
+ children: ReactNode;
310
+ }) {
311
+ const rowGroup = (
312
+ <div
313
+ role="rowgroup"
314
+ style={{ display: "contents" }}
315
+ // Runs before Mantine's own handler, which `Menu.ContextMenu` composes on
316
+ // top of this one — so the row and cell are known by the time the
317
+ // dropdown is built and opened at the pointer.
318
+ onContextMenu={enabled ? onContextMenu : undefined}
319
+ >
320
+ {children}
321
+ </div>
322
+ );
323
+
324
+ if (!enabled) return rowGroup;
325
+
326
+ return (
327
+ <Menu
328
+ // Open only once the render prop has returned something: a row it answers
329
+ // `null` for never opens an empty dropdown.
330
+ opened={menuContent !== null}
331
+ onChange={(opened) => {
332
+ if (!opened) onClose();
333
+ }}
334
+ position="bottom-start"
335
+ shadow="md"
336
+ width={220}
337
+ {...menuProps}
338
+ >
339
+ <Menu.ContextMenu>{rowGroup}</Menu.ContextMenu>
340
+ <Menu.Dropdown>{menuContent}</Menu.Dropdown>
341
+ </Menu>
342
+ );
343
+ }
344
+
345
+ /** What a `rowContextMenu` render prop is handed for the right-clicked row. */
346
+ export type TMDataGridRowContextMenuArgs<TData extends RowData> = {
347
+ table: Table<TMDataGridFeatures, TData>;
348
+ row: Row<TMDataGridFeatures, TData>;
349
+ /**
350
+ * The cell under the pointer. `null` only when the right-click landed on the
351
+ * row but on no cell — which a custom cell renderer that stops propagation
352
+ * can cause.
353
+ */
354
+ cell: Cell<TMDataGridFeatures, TData, unknown> | null;
355
+ /**
356
+ * Closes the menu. `Menu.Item` already closes on click (Mantine's
357
+ * `closeOnItemClick`), so this is for content that isn't a menu item.
358
+ */
359
+ close: () => void;
360
+ };
361
+
362
+ /**
363
+ * Builds the contents of a row's context menu — `Menu.Item`, `Menu.Label`,
364
+ * `Menu.Divider`, `Menu.Sub`, or any other node. The grid owns the `Menu` and
365
+ * its `Menu.Dropdown`; this fills the dropdown.
366
+ */
367
+ export type TMDataGridRowContextMenu<TData extends RowData> = (
368
+ args: TMDataGridRowContextMenuArgs<TData>,
369
+ ) => ReactNode;
370
+
72
371
  export type TMDataGridTableProps<TData extends RowData> = {
73
372
  /**
74
373
  * Called when a body row is clicked. Runs in addition to row selection under
75
- * `rowSelectionMode: "row"`, not instead of it.
374
+ * `selectionMode: "row"`, not instead of it.
76
375
  */
77
376
  onRowClick?: (row: Row<TMDataGridFeatures, TData>) => void;
377
+ /**
378
+ * Contents of the menu a right-click on a row opens, at the pointer. Return
379
+ * `null` to leave a row without one — the browser's own menu stays suppressed
380
+ * either way.
381
+ *
382
+ * ```tsx
383
+ * <TMDataGrid.Table<Employee>
384
+ * rowContextMenu={({ row, close }) => (
385
+ * <>
386
+ * <Menu.Label>{row.original.firstName}</Menu.Label>
387
+ * <Menu.Item onClick={() => open(row.original.id)}>Open</Menu.Item>
388
+ * <Menu.Divider />
389
+ * <Menu.Item color="red" onClick={() => remove(row.original.id)}>
390
+ * Delete
391
+ * </Menu.Item>
392
+ * </>
393
+ * )}
394
+ * />
395
+ * ```
396
+ *
397
+ * Called during render, and only for the row whose menu is open — so it stays
398
+ * a pure function of the row, and cost per row does not matter.
399
+ */
400
+ rowContextMenu?: TMDataGridRowContextMenu<TData>;
401
+ /**
402
+ * Passed to the Mantine `Menu` the grid wraps the row in — `width`, `shadow`,
403
+ * `position`, `transitionProps` and the rest. Its open state is the grid's.
404
+ */
405
+ rowContextMenuProps?: Omit<MenuProps, "opened" | "onChange" | "children">;
406
+ /**
407
+ * How Ctrl+C and the export item write values, under
408
+ * `cellSelection: "range"`. Defaults to the Nordic Excel conventions — see
409
+ * {@link TMDataGridCellExportOptions}.
410
+ */
411
+ cellExport?: TMDataGridCellExportOptions;
412
+ /**
413
+ * Called when the scroll approaches the last row — the infinite-scroll
414
+ * hook-in. Append the next page to `data` and the virtualizer keeps its
415
+ * position; fires once per row count, so a pending fetch is not asked
416
+ * again until it lands.
417
+ *
418
+ * ```tsx
419
+ * <TMDataGrid.Table onReachEnd={() => query.fetchNextPage()} />
420
+ * ```
421
+ *
422
+ * Sorting and filtering must be server-side (`manual*`) on a grid that
423
+ * loads this way — the client only ever holds a prefix of the data. Not
424
+ * compatible with `enablePagination`, which slices the same scroll.
425
+ */
426
+ onReachEnd?: () => void;
427
+ /**
428
+ * How many rows before the end {@link onReachEnd} fires at. Defaults to 10.
429
+ */
430
+ reachEndThreshold?: number;
78
431
  };
79
432
 
80
433
  /**
@@ -85,23 +438,72 @@ export type TMDataGridTableProps<TData extends RowData> = {
85
438
  */
86
439
  export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
87
440
  onRowClick,
441
+ rowContextMenu,
442
+ rowContextMenuProps,
443
+ cellExport,
444
+ onReachEnd,
445
+ reachEndThreshold = 10,
88
446
  }: TMDataGridTableProps<TData>) {
89
- const { table, features, rowHeight, controlSize } = useTMDataGridContext();
447
+ const {
448
+ table,
449
+ ui,
450
+ features,
451
+ labels,
452
+ rowHeight,
453
+ controlSize,
454
+ renderDetails,
455
+ renderDetailsEstHeight,
456
+ overscan,
457
+ } = useTMDataGridContext();
90
458
 
91
459
  // The body depends on every state slice (sorting, filters, paging, sizing,
92
460
  // visibility, selection), so it subscribes to the whole table store.
93
461
  useSelector(table.store);
462
+ // The active row lives in the chrome store, so it needs its own subscription.
463
+ const highlightedRowId = useSelector(ui, (state) => state.highlightedRowId);
464
+ // As does the focused cell. Selected separately from the row above so a grid
465
+ // with cell selection off never re-renders for it.
466
+ const focusedCell = useSelector(ui, (state) => state.focusedCell);
467
+ const cellRange = useSelector(ui, (state) => state.cellRange);
94
468
 
95
- const { loading, noResultsLabel = "No rows match your filters" } =
469
+ const { loading, noResultsLabel = labels.noResults } =
96
470
  table.options.meta ?? {};
97
471
 
98
472
  const scrollContainerRef = useRef<HTMLDivElement>(null);
99
473
  // Pagination off (the default): every filtered+sorted row, virtualized.
100
474
  // Pagination on: the current page; under `manualPagination` TanStack returns
101
475
  // the rows as delivered, so the same branch serves server paging.
102
- const rows = features.pagination
103
- ? table.getPaginatedRowModel().rows
104
- : table.getPrePaginatedRowModel().rows;
476
+ const rows = getDisplayedRows(table, features);
477
+
478
+ /**
479
+ * Whether this row opens a detail panel underneath it.
480
+ *
481
+ * Group rows are out: expanding one opens its children, and they share the
482
+ * `expanded` state with details — so a grouped grid with `renderDetails` set
483
+ * would otherwise render a panel about the group's first record.
484
+ */
485
+ const showsDetails = (row: Row<TMDataGridFeatures, TMDataGridRowData>) =>
486
+ renderDetails !== undefined && !row.getIsGrouped() && row.getIsExpanded();
487
+
488
+ /**
489
+ * `meta.autoSize` columns, sized once after the first rows are in the DOM.
490
+ * A ref rather than an effect dependency: this must run exactly once per
491
+ * mount, and only for columns no persisted or user width already covers —
492
+ * autosizing again on data changes would fight a width the user has since
493
+ * dragged.
494
+ */
495
+ const autoSizedRef = useRef(false);
496
+ useEffect(() => {
497
+ if (autoSizedRef.current) return;
498
+ autoSizedRef.current = true;
499
+ const container = scrollContainerRef.current;
500
+ if (container === null) return;
501
+ for (const column of orderedColumns) {
502
+ if (column.columnDef.meta?.autoSize !== true) continue;
503
+ if (column.id in table.store.state.columnSizing) continue;
504
+ autosizeColumn({ table, columnId: column.id, container });
505
+ }
506
+ });
105
507
 
106
508
  // A persisted pageIndex can outlive the data that produced it; TanStack only
107
509
  // auto-resets on live filter/sort/data changes, not on restored state. The
@@ -117,9 +519,35 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
117
519
  const virtualizer = useVirtualizer({
118
520
  count: rows.length,
119
521
  getScrollElement: () => scrollContainerRef.current,
120
- estimateSize: useCallback(() => rowHeight, [rowHeight]),
522
+ /**
523
+ * Exact for a plain row, which is fixed at `rowHeight`, and a guess for a
524
+ * row showing a detail panel — the panel is as tall as whatever the
525
+ * consumer renders. Rows are measured whenever details are on (see
526
+ * `measureElement` below), so this only has to hold until the row has been
527
+ * on screen once.
528
+ *
529
+ * Not a `useCallback`, unlike `getItemKey` below it: the virtualizer's
530
+ * measurement cache lists `getItemKey` among its dependencies and not this,
531
+ * so a stable identity here would buy nothing — and the closure has to see
532
+ * the current expansion to answer at all.
533
+ */
534
+ estimateSize: (index: number) => {
535
+ const row = rows[index];
536
+ if (row === undefined) return rowHeight;
537
+ return rowHeight + (showsDetails(row) ? renderDetailsEstHeight : 0);
538
+ },
539
+ /**
540
+ * Zero is never a row: every one carries `minHeight: rowHeight`. An element
541
+ * reports it before layout has run — and always under jsdom, which has no
542
+ * layout at all — and a row cached at zero height stays that way until
543
+ * something resizes it. The estimate stands in until there is a real
544
+ * measurement.
545
+ */
546
+ measureElement: (element, _entry, instance) =>
547
+ element.getBoundingClientRect().height ||
548
+ instance.options.estimateSize(instance.indexFromElement(element)),
121
549
  getItemKey: useCallback((index: number) => rows[index]?.id ?? index, [rows]),
122
- overscan: 6,
550
+ overscan,
123
551
  initialRect: { height: 600, width: 1200 },
124
552
  });
125
553
 
@@ -128,9 +556,41 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
128
556
  const paddingBottom =
129
557
  virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
130
558
 
131
- const leftLeafColumns = table.getLeftLeafColumns();
132
- const rightLeafColumns = table.getRightLeafColumns();
133
- const centerLeafColumns = table.getCenterLeafColumns();
559
+ /**
560
+ * Infinite scroll. Latched on the row count rather than debounced: one call
561
+ * per count means a pending fetch is never asked twice, and the append that
562
+ * resolves it changes the count, which re-arms the latch. A failed fetch
563
+ * stays quiet until something changes the rows — retrying is the
564
+ * consumer's call, not a scroll side effect.
565
+ *
566
+ * Effect (not render): `onReachEnd` sets consumer state.
567
+ */
568
+ const reachEndFiredForCountRef = useRef<number | null>(null);
569
+ const warnedReachEndPaginationRef = useRef(false);
570
+ const lastMountedIndex = virtualItems.at(-1)?.index ?? -1;
571
+ useEffect(() => {
572
+ if (onReachEnd === undefined || rows.length === 0) return;
573
+ if (features.pagination && !warnedReachEndPaginationRef.current) {
574
+ warnedReachEndPaginationRef.current = true;
575
+ console.warn(
576
+ "TMDataGrid: onReachEnd and pagination slice the same scroll — the pager caps the rows, so the end it reaches is the page's. Use one or the other.",
577
+ );
578
+ }
579
+ if (lastMountedIndex < rows.length - 1 - reachEndThreshold) return;
580
+ if (reachEndFiredForCountRef.current === rows.length) return;
581
+ reachEndFiredForCountRef.current = rows.length;
582
+ onReachEnd();
583
+ });
584
+
585
+ // The *Visible* variants throughout: `getLeftLeafColumns()` and friends
586
+ // include hidden columns, while the cells below come from
587
+ // `row.getLeftVisibleCells()`, which does not. Mixing the two would lay down
588
+ // a grid track for a column that renders no cell, shifting every column after
589
+ // it out of its header. The tree column is hidden exactly this way while
590
+ // nothing is grouped.
591
+ const leftLeafColumns = table.getLeftVisibleLeafColumns();
592
+ const rightLeafColumns = table.getRightVisibleLeafColumns();
593
+ const centerLeafColumns = table.getCenterVisibleLeafColumns();
134
594
 
135
595
  const orderedColumns = [
136
596
  ...leftLeafColumns,
@@ -221,27 +681,633 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
221
681
 
222
682
  const isEmpty = rows.length === 0;
223
683
 
224
- // "row" mode: the row itself is the selection control — clicking it toggles
225
- // that row and leaves the rest of the selection alone.
226
- const selectsOnRowClick =
227
- features.rowSelection && features.rowSelectionMode === "row";
228
- const rowIsInteractive = selectsOnRowClick || onRowClick !== undefined;
684
+ /**
685
+ * The summary row exists by declaration, not by flag: it renders exactly
686
+ * when at least one visible leaf column defines a `footer`. Its cells come
687
+ * from the leaf header group, whose headers sit in the same left → centre →
688
+ * right order as `orderedColumns` — so `layoutFor` and the grid tracks
689
+ * apply unchanged.
690
+ */
691
+ const leafHeaders = headerGroups.at(-1)?.headers ?? [];
692
+ const hasSummaryRow = orderedColumns.some(
693
+ (column) => column.columnDef.footer !== undefined,
694
+ );
695
+
696
+ // "row" mode: the row itself is the selection control, with the modifier
697
+ // conventions of any desktop list — see resolveRowSelectionClick.
698
+ const selectsOnRowClick = features.rowClickSelects;
699
+ const rowIsInteractive =
700
+ selectsOnRowClick || features.highlightRow || onRowClick !== undefined;
701
+
702
+ /**
703
+ * Group rows sit out every row-level gesture.
704
+ *
705
+ * Not squeamishness: TanStack builds a group row on top of its first leaf's
706
+ * record (`constructRow(table, id, leafRows[0].original, …)`), so an
707
+ * `onRowClick` firing here would hand a consumer a real-looking row that is
708
+ * the wrong one — a detail panel would open on an arbitrary child. The same
709
+ * goes for the highlight, which is what a detail panel follows.
710
+ *
711
+ * Selecting a group is still possible, through its checkbox, which resolves
712
+ * to the descendants rather than to the group itself. See
713
+ * getSelectableRowIds.
714
+ */
715
+ const rowGesturesFor = (row: Row<TMDataGridFeatures, TMDataGridRowData>) =>
716
+ row.getIsGrouped() ? false : rowIsInteractive;
229
717
 
230
- const handleRowActivate = (row: Row<TMDataGridFeatures, TMDataGridRowData>) => {
231
- if (selectsOnRowClick && row.getCanSelect()) row.toggleSelected();
718
+ const handleRowActivate = (
719
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
720
+ modifiers: { toggle: boolean; extend: boolean },
721
+ ) => {
722
+ if (selectsOnRowClick && row.getCanSelect()) {
723
+ const resolved = resolveRowSelectionClick({
724
+ rows,
725
+ rowId: row.id,
726
+ anchorRowId: ui.state.selectionAnchorRowId,
727
+ modifiers,
728
+ selection: table.store.state.rowSelection,
729
+ canReplaceSelection: true,
730
+ });
731
+ table.setRowSelection(resolved.selection);
732
+ ui.actions.setSelectionAnchor(resolved.anchorRowId);
733
+ }
734
+ // Set, never toggle: a second click on the highlighted row must not close
735
+ // the detail panel showing it. Clearing is
736
+ // `ui.actions.setHighlightedRow(null)`.
737
+ if (features.highlightRow) ui.actions.setHighlightedRow(row.id);
232
738
  onRowClick?.(row as unknown as Row<TMDataGridFeatures, TData>);
233
739
  };
234
740
 
741
+ // ---------------------------------------------------------------------------
742
+ // Cell selection
743
+ //
744
+ // Three pieces: the tab stop (which cell the browser can reach), the key
745
+ // handler (which cell the state points at), and the effect (which cell the
746
+ // DOM actually focuses). They are separate because under virtualization the
747
+ // three can disagree for a frame — a move to row 4000 names a cell that has
748
+ // no element yet — and the state is the one that is always right.
749
+ // ---------------------------------------------------------------------------
750
+ const cellSelection = features.cellSelection;
751
+ const cellRangeSelection = features.cellRangeSelection;
752
+
753
+ /**
754
+ * That a move came from inside the grid, and so should take the DOM focus
755
+ * with it.
756
+ *
757
+ * A ref rather than "is the focus in the grid?" asked at the time, because by
758
+ * the time the effect runs the cell the key was pressed on may have unmounted
759
+ * — a PageDown scrolls it away — and the focus with it. The intent survives
760
+ * where the evidence does not.
761
+ */
762
+ const wantsCellFocusRef = useRef(false);
763
+ /** The last position the effect acted on, so it can tell a move from a re-render. */
764
+ const appliedCellRef = useRef<TMDataGridCellPosition | null>(null);
765
+
766
+ // O(n) per keystroke and per render while a cell is focused. Fine at any row
767
+ // count a grid actually holds, and the alternative — an id→index map rebuilt
768
+ // whenever sorting, filtering or grouping changes — costs more than it saves.
769
+ const focusedRowIndex =
770
+ cellSelection && focusedCell !== null
771
+ ? rows.findIndex((row) => row.id === focusedCell.rowId)
772
+ : -1;
773
+ const focusedColumnIndex =
774
+ cellSelection && focusedCell !== null
775
+ ? orderedColumns.findIndex((column) => column.id === focusedCell.columnId)
776
+ : -1;
777
+ const focusedCellIsMounted =
778
+ focusedRowIndex >= 0 &&
779
+ virtualItems.some((item) => item.index === focusedRowIndex);
780
+
781
+ /**
782
+ * The one cell in the body that Tab can reach.
783
+ *
784
+ * The focused cell when it is on screen. When it is not — the grid has never
785
+ * been entered, or the user scrolled the focus away with the wheel — the
786
+ * first mounted cell stands in, so the body always has exactly one tab stop.
787
+ * Without the fallback, scrolling the focused row out of the DOM would take
788
+ * the grid out of the tab order entirely.
789
+ */
790
+ const firstMountedRow = rows[virtualItems[0]?.index ?? -1];
791
+ const tabStopCell: TMDataGridCellPosition | null = !cellSelection
792
+ ? null
793
+ : focusedCellIsMounted && focusedCell !== null
794
+ ? focusedCell
795
+ : firstMountedRow && orderedColumns[0]
796
+ ? { rowId: firstMountedRow.id, columnId: orderedColumns[0].id }
797
+ : null;
798
+
799
+ const isCellAt = (
800
+ position: TMDataGridCellPosition | null,
801
+ rowId: string,
802
+ columnId: string,
803
+ ) => position !== null && position.rowId === rowId && position.columnId === columnId;
804
+
805
+ // The rectangle, resolved from ids to the indices the cells are rendered at.
806
+ // `null` whenever a corner has been filtered or hidden away — the range keeps
807
+ // its ids, so lifting the filter brings the selection back.
808
+ const rangeBounds = resolveRangeBounds({
809
+ range: cellRangeSelection ? cellRange : null,
810
+ rowIndexOf: (rowId) => rows.findIndex((row) => row.id === rowId),
811
+ columnIndexOf: (columnId) =>
812
+ orderedColumns.findIndex((column) => column.id === columnId),
813
+ });
814
+
815
+ /**
816
+ * Extends the rectangle to a cell, or lays a new one-cell rectangle down.
817
+ *
818
+ * The anchor is what tells the two apart: extending keeps it and moves the
819
+ * far corner, starting drops it on the cell under the pointer. Every gesture
820
+ * that selects cells is one of these two.
821
+ */
822
+ const setRangeTo = (
823
+ cell: TMDataGridCellPosition,
824
+ { extend }: { extend: boolean },
825
+ ) => {
826
+ if (!cellRangeSelection) return;
827
+ const anchor = extend ? (cellRange?.anchor ?? cell) : cell;
828
+ ui.actions.setCellRange({ anchor, focus: cell });
829
+ };
830
+
831
+ /**
832
+ * What a copy or an export would take.
833
+ *
834
+ * The rectangle when there is one, and the focused cell on its own when there
835
+ * is not — under `"single"` there never is one, and under `"range"` there is
836
+ * none until the first gesture. Copying the one cell the user is on is what
837
+ * every spreadsheet does, so the two cases collapse into the same block here
838
+ * rather than into two paths through the export code.
839
+ */
840
+ const selectionBounds =
841
+ rangeBounds ??
842
+ (cellSelection && focusedRowIndex >= 0 && focusedColumnIndex >= 0
843
+ ? {
844
+ top: focusedRowIndex,
845
+ bottom: focusedRowIndex,
846
+ left: focusedColumnIndex,
847
+ right: focusedColumnIndex,
848
+ }
849
+ : null);
850
+ const selectedCellCount = boundsCellCount(selectionBounds);
851
+ /**
852
+ * How many of the selected columns have anything to export.
853
+ *
854
+ * The generated lanes are part of the selection — they take the tint and the
855
+ * outline, so the block stays a rectangle and the keyboard still reaches the
856
+ * checkbox — but they hold controls rather than values. A rectangle covering
857
+ * only those has nothing to copy, and the items that would say otherwise are
858
+ * disabled rather than quietly doing nothing.
859
+ */
860
+ const selectedDataColumnCount =
861
+ selectionBounds === null
862
+ ? 0
863
+ : orderedColumns
864
+ .slice(selectionBounds.left, selectionBounds.right + 1)
865
+ .filter((column) => !isControlColumn(column.id)).length;
866
+
867
+ const exportOptions = { ...DEFAULT_CELL_EXPORT_OPTIONS, ...cellExport };
868
+ // Sticky across menus, the way a checkbox in a dialog is: a user who exports
869
+ // with headers once almost always wants them the next time too.
870
+ const [exportIncludesHeaders, setExportIncludesHeaders] = useState(
871
+ exportOptions.includeHeaders,
872
+ );
873
+ const buildSelectionMatrix = (includeHeaders: boolean) =>
874
+ selectionBounds === null
875
+ ? []
876
+ : buildCellMatrix({
877
+ rows,
878
+ columns: orderedColumns,
879
+ bounds: selectionBounds,
880
+ includeHeaders,
881
+ decimalComma: exportOptions.decimalComma,
882
+ });
883
+
884
+ /**
885
+ * Ctrl+C. Values only, no header row — Excel's own copy does not include one
886
+ * either, and a header pasted into the middle of a sheet is a row of text
887
+ * where numbers were expected. The export menu is where headers are offered,
888
+ * because a file is a document and a clipboard is a fragment.
889
+ */
890
+ const copySelection = async () => {
891
+ const matrix = buildSelectionMatrix(false);
892
+ if (matrix.length === 0) return;
893
+ await writeClipboardText(toClipboardText(matrix));
894
+ };
895
+
896
+ /**
897
+ * Whether a drag is laying down a rectangle right now.
898
+ *
899
+ * State rather than a ref, because the cells only listen for `mouseenter`
900
+ * while it is true — one re-render at each end of the gesture buys a body
901
+ * that is not running a handler per cell the rest of the time.
902
+ */
903
+ const [isDraggingRange, setIsDraggingRange] = useState(false);
904
+
905
+ // The drag ends wherever the button comes up, which is routinely outside the
906
+ // grid — over the scrollbar, or past the window edge. Only the window hears
907
+ // that, so only the window can end it.
908
+ useEffect(() => {
909
+ if (!isDraggingRange) return;
910
+ const stop = () => setIsDraggingRange(false);
911
+ window.addEventListener("mouseup", stop);
912
+ return () => window.removeEventListener("mouseup", stop);
913
+ }, [isDraggingRange]);
914
+
915
+ const startRangeDrag = (
916
+ position: TMDataGridCellPosition,
917
+ { extend }: { extend: boolean },
918
+ ) => {
919
+ ui.actions.setFocusedCell(position);
920
+ setRangeTo(position, { extend });
921
+ // A Shift+click is a single act, not the start of one — there is nothing
922
+ // left to drag once the far corner has been named.
923
+ if (!extend) setIsDraggingRange(true);
924
+ };
925
+
926
+ const exportSelectionCsv = (includeHeaders: boolean) => {
927
+ const matrix = buildSelectionMatrix(includeHeaders);
928
+ if (matrix.length === 0) return;
929
+ downloadTextFile({
930
+ fileName: `${exportOptions.fileName}.csv`,
931
+ text: toExcelCsv(matrix, { separator: exportOptions.separator }),
932
+ });
933
+ };
934
+
935
+ /**
936
+ * Moves the focus, and takes the selection with it.
937
+ *
938
+ * `extend` is the Shift key: held, the rectangle grows from wherever it was
939
+ * anchored; released, it collapses onto the cell just moved to. Collapsing
940
+ * rather than clearing keeps the invariant the copy path relies on — the
941
+ * selection is always the block Ctrl+C would take, even when that block is
942
+ * one cell.
943
+ */
944
+ const moveFocusedCell = (
945
+ coords: { rowIndex: number; columnIndex: number },
946
+ { extend }: { extend: boolean } = { extend: false },
947
+ ) => {
948
+ const row = rows[coords.rowIndex];
949
+ const column = orderedColumns[coords.columnIndex];
950
+ if (!row || !column) return;
951
+ const cell = { rowId: row.id, columnId: column.id };
952
+ // The state moves; the effect below is what makes the DOM follow, because
953
+ // the element for an off-screen row does not exist yet to be focused.
954
+ wantsCellFocusRef.current = true;
955
+ ui.actions.setFocusedCell(cell);
956
+ setRangeTo(cell, { extend });
957
+ };
958
+
959
+ /**
960
+ * Space, from a cell. Checkbox semantics rather than click semantics: it adds
961
+ * to the selection instead of replacing it, so a keyboard user builds one the
962
+ * way they would by ticking boxes — which is what the key has taken over from.
963
+ *
964
+ * Group rows work because the resolver expands a tick to the descendants they
965
+ * stand for. A grid with no selection at all falls back to the highlight,
966
+ * which is the only thing "pick this row" can mean there.
967
+ */
968
+ const selectRowFromCell = (
969
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
970
+ { extend }: { extend: boolean },
971
+ ) => {
972
+ if (features.rowSelection && getSelectableRowIds(row).length > 0) {
973
+ const resolved = resolveRowSelectionClick({
974
+ rows,
975
+ rowId: row.id,
976
+ anchorRowId: ui.state.selectionAnchorRowId,
977
+ modifiers: { toggle: !extend, extend },
978
+ selection: table.store.state.rowSelection,
979
+ canReplaceSelection: false,
980
+ });
981
+ table.setRowSelection(resolved.selection);
982
+ ui.actions.setSelectionAnchor(resolved.anchorRowId);
983
+ return;
984
+ }
985
+ if (features.highlightRow && !row.getIsGrouped()) {
986
+ ui.actions.setHighlightedRow(row.id);
987
+ }
988
+ };
989
+
990
+ /**
991
+ * One handler on the grid rather than one per cell: the body mounts a few
992
+ * hundred cells and remounts them on every scroll frame, and this closes over
993
+ * `rows` and `orderedColumns` — which every cell would otherwise have to be
994
+ * handed just in case it is the one that gets a key.
995
+ *
996
+ * The cell it is about comes off `event.target`, which is where the
997
+ * coordinate attributes are for.
998
+ */
999
+ const handleGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
1000
+ const target = event.target as HTMLElement;
1001
+
1002
+ // Focus is on something inside a cell — a checkbox, a link, an editor. Its
1003
+ // keys are its own; the one the grid still answers is the way back out.
1004
+ if (target.dataset.cell !== "true") {
1005
+ if (event.key !== "Escape") return;
1006
+ const cellElement = target.closest<HTMLElement>('[data-cell="true"]');
1007
+ if (cellElement === null) return;
1008
+ event.preventDefault();
1009
+ cellElement.focus();
1010
+ return;
1011
+ }
1012
+
1013
+ const { rowId, columnId } = target.dataset;
1014
+ if (rowId === undefined || columnId === undefined) return;
1015
+ const rowIndex = rows.findIndex((row) => row.id === rowId);
1016
+ const columnIndex = orderedColumns.findIndex(
1017
+ (column) => column.id === columnId,
1018
+ );
1019
+ if (rowIndex < 0 || columnIndex < 0) return;
1020
+
1021
+ // Ctrl+C over the selection. Claimed rather than left to the browser: the
1022
+ // native copy of a grid whose rows are mostly not in the DOM would take the
1023
+ // twenty that happen to be mounted, in whatever shape the CSS left them.
1024
+ if (
1025
+ (event.ctrlKey || event.metaKey) &&
1026
+ (event.key === "c" || event.key === "C")
1027
+ ) {
1028
+ if (selectionBounds === null) return;
1029
+ event.preventDefault();
1030
+ void copySelection();
1031
+ return;
1032
+ }
1033
+
1034
+ // Step into the cell. The pair Excel and AG Grid both use, and the pair the
1035
+ // editor will take over: F2 has never meant anything else, and Enter is
1036
+ // what a keyboard user reaches for. Nothing to step into leaves the key
1037
+ // alone rather than swallowing it.
1038
+ if (event.key === "Enter" || event.key === "F2") {
1039
+ const interactive = target.querySelector<HTMLElement>(FOCUSABLE_IN_CELL);
1040
+ if (interactive === null) return;
1041
+ event.preventDefault();
1042
+ interactive.focus();
1043
+ return;
1044
+ }
1045
+
1046
+ // Escape drops the rectangle back to the one cell, as it drops the marching
1047
+ // ants in a spreadsheet. The focus stays where it is — Escape is about the
1048
+ // selection, not about leaving the grid.
1049
+ if (event.key === "Escape") {
1050
+ if (cellRange === null) return;
1051
+ event.preventDefault();
1052
+ setRangeTo({ rowId, columnId }, { extend: false });
1053
+ return;
1054
+ }
1055
+
1056
+ // Space selects the row, from any of its cells.
1057
+ //
1058
+ // Not routed through `handleRowActivate`, which is the *click* path and only
1059
+ // selects in `"row"` mode: with the cell cursor on, the checkbox is no
1060
+ // longer a tab stop (see useCellControlTabIndex), so this is the keyboard's
1061
+ // way to a selection under every mode that has one — checkbox included.
1062
+ if (event.key === " ") {
1063
+ event.preventDefault();
1064
+ const row = rows[rowIndex];
1065
+ if (row) selectRowFromCell(row, { extend: event.shiftKey });
1066
+ return;
1067
+ }
1068
+
1069
+ const next = resolveCellMove({
1070
+ key: event.key,
1071
+ ctrlKey: event.ctrlKey || event.metaKey,
1072
+ from: { rowIndex, columnIndex },
1073
+ rowCount: rows.length,
1074
+ columnCount: orderedColumns.length,
1075
+ pageRows: Math.max(
1076
+ 1,
1077
+ Math.floor((scrollContainerRef.current?.clientHeight ?? 0) / rowHeight) -
1078
+ 1,
1079
+ ),
1080
+ });
1081
+ if (next === null) return;
1082
+ // Claimed even when the move was clamped at an edge: letting an arrow
1083
+ // through at the last row would scroll the body out from under a focus that
1084
+ // did not move.
1085
+ event.preventDefault();
1086
+ if (next.rowIndex === rowIndex && next.columnIndex === columnIndex) return;
1087
+ // Shift extends the rectangle instead of collapsing it — the same key that
1088
+ // extends a text selection, a file list and a spreadsheet range.
1089
+ moveFocusedCell(next, { extend: event.shiftKey && cellRangeSelection });
1090
+ };
1091
+
1092
+ /**
1093
+ * Puts the DOM where the state says, once the state has moved.
1094
+ *
1095
+ * No dependency array, like the page-clamp effect above: the interesting
1096
+ * moment is not a state change but the render after the virtualizer mounted
1097
+ * the row that was scrolled to, and a scroll is not in any dependency list.
1098
+ * Every path out of here is a ref comparison, so the cost of running it on
1099
+ * each frame is nil.
1100
+ */
1101
+ useEffect(() => {
1102
+ if (!cellSelection) return;
1103
+ const container = scrollContainerRef.current;
1104
+ if (container === null) return;
1105
+
1106
+ if (focusedCell !== appliedCellRef.current) {
1107
+ appliedCellRef.current = focusedCell;
1108
+ // Bring the row on screen first — this is what makes a cell that is not
1109
+ // mounted focusable at all, one frame later.
1110
+ if (focusedRowIndex >= 0) {
1111
+ virtualizer.scrollToIndex(focusedRowIndex, { align: "auto" });
1112
+ }
1113
+ }
1114
+ if (focusedCell === null) {
1115
+ wantsCellFocusRef.current = false;
1116
+ return;
1117
+ }
1118
+
1119
+ // Never take the focus from elsewhere on the page. A consumer moving the
1120
+ // cell while the user is typing in a form somewhere means "move the ring",
1121
+ // not "move the caret" — the scroll above already did the visible part.
1122
+ if (
1123
+ !wantsCellFocusRef.current &&
1124
+ !container.contains(document.activeElement)
1125
+ ) {
1126
+ return;
1127
+ }
1128
+
1129
+ const cellElement = findCellElement(container, focusedCell);
1130
+ // Still scrolling. The intent stays up, and the next render tries again.
1131
+ if (cellElement === null) return;
1132
+ wantsCellFocusRef.current = false;
1133
+ // Focus is already inside — on the control Enter stepped into. The cell is
1134
+ // where the ring belongs, so there is nothing to move.
1135
+ if (cellElement.contains(document.activeElement)) return;
1136
+
1137
+ // The vertical half is the virtualizer's, and it has already been asked.
1138
+ cellElement.focus({ preventScroll: true });
1139
+ if (layoutFor(focusedCell.columnId).pinnedAt === false) {
1140
+ keepColumnInView({
1141
+ container,
1142
+ cellElement,
1143
+ leftPinnedWidth: leftOffset,
1144
+ rightPinnedWidth: rightOffset,
1145
+ });
1146
+ }
1147
+ });
1148
+
1149
+ // Which row's context menu is open, and which cell it was opened from.
1150
+ const [contextMenuTarget, setContextMenuTarget] = useState<{
1151
+ rowId: string;
1152
+ columnId: string | null;
1153
+ } | null>(null);
1154
+ const closeContextMenu = () => setContextMenuTarget(null);
1155
+
1156
+ // Row and cell each record themselves on the way up, so that by the time the
1157
+ // rowgroup's handler opens the menu it knows what was hit. Refs rather than
1158
+ // state: written and read inside the same event, before the render it causes.
1159
+ const contextMenuRowRef = useRef<string | null>(null);
1160
+ const contextMenuColumnRef = useRef<string | null>(null);
1161
+
1162
+ const handleContextMenu = () => {
1163
+ const rowId = contextMenuRowRef.current;
1164
+ const columnId = contextMenuColumnRef.current;
1165
+ contextMenuRowRef.current = null;
1166
+ contextMenuColumnRef.current = null;
1167
+
1168
+ // A right-click inside the selection is about the selection and leaves it
1169
+ // alone; one outside moves it first, so the menu never offers to copy a
1170
+ // block somewhere else on the screen. Spreadsheets and file managers both
1171
+ // behave this way, and the alternative — exporting cells the user cannot
1172
+ // see the outline of — is the kind of surprise that ends in a support call.
1173
+ if (cellSelection && rowId !== null && columnId !== null) {
1174
+ const rowIndex = rows.findIndex((row) => row.id === rowId);
1175
+ const columnIndex = orderedColumns.findIndex(
1176
+ (column) => column.id === columnId,
1177
+ );
1178
+ if (!isWithinBounds(selectionBounds, rowIndex, columnIndex)) {
1179
+ ui.actions.setFocusedCell({ rowId, columnId });
1180
+ setRangeTo({ rowId, columnId }, { extend: false });
1181
+ }
1182
+ }
1183
+
1184
+ setContextMenuTarget(rowId === null ? null : { rowId, columnId });
1185
+ };
1186
+
1187
+ const contextMenuRow =
1188
+ contextMenuTarget === null
1189
+ ? undefined
1190
+ : rows.find((row) => row.id === contextMenuTarget.rowId);
1191
+
1192
+ /**
1193
+ * Built for the open row only, so a consumer's render prop is called once per
1194
+ * opened menu rather than once per mounted row. `null` back from it means
1195
+ * this row has no menu, which is also how the menu stays shut.
1196
+ *
1197
+ * The casts put back the concrete row type the context boundary erased — the
1198
+ * same crossing `onRowClick` makes.
1199
+ */
1200
+ const rowMenuContent =
1201
+ rowContextMenu &&
1202
+ contextMenuTarget &&
1203
+ contextMenuRow &&
1204
+ !contextMenuRow.getIsGrouped()
1205
+ ? rowContextMenu({
1206
+ table: table as unknown as Table<TMDataGridFeatures, TData>,
1207
+ row: contextMenuRow as unknown as Row<TMDataGridFeatures, TData>,
1208
+ cell: (contextMenuRow
1209
+ .getAllCells()
1210
+ .find((cell) => cell.column.id === contextMenuTarget.columnId) ??
1211
+ null) as unknown as Cell<TMDataGridFeatures, TData, unknown> | null,
1212
+ close: closeContextMenu,
1213
+ }) ?? null
1214
+ : null;
1215
+
1216
+ /**
1217
+ * The built-in half of the menu: what to do with the cells that are selected.
1218
+ *
1219
+ * Offered under `"range"` only. Under `"single"` the selection is always the
1220
+ * one cell the user is standing on, and a menu whose every item acts on it
1221
+ * would be a menu about nothing much — Ctrl+C still copies it.
1222
+ */
1223
+ const cellMenuContent =
1224
+ cellRangeSelection && contextMenuTarget !== null && selectionBounds !== null ? (
1225
+ <>
1226
+ <Menu.Label>{labels.cellCount(selectedCellCount)}</Menu.Label>
1227
+ <Menu.Item
1228
+ disabled={selectedDataColumnCount === 0}
1229
+ onClick={() => void copySelection()}
1230
+ >
1231
+ {labels.copy}
1232
+ </Menu.Item>
1233
+ <Menu.Item
1234
+ disabled={selectedDataColumnCount === 0}
1235
+ onClick={() => exportSelectionCsv(exportIncludesHeaders)}
1236
+ >
1237
+ {labels.exportCsv}
1238
+ </Menu.Item>
1239
+ {/* Stays open on click: it is the setting the item above reads, and
1240
+ reopening the menu to change your mind about headers is a worse
1241
+ answer than one that lets you see the box tick. */}
1242
+ <Menu.Item
1243
+ closeMenuOnClick={false}
1244
+ onClick={() => setExportIncludesHeaders((previous) => !previous)}
1245
+ >
1246
+ <Checkbox
1247
+ size="xs"
1248
+ checked={exportIncludesHeaders}
1249
+ label={labels.includeHeaders}
1250
+ // The item owns the click; the box only shows what it did.
1251
+ readOnly
1252
+ styles={{ input: { cursor: "pointer" } }}
1253
+ />
1254
+ </Menu.Item>
1255
+ </>
1256
+ ) : null;
1257
+
1258
+ /**
1259
+ * The two halves, in the order they are about: the cells first, since the
1260
+ * right-click landed on one, then whatever the consumer offers for the row.
1261
+ */
1262
+ const contextMenuContent =
1263
+ cellMenuContent === null && rowMenuContent === null ? null : (
1264
+ <>
1265
+ {cellMenuContent}
1266
+ {cellMenuContent !== null && rowMenuContent !== null && <Menu.Divider />}
1267
+ {rowMenuContent}
1268
+ </>
1269
+ );
1270
+
1271
+ /**
1272
+ * The dropdown is anchored to a fixed viewport point, so it does not travel
1273
+ * with the row when the body scrolls — and under virtualization the row it
1274
+ * belongs to may unmount entirely. Closing is the honest answer, and matches
1275
+ * what every desktop context menu does.
1276
+ */
1277
+ const handleScroll = contextMenuTarget ? closeContextMenu : undefined;
1278
+
235
1279
  return (
236
1280
  <div className={classes.tableWrapper}>
237
- <div ref={scrollContainerRef} className={classes.scrollContainer}>
1281
+ <div
1282
+ ref={scrollContainerRef}
1283
+ className={classes.scrollContainer}
1284
+ // How autosize finds the measurable subtree from a header cell.
1285
+ data-dg-scroll-container
1286
+ onScroll={handleScroll}
1287
+ >
238
1288
  <div
239
- role="table"
1289
+ // A `table` is content; a `grid` is a widget with a keyboard cursor
1290
+ // in it. Which one this is depends on whether cell selection is on,
1291
+ // and saying `grid` without one would promise arrow keys that do
1292
+ // nothing.
1293
+ role={cellSelection ? "grid" : "table"}
1294
+ aria-multiselectable={
1295
+ cellSelection && features.rowSelection && features.multiRowSelection
1296
+ ? true
1297
+ : undefined
1298
+ }
1299
+ onKeyDown={cellSelection ? handleGridKeyDown : undefined}
1300
+ // A drag across cells is a selection gesture, and the browser's own
1301
+ // text selection would follow the pointer through it. Only while the
1302
+ // drag runs, so copying a cell value with the mouse still works.
1303
+ data-range-dragging={isDraggingRange || undefined}
240
1304
  className={classes.grid}
241
1305
  // Virtualization means most rows are not in the DOM, so the counts
242
1306
  // have to be stated rather than inferred from what is rendered. Every
243
1307
  // row carries its aria-rowindex for the same reason.
244
- aria-rowcount={rows.length + headerGroups.length}
1308
+ aria-rowcount={
1309
+ rows.length + headerGroups.length + (hasSummaryRow ? 1 : 0)
1310
+ }
245
1311
  aria-colcount={orderedColumns.length}
246
1312
  style={{ gridTemplateColumns, minWidth: gridMinWidth }}
247
1313
  >
@@ -268,43 +1334,148 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
268
1334
  <div aria-hidden style={{ gridColumn: "1/-1", height: paddingTop }} />
269
1335
  )}
270
1336
 
271
- <div role="rowgroup" style={{ display: "contents" }}>
1337
+ <TMDataGridBodyRowGroup
1338
+ // Under `"range"` the grid has a menu of its own to offer, so the
1339
+ // wrapper goes on even without a consumer's `rowContextMenu`.
1340
+ enabled={rowContextMenu !== undefined || cellRangeSelection}
1341
+ menuContent={contextMenuContent}
1342
+ menuProps={rowContextMenuProps}
1343
+ onContextMenu={handleContextMenu}
1344
+ onClose={closeContextMenu}
1345
+ >
272
1346
  {virtualItems.map((virtualItem) => {
273
1347
  const row = rows[virtualItem.index];
274
1348
  if (!row) return null;
1349
+ const isGroupRow = row.getIsGrouped();
1350
+ const isInteractive = rowGesturesFor(row);
1351
+ // Cell selection takes the body's tab stop off the row and puts
1352
+ // it on a cell — two stops per row would make Tab a way of
1353
+ // walking the grid, which is what the arrow keys are for. Space
1354
+ // on a cell keeps the selection reachable; see handleGridKeyDown.
1355
+ const takesKeyboard =
1356
+ !isGroupRow &&
1357
+ !cellSelection &&
1358
+ (selectsOnRowClick || features.highlightRow);
275
1359
  return (
276
1360
  <div
277
1361
  key={virtualItem.key}
278
1362
  role="row"
279
1363
  aria-rowindex={virtualItem.index + headerGroups.length + 1}
280
1364
  data-testid={`dg-row-${row.id}`}
281
- data-selected={row.getIsSelected()}
282
- // Selection is state; the highlight is a display choice, so
1365
+ // Only measured when details are on: without them every row
1366
+ // is exactly `rowHeight`, and the estimate above is already
1367
+ // the answer — so a plain grid keeps its ResizeObserver-free
1368
+ // body. `measureElement` is a stable instance method and
1369
+ // returns nothing, so it can be the ref itself.
1370
+ //
1371
+ // `data-index` is how the virtualizer maps the observed
1372
+ // element back to its item. The panel is inside this element,
1373
+ // so what gets measured is the row *and* its detail — which
1374
+ // is the whole reason no second virtual item is needed.
1375
+ data-index={renderDetails ? virtualItem.index : undefined}
1376
+ ref={renderDetails ? virtualizer.measureElement : undefined}
1377
+ // The tree's own rows. `data-grouped` rather than a class, to
1378
+ // match how the rest of the row's state is published — and so
1379
+ // a consumer can restyle them without reaching into modules.
1380
+ data-grouped={isGroupRow}
1381
+ data-depth={row.depth}
1382
+ // Gated on the flag, not just on `getIsSelected()`: under
1383
+ // `"highlight"` there is no selection, but `rowSelection` may
1384
+ // still hold ids from before the mode changed — TanStack never
1385
+ // prunes it — and a row must not report itself selected in a
1386
+ // mode where selecting is not a thing.
1387
+ data-selected={features.rowSelection && row.getIsSelected()}
1388
+ // Being selected is state; painting it is a display choice, so
283
1389
  // the two are separate attributes.
1390
+ data-selected-bg={
1391
+ features.rowSelection &&
1392
+ features.showSelectedBackground &&
1393
+ row.getIsSelected()
1394
+ }
1395
+ // The highlighted row is its own concept, so its own attribute
1396
+ // pair — `data-highlighted` / `aria-current` against
1397
+ // `data-selected` / `aria-selected` for selection. Under
1398
+ // `"checkboxAndHighlight"` a row can carry both.
1399
+ //
1400
+ // Not `data-active`: header cells already use that for
1401
+ // sorted-or-filtered, and Mantine puts it on its own controls,
1402
+ // so a consumer styling or querying rows by it would cast far
1403
+ // wider than they meant to.
284
1404
  data-highlighted={
285
- features.highlightSelectedRows && row.getIsSelected()
1405
+ features.highlightRow && row.id === highlightedRowId
1406
+ }
1407
+ // The menu is anchored to the rowgroup, so Mantine's own
1408
+ // `data-expanded` lands there rather than on a row. This is
1409
+ // what says which row the open menu is about.
1410
+ data-context-menu={
1411
+ contextMenuContent !== null &&
1412
+ contextMenuTarget?.rowId === row.id
286
1413
  }
287
- data-selects-on-click={selectsOnRowClick}
1414
+ data-selects-on-click={selectsOnRowClick && !isGroupRow}
288
1415
  aria-selected={
289
1416
  features.rowSelection ? row.getIsSelected() : undefined
290
1417
  }
1418
+ aria-current={
1419
+ features.highlightRow && row.id === highlightedRowId
1420
+ ? true
1421
+ : undefined
1422
+ }
291
1423
  className={classes.bodyRow}
292
1424
  style={{
293
1425
  minHeight: rowHeight,
294
- cursor: rowIsInteractive ? "pointer" : undefined,
1426
+ cursor: isInteractive ? "pointer" : undefined,
295
1427
  }}
296
1428
  // Keyboard parity with the checkbox the row replaces.
297
- tabIndex={selectsOnRowClick ? 0 : undefined}
1429
+ tabIndex={takesKeyboard ? 0 : undefined}
1430
+ // Shift-click extends the browser's text selection across
1431
+ // every row it passes. `user-select: none` would fix it too,
1432
+ // but at the cost of ever copying a cell value, so the smear
1433
+ // is stopped at the one gesture that causes it.
1434
+ onMouseDown={
1435
+ selectsOnRowClick && !isGroupRow
1436
+ ? (event) => {
1437
+ if (event.shiftKey) event.preventDefault();
1438
+ }
1439
+ : undefined
1440
+ }
298
1441
  onClick={
299
- rowIsInteractive ? () => handleRowActivate(row) : undefined
1442
+ isInteractive
1443
+ ? (event) =>
1444
+ handleRowActivate(row, {
1445
+ toggle: event.ctrlKey || event.metaKey,
1446
+ extend: event.shiftKey,
1447
+ })
1448
+ : undefined
300
1449
  }
301
1450
  onKeyDown={
302
- selectsOnRowClick
1451
+ takesKeyboard
303
1452
  ? (event) => {
304
1453
  if (event.key !== " " && event.key !== "Enter") return;
305
1454
  if (event.target !== event.currentTarget) return;
306
1455
  event.preventDefault();
307
- handleRowActivate(row);
1456
+ // Space/Enter toggle rather than replace, so a
1457
+ // keyboard-only user can still build a selection.
1458
+ handleRowActivate(row, {
1459
+ toggle: true,
1460
+ extend: event.shiftKey,
1461
+ });
1462
+ }
1463
+ : undefined
1464
+ }
1465
+ // Bubbles to the rowgroup, where the menu opens — so the row
1466
+ // is recorded before the dropdown is built.
1467
+ //
1468
+ // A group row records itself only for the cell menu, which is
1469
+ // about the cells it covers. `rowContextMenu` is still never
1470
+ // called for one: TanStack builds a group row on its first
1471
+ // child's record, so a render prop reaching for `row.original`
1472
+ // would be handed a real employee that has nothing to do with
1473
+ // the group — see where the content is built. The browser menu
1474
+ // stays suppressed over them either way.
1475
+ onContextMenu={
1476
+ (rowContextMenu && !isGroupRow) || cellRangeSelection
1477
+ ? () => {
1478
+ contextMenuRowRef.current = row.id;
308
1479
  }
309
1480
  : undefined
310
1481
  }
@@ -313,22 +1484,132 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
313
1484
  ...row.getLeftVisibleCells(),
314
1485
  ...row.getCenterVisibleCells(),
315
1486
  ...row.getRightVisibleCells(),
316
- ].map((cell) => (
1487
+ ].map((cell, cellIndex) => (
317
1488
  <TMDataGridBodyCell
318
1489
  key={cell.id}
319
1490
  cell={cell}
320
1491
  rowHeight={rowHeight}
321
1492
  layout={layoutFor(cell.column.id)}
1493
+ // The cells come out in the same left → centre → right
1494
+ // order as `orderedColumns`, which is what lets the index
1495
+ // here stand for the column index everywhere else.
1496
+ nav={
1497
+ cellSelection
1498
+ ? {
1499
+ rowId: row.id,
1500
+ columnIndex: cellIndex + 1,
1501
+ focused: isCellAt(
1502
+ focusedCell,
1503
+ row.id,
1504
+ cell.column.id,
1505
+ ),
1506
+ tabIndex: isCellAt(
1507
+ tabStopCell,
1508
+ row.id,
1509
+ cell.column.id,
1510
+ )
1511
+ ? 0
1512
+ : -1,
1513
+ // "Selected" means "in the block Ctrl+C would
1514
+ // take" — which under `"single"` is the one cell
1515
+ // the user is standing on.
1516
+ selected: cellRangeSelection
1517
+ ? isWithinBounds(
1518
+ rangeBounds,
1519
+ virtualItem.index,
1520
+ cellIndex,
1521
+ )
1522
+ : isCellAt(focusedCell, row.id, cell.column.id),
1523
+ edges: boundsEdges(
1524
+ rangeBounds,
1525
+ virtualItem.index,
1526
+ cellIndex,
1527
+ ),
1528
+ }
1529
+ : undefined
1530
+ }
1531
+ onFocus={
1532
+ cellSelection
1533
+ ? () =>
1534
+ ui.actions.setFocusedCell({
1535
+ rowId: row.id,
1536
+ columnId: cell.column.id,
1537
+ })
1538
+ : undefined
1539
+ }
1540
+ onMouseDown={
1541
+ cellRangeSelection
1542
+ ? (event) => {
1543
+ // Right-click is the menu's; the middle button is
1544
+ // the browser's.
1545
+ if (event.button !== 0) return;
1546
+ // Shift+click would otherwise smear the browser's
1547
+ // own text selection across everything between
1548
+ // the two cells.
1549
+ if (event.shiftKey) event.preventDefault();
1550
+ wantsCellFocusRef.current = true;
1551
+ startRangeDrag(
1552
+ { rowId: row.id, columnId: cell.column.id },
1553
+ { extend: event.shiftKey },
1554
+ );
1555
+ }
1556
+ : undefined
1557
+ }
1558
+ // Only bound while a drag is running, so the other 99% of
1559
+ // the time the body carries no pointer handlers per cell.
1560
+ onMouseEnter={
1561
+ cellRangeSelection && isDraggingRange
1562
+ ? () =>
1563
+ setRangeTo(
1564
+ { rowId: row.id, columnId: cell.column.id },
1565
+ { extend: true },
1566
+ )
1567
+ : undefined
1568
+ }
1569
+ onContextMenu={
1570
+ rowContextMenu || cellRangeSelection
1571
+ ? () => {
1572
+ contextMenuColumnRef.current = cell.column.id;
1573
+ }
1574
+ : undefined
1575
+ }
322
1576
  />
323
1577
  ))}
1578
+
1579
+ {/* A second grid row inside the row element, on the same
1580
+ subgrid tracks — which is what lets one measurement cover
1581
+ both. A cell spanning every column rather than a row of
1582
+ its own, so the row keeps holding exactly one row's worth
1583
+ of content and `aria-rowcount` still counts records. */}
1584
+ {showsDetails(row) && (
1585
+ <div
1586
+ role="cell"
1587
+ aria-colspan={orderedColumns.length}
1588
+ data-testid={`dg-details-${row.id}`}
1589
+ className={classes.detailsCell}
1590
+ // The row underneath may select, highlight or open a
1591
+ // context menu. A panel is content, not part of that
1592
+ // gesture surface — and stopping the right-click here is
1593
+ // what leaves the browser's own menu working for a link
1594
+ // or an input inside it.
1595
+ onClick={(event) => event.stopPropagation()}
1596
+ onContextMenu={(event) => event.stopPropagation()}
1597
+ >
1598
+ {/* No cast back to `TData`: the context erased the row
1599
+ type, and the renderer reaching this point was typed
1600
+ against the consumer's own at the `useTMDataGrid`
1601
+ call. */}
1602
+ {renderDetails?.({ table, row })}
1603
+ </div>
1604
+ )}
324
1605
  </div>
325
1606
  );
326
1607
  })}
327
- </div>
1608
+ </TMDataGridBodyRowGroup>
328
1609
 
329
1610
  {isEmpty && loading === true && (
330
1611
  <div role="row" className={classes.messageRow}>
331
- <Loader size="lg" />
1612
+ <Loader size="lg" aria-label={labels.loading} />
332
1613
  </div>
333
1614
  )}
334
1615
 
@@ -347,6 +1628,62 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
347
1628
  style={{ gridColumn: "1/-1", height: paddingBottom }}
348
1629
  />
349
1630
  )}
1631
+
1632
+ {/* Sticky along the bottom edge, the way the header is along the
1633
+ top. `flexRender` with the header context, which is what a
1634
+ TanStack `footer` renderer is written against. */}
1635
+ {hasSummaryRow && (
1636
+ <div
1637
+ role="row"
1638
+ aria-rowindex={rows.length + headerGroups.length + 1}
1639
+ data-testid="dg-summary-row"
1640
+ className={classes.summaryRow}
1641
+ >
1642
+ {leafHeaders.map((header) => {
1643
+ const layout = layoutFor(header.column.id);
1644
+ return (
1645
+ <div
1646
+ key={header.id}
1647
+ role="cell"
1648
+ data-column-id={header.column.id}
1649
+ data-align={getColumnAlign(header.column)}
1650
+ data-control-column={isControlColumn(header.column.id)}
1651
+ className={[
1652
+ classes.summaryCell,
1653
+ layout.isBoundary && layout.pinnedAt === "left"
1654
+ ? sticky.stickyLeft
1655
+ : "",
1656
+ layout.isBoundary && layout.pinnedAt === "right"
1657
+ ? sticky.stickyRight
1658
+ : "",
1659
+ ]
1660
+ .filter(Boolean)
1661
+ .join(" ")}
1662
+ style={{
1663
+ minHeight: rowHeight,
1664
+ left:
1665
+ layout.pinnedAt === "left" ? layout.offset : undefined,
1666
+ right:
1667
+ layout.pinnedAt === "right" ? layout.offset : undefined,
1668
+ position: layout.pinnedAt ? "sticky" : undefined,
1669
+ zIndex: layout.pinnedAt
1670
+ ? "var(--dg-z-summary-pinned-cell, 5)"
1671
+ : undefined,
1672
+ }}
1673
+ >
1674
+ <span className={classes.cellContent}>
1675
+ {header.isPlaceholder
1676
+ ? null
1677
+ : flexRender(
1678
+ header.column.columnDef.footer,
1679
+ header.getContext(),
1680
+ )}
1681
+ </span>
1682
+ </div>
1683
+ );
1684
+ })}
1685
+ </div>
1686
+ )}
350
1687
  </div>
351
1688
  </div>
352
1689