@jielga/tmdatagrid 0.3.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 (43) hide show
  1. package/dist/index.d.ts +947 -64
  2. package/dist/index.js +2474 -1423
  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 +1 -1
  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 +10 -0
  13. package/src/tmdatagrid/components/TMDataGrid.tsx +23 -1
  14. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +5 -5
  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.tsx +111 -33
  18. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +5 -4
  19. package/src/tmdatagrid/components/TMDataGridFooter.tsx +11 -19
  20. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +23 -11
  21. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -3
  22. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +181 -52
  23. package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
  24. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +23 -13
  25. package/src/tmdatagrid/components/TMDataGridTable.module.css +127 -6
  26. package/src/tmdatagrid/components/TMDataGridTable.tsx +1003 -30
  27. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +30 -7
  28. package/src/tmdatagrid/components/icons.ts +1 -0
  29. package/src/tmdatagrid/components/sticky.module.css +77 -16
  30. package/src/tmdatagrid/core/autosize.ts +125 -0
  31. package/src/tmdatagrid/core/capabilities.ts +41 -0
  32. package/src/tmdatagrid/core/cellExport.ts +321 -0
  33. package/src/tmdatagrid/core/cellNavigation.ts +103 -0
  34. package/src/tmdatagrid/core/cellRange.ts +118 -0
  35. package/src/tmdatagrid/core/columnOptions.ts +123 -0
  36. package/src/tmdatagrid/core/columnUtils.ts +18 -0
  37. package/src/tmdatagrid/core/expanding.ts +93 -0
  38. package/src/tmdatagrid/core/filterOperators.ts +176 -15
  39. package/src/tmdatagrid/core/labels.ts +211 -0
  40. package/src/tmdatagrid/core/labelsSv.ts +108 -0
  41. package/src/tmdatagrid/core/summary.ts +35 -0
  42. package/src/tmdatagrid/index.ts +70 -0
  43. package/src/tmdatagrid/useTMDataGrid.tsx +328 -12
