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