@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.
- package/dist/index.d.ts +947 -64
- package/dist/index.js +2474 -1423
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/skills/columns/SKILL.md +1 -1
- package/skills/features/SKILL.md +61 -11
- package/skills/getting-started/SKILL.md +1 -1
- package/skills/options/SKILL.md +12 -2
- package/skills/server-side/SKILL.md +1 -1
- package/src/tmdatagrid/TMDataGridContext.ts +26 -0
- package/src/tmdatagrid/components/TMDataGrid.module.css +10 -0
- package/src/tmdatagrid/components/TMDataGrid.tsx +23 -1
- package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +5 -5
- package/src/tmdatagrid/components/TMDataGridDetailsColumn.module.css +33 -0
- package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +160 -0
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +111 -33
- package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +5 -4
- package/src/tmdatagrid/components/TMDataGridFooter.tsx +11 -19
- package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +23 -11
- package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -3
- package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +181 -52
- package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
- package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +23 -13
- package/src/tmdatagrid/components/TMDataGridTable.module.css +127 -6
- package/src/tmdatagrid/components/TMDataGridTable.tsx +1003 -30
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +30 -7
- package/src/tmdatagrid/components/icons.ts +1 -0
- package/src/tmdatagrid/components/sticky.module.css +77 -16
- package/src/tmdatagrid/core/autosize.ts +125 -0
- package/src/tmdatagrid/core/capabilities.ts +41 -0
- package/src/tmdatagrid/core/cellExport.ts +321 -0
- package/src/tmdatagrid/core/cellNavigation.ts +103 -0
- package/src/tmdatagrid/core/cellRange.ts +118 -0
- package/src/tmdatagrid/core/columnOptions.ts +123 -0
- package/src/tmdatagrid/core/columnUtils.ts +18 -0
- package/src/tmdatagrid/core/expanding.ts +93 -0
- package/src/tmdatagrid/core/filterOperators.ts +176 -15
- package/src/tmdatagrid/core/labels.ts +211 -0
- package/src/tmdatagrid/core/labelsSv.ts +108 -0
- package/src/tmdatagrid/core/summary.ts +35 -0
- package/src/tmdatagrid/index.ts +70 -0
- 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
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
data-
|
|
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
|
-
|
|
117
|
-
|
|
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 {
|
|
263
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
484
|
-
rowContextMenu &&
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
//
|
|
656
|
-
//
|
|
657
|
-
// builds a group row on its first
|
|
658
|
-
// prop reaching for `row.original`
|
|
659
|
-
// employee that has nothing to do with
|
|
660
|
-
//
|
|
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
|
|