@@ -1,4 +1,4 @@
1
- import { Loader, Menu, type MenuProps, 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,
@@ -9,6 +9,7 @@ import {
9
9
  import { useSelector } from "@tanstack/react-store";
10
10
  import { useVirtualizer } from "@tanstack/react-virtual";
11
11
  import {
12
+ type KeyboardEvent,
12
13
  type MouseEvent,
13
14
  type ReactNode,
14
15
  useCallback,
@@ -21,13 +22,33 @@ import sticky from "./sticky.module.css";
21
22
  import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
22
23
  import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
23
24
  import { TMDataGridHeaderCell } from "./TMDataGridHeaderCell";
24
- 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";
25
46
  import {
26
47
  getDisplayedRows,
48
+ getSelectableRowIds,
27
49
  resolveRowSelectionClick,
28
50
  } from "../core/rowSelection";
29
51
  import { SearchIcon } from "./icons";
30
- import { SELECT_COLUMN_ID } from "./TMDataGridSelectColumn";
31
52
  import type { TMDataGridFeatures } from "../useTMDataGrid";
32
53
 
33
54
  /** Where a column sits relative to the pinned regions, resolved once per render. */
@@ -45,6 +66,66 @@ const UNPINNED_LAYOUT: TMDataGridColumnLayout = {
45
66
  isBoundary: false,
46
67
  };
47
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
+
48
129
  /**
49
130
  * What a cell shows once grouping is in play.
50
131
  *
@@ -81,24 +162,87 @@ function renderCellContent(
81
162
  return flexRender(cell.column.columnDef.cell, cell.getContext());
82
163
  }
83
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
+
84
190
  function TMDataGridBodyCell({
85
191
  cell,
86
192
  rowHeight,
87
193
  layout,
194
+ nav,
195
+ onFocus,
196
+ onMouseDown,
197
+ onMouseEnter,
88
198
  onContextMenu,
89
199
  }: {
90
200
  cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
91
201
  rowHeight: number;
92
202
  layout: TMDataGridColumnLayout;
203
+ nav?: TMDataGridCellNav;
204
+ onFocus?: () => void;
205
+ onMouseDown?: (event: MouseEvent<HTMLDivElement>) => void;
206
+ onMouseEnter?: () => void;
93
207
  onContextMenu?: (event: MouseEvent<HTMLDivElement>) => void;
94
208
  }) {
95
209
  return (
96
210
  <div
97
- 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}
98
242
  data-align={getColumnAlign(cell.column)}
99
- // The checkbox lane is a fixed track, so it cannot take the cell padding
100
- // the scale grows for text. See TMDataGridTable.module.css.
101
- data-select-column={cell.column.id === SELECT_COLUMN_ID}
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)}
102
246
  onContextMenu={onContextMenu}
103
247
  className={[
104
248
  classes.bodyCell,
@@ -113,8 +257,21 @@ function TMDataGridBodyCell({
113
257
  minHeight: rowHeight,
114
258
  left: layout.pinnedAt === "left" ? layout.offset : undefined,
115
259
  right: layout.pinnedAt === "right" ? layout.offset : undefined,
116
- position: layout.pinnedAt ? "sticky" : undefined,
117
- 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,
118
275
  }}
119
276
  >
120
277
  <span className={classes.cellContent}>{renderCellContent(cell)}</span>
@@ -246,6 +403,31 @@ export type TMDataGridTableProps<TData extends RowData> = {
246
403
  * `position`, `transitionProps` and the rest. Its open state is the grid's.
247
404
  */
248
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;
249
431
  };
250
432
 
251
433
  /**
@@ -258,17 +440,33 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
258
440
  onRowClick,
259
441
  rowContextMenu,
260
442
  rowContextMenuProps,
443
+ cellExport,
444
+ onReachEnd,
445
+ reachEndThreshold = 10,
261
446
  }: TMDataGridTableProps<TData>) {
262
- const { table, ui, features, rowHeight, controlSize } =
263
- useTMDataGridContext();
447
+ const {
448
+ table,
449
+ ui,
450
+ features,
451
+ labels,
452
+ rowHeight,
453
+ controlSize,
454
+ renderDetails,
455
+ renderDetailsEstHeight,
456
+ overscan,
457
+ } = useTMDataGridContext();
264
458
 
265
459
  // The body depends on every state slice (sorting, filters, paging, sizing,
266
460
  // visibility, selection), so it subscribes to the whole table store.
267
461
  useSelector(table.store);
268
462
  // The active row lives in the chrome store, so it needs its own subscription.
269
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);
270
468
 
271
- const { loading, noResultsLabel = "No rows match your filters" } =
469
+ const { loading, noResultsLabel = labels.noResults } =
272
470
  table.options.meta ?? {};
273
471
 
274
472
  const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -277,6 +475,36 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
277
475
  // the rows as delivered, so the same branch serves server paging.
278
476
  const rows = getDisplayedRows(table, features);
279
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
+ });
507
+
280
508
  // A persisted pageIndex can outlive the data that produced it; TanStack only
281
509
  // auto-resets on live filter/sort/data changes, not on restored state. The
282
510
  // guard makes the dependency-free effect idempotent.
@@ -291,9 +519,35 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
291
519
  const virtualizer = useVirtualizer({
292
520
  count: rows.length,
293
521
  getScrollElement: () => scrollContainerRef.current,
294
- 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)),
295
549
  getItemKey: useCallback((index: number) => rows[index]?.id ?? index, [rows]),
296
- overscan: 6,
550
+ overscan,
297
551
  initialRect: { height: 600, width: 1200 },
298
552
  });
299
553
 
@@ -302,6 +556,32 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
302
556
  const paddingBottom =
303
557
  virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
304
558
 
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
+
305
585
  // The *Visible* variants throughout: `getLeftLeafColumns()` and friends
306
586
  // include hidden columns, while the cells below come from
307
587
  // `row.getLeftVisibleCells()`, which does not. Mixing the two would lay down
@@ -401,6 +681,18 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
401
681
 
402
682
  const isEmpty = rows.length === 0;
403
683
 
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
+
404
696
  // "row" mode: the row itself is the selection control, with the modifier
405
697
  // conventions of any desktop list — see resolveRowSelectionClick.
406
698
  const selectsOnRowClick = features.rowClickSelects;
@@ -446,6 +738,414 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
446
738
  onRowClick?.(row as unknown as Row<TMDataGridFeatures, TData>);
447
739
  };
448
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
+
449
1149
  // Which row's context menu is open, and which cell it was opened from.
450
1150
  const [contextMenuTarget, setContextMenuTarget] = useState<{
451
1151
  rowId: string;
@@ -464,6 +1164,23 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
464
1164
  const columnId = contextMenuColumnRef.current;
465
1165
  contextMenuRowRef.current = null;
466
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
+
467
1184
  setContextMenuTarget(rowId === null ? null : { rowId, columnId });
468
1185
  };
469
1186
 
@@ -480,8 +1197,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
480
1197
  * The casts put back the concrete row type the context boundary erased — the
481
1198
  * same crossing `onRowClick` makes.
482
1199
  */
483
- const contextMenuContent =
484
- rowContextMenu && contextMenuTarget && contextMenuRow
1200
+ const rowMenuContent =
1201
+ rowContextMenu &&
1202
+ contextMenuTarget &&
1203
+ contextMenuRow &&
1204
+ !contextMenuRow.getIsGrouped()
485
1205
  ? rowContextMenu({
486
1206
  table: table as unknown as Table<TMDataGridFeatures, TData>,
487
1207
  row: contextMenuRow as unknown as Row<TMDataGridFeatures, TData>,
@@ -493,6 +1213,61 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
493
1213
  }) ?? null
494
1214
  : null;
495
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
+
496
1271
  /**
497
1272
  * The dropdown is anchored to a fixed viewport point, so it does not travel
498
1273
  * with the row when the body scrolls — and under virtualization the row it
@@ -506,15 +1281,33 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
506
1281
  <div
507
1282
  ref={scrollContainerRef}
508
1283
  className={classes.scrollContainer}
1284
+ // How autosize finds the measurable subtree from a header cell.
1285
+ data-dg-scroll-container
509
1286
  onScroll={handleScroll}
510
1287
  >
511
1288
  <div
512
- 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}
513
1304
  className={classes.grid}
514
1305
  // Virtualization means most rows are not in the DOM, so the counts
515
1306
  // have to be stated rather than inferred from what is rendered. Every
516
1307
  // row carries its aria-rowindex for the same reason.
517
- aria-rowcount={rows.length + headerGroups.length}
1308
+ aria-rowcount={
1309
+ rows.length + headerGroups.length + (hasSummaryRow ? 1 : 0)
1310
+ }
518
1311
  aria-colcount={orderedColumns.length}
519
1312
  style={{ gridTemplateColumns, minWidth: gridMinWidth }}
520
1313
  >
@@ -542,7 +1335,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
542
1335
  )}
543
1336
 
544
1337
  <TMDataGridBodyRowGroup
545
- enabled={rowContextMenu !== undefined}
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}
546
1341
  menuContent={contextMenuContent}
547
1342
  menuProps={rowContextMenuProps}
548
1343
  onContextMenu={handleContextMenu}
@@ -553,14 +1348,32 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
553
1348
  if (!row) return null;
554
1349
  const isGroupRow = row.getIsGrouped();
555
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.
556
1355
  const takesKeyboard =
557
- !isGroupRow && (selectsOnRowClick || features.highlightRow);
1356
+ !isGroupRow &&
1357
+ !cellSelection &&
1358
+ (selectsOnRowClick || features.highlightRow);
558
1359
  return (
559
1360
  <div
560
1361
  key={virtualItem.key}
561
1362
  role="row"
562
1363
  aria-rowindex={virtualItem.index + headerGroups.length + 1}
563
1364
  data-testid={`dg-row-${row.id}`}
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}
564
1377
  // The tree's own rows. `data-grouped` rather than a class, to
565
1378
  // match how the rest of the row's state is published — and so
566
1379
  // a consumer can restyle them without reaching into modules.
@@ -652,14 +1465,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
652
1465
  // Bubbles to the rowgroup, where the menu opens — so the row
653
1466
  // is recorded before the dropdown is built.
654
1467
  //
655
- // Group rows record nothing, which leaves the ref null and the
656
- // menu shut. Same reason they sit out `onRowClick`: TanStack
657
- // builds a group row on its first child's record, so a render
658
- // prop reaching for `row.original` would be handed a real
659
- // employee that has nothing to do with the group. The browser
660
- // menu stays suppressed over them either way.
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.
661
1475
  onContextMenu={
662
- rowContextMenu && !isGroupRow
1476
+ (rowContextMenu && !isGroupRow) || cellRangeSelection
663
1477
  ? () => {
664
1478
  contextMenuRowRef.current = row.id;
665
1479
  }
@@ -670,14 +1484,90 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
670
1484
  ...row.getLeftVisibleCells(),
671
1485
  ...row.getCenterVisibleCells(),
672
1486
  ...row.getRightVisibleCells(),
673
- ].map((cell) => (
1487
+ ].map((cell, cellIndex) => (
674
1488
  <TMDataGridBodyCell
675
1489
  key={cell.id}
676
1490
  cell={cell}
677
1491
  rowHeight={rowHeight}
678
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
+ }
679
1569
  onContextMenu={
680
- rowContextMenu
1570
+ rowContextMenu || cellRangeSelection
681
1571
  ? () => {
682
1572
  contextMenuColumnRef.current = cell.column.id;
683
1573
  }
@@ -685,6 +1575,33 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
685
1575
  }
686
1576
  />
687
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
+ )}
688
1605
  </div>
689
1606
  );
690
1607
  })}
@@ -692,7 +1609,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
692
1609
 
693
1610
  {isEmpty && loading === true && (
694
1611
  <div role="row" className={classes.messageRow}>
695
- <Loader size="lg" />
1612
+ <Loader size="lg" aria-label={labels.loading} />
696
1613
  </div>
697
1614
  )}
698
1615
 
@@ -711,6 +1628,62 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
711
1628
  style={{ gridColumn: "1/-1", height: paddingBottom }}
712
1629
  />
713
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
+ )}
714
1687
  </div>
715
1688
  </div>
716
1689