@jielga/tmdatagrid 0.2.0 → 0.3.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 +397 -37
- package/dist/index.js +1550 -978
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/skills/columns/SKILL.md +1 -1
- package/skills/features/SKILL.md +1 -1
- package/skills/getting-started/SKILL.md +41 -6
- package/skills/options/SKILL.md +1 -1
- package/skills/server-side/SKILL.md +1 -1
- package/src/tmdatagrid/components/TMDataGrid.module.css +11 -1
- package/src/tmdatagrid/components/TMDataGrid.tsx +6 -0
- package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +7 -1
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +15 -0
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +87 -10
- package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
- package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +135 -0
- package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
- package/src/tmdatagrid/components/TMDataGridFooter.tsx +70 -42
- package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
- package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +147 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +6 -0
- package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +77 -1
- package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +83 -6
- package/src/tmdatagrid/components/TMDataGridTable.module.css +38 -4
- package/src/tmdatagrid/components/TMDataGridTable.tsx +396 -32
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +3 -0
- package/src/tmdatagrid/components/icons.ts +5 -0
- package/src/tmdatagrid/core/capabilities.ts +81 -11
- package/src/tmdatagrid/core/filterOperators.ts +25 -0
- package/src/tmdatagrid/core/grouping.ts +21 -0
- package/src/tmdatagrid/core/persistence.ts +12 -0
- package/src/tmdatagrid/core/rowSelection.ts +202 -0
- package/src/tmdatagrid/index.ts +26 -2
- package/src/tmdatagrid/useTMDataGrid.tsx +240 -35
|
@@ -1,20 +1,33 @@
|
|
|
1
|
-
import { Loader, Text } from "@mantine/core";
|
|
1
|
+
import { 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 MouseEvent,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
useCallback,
|
|
15
|
+
useEffect,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
} from "react";
|
|
11
19
|
import classes from "./TMDataGridTable.module.css";
|
|
12
20
|
import sticky from "./sticky.module.css";
|
|
13
21
|
import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
|
|
14
22
|
import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
|
|
15
23
|
import { TMDataGridHeaderCell } from "./TMDataGridHeaderCell";
|
|
16
24
|
import { getColumnAlign } from "../core/columnUtils";
|
|
25
|
+
import {
|
|
26
|
+
getDisplayedRows,
|
|
27
|
+
resolveRowSelectionClick,
|
|
28
|
+
} from "../core/rowSelection";
|
|
17
29
|
import { SearchIcon } from "./icons";
|
|
30
|
+
import { SELECT_COLUMN_ID } from "./TMDataGridSelectColumn";
|
|
18
31
|
import type { TMDataGridFeatures } from "../useTMDataGrid";
|
|
19
32
|
|
|
20
33
|
/** Where a column sits relative to the pinned regions, resolved once per render. */
|
|
@@ -32,19 +45,61 @@ const UNPINNED_LAYOUT: TMDataGridColumnLayout = {
|
|
|
32
45
|
isBoundary: false,
|
|
33
46
|
};
|
|
34
47
|
|
|
48
|
+
/**
|
|
49
|
+
* What a cell shows once grouping is in play.
|
|
50
|
+
*
|
|
51
|
+
* A group row carries a cell for every column, but only some of them have
|
|
52
|
+
* anything to say:
|
|
53
|
+
*
|
|
54
|
+
* | Cell | Renders |
|
|
55
|
+
* | ---- | ------- |
|
|
56
|
+
* | On a data row | the column's `cell`, as always |
|
|
57
|
+
* | Placeholder — a grouped column other than this row's own | nothing |
|
|
58
|
+
* | Aggregated, column declares `aggregatedCell` | that |
|
|
59
|
+
* | Aggregated, column declares an `aggregationFn` | the column's `cell`, over the aggregate |
|
|
60
|
+
* | Aggregated, column declares neither | nothing |
|
|
61
|
+
*
|
|
62
|
+
* That last row is the one worth spelling out. Without an aggregation function
|
|
63
|
+
* `getValue()` is `undefined` on a group row, so the column's own renderer
|
|
64
|
+
* would be handed nothing — and a renderer that formats what it gets
|
|
65
|
+
* (`value.toFixed(2)`) would throw. Blank is both the safe answer and the right
|
|
66
|
+
* one: a plain "group by" is a tree, not a summary, and a column only joins in
|
|
67
|
+
* once it has been told how.
|
|
68
|
+
*/
|
|
69
|
+
function renderCellContent(
|
|
70
|
+
cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>,
|
|
71
|
+
) {
|
|
72
|
+
if (cell.getIsPlaceholder()) return null;
|
|
73
|
+
if (!cell.getIsAggregated()) {
|
|
74
|
+
return flexRender(cell.column.columnDef.cell, cell.getContext());
|
|
75
|
+
}
|
|
76
|
+
const { aggregatedCell, aggregationFn } = cell.column.columnDef;
|
|
77
|
+
if (aggregatedCell !== undefined) {
|
|
78
|
+
return flexRender(aggregatedCell, cell.getContext());
|
|
79
|
+
}
|
|
80
|
+
if (aggregationFn === undefined) return null;
|
|
81
|
+
return flexRender(cell.column.columnDef.cell, cell.getContext());
|
|
82
|
+
}
|
|
83
|
+
|
|
35
84
|
function TMDataGridBodyCell({
|
|
36
85
|
cell,
|
|
37
86
|
rowHeight,
|
|
38
87
|
layout,
|
|
88
|
+
onContextMenu,
|
|
39
89
|
}: {
|
|
40
90
|
cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
|
|
41
91
|
rowHeight: number;
|
|
42
92
|
layout: TMDataGridColumnLayout;
|
|
93
|
+
onContextMenu?: (event: MouseEvent<HTMLDivElement>) => void;
|
|
43
94
|
}) {
|
|
44
95
|
return (
|
|
45
96
|
<div
|
|
46
97
|
role="cell"
|
|
47
98
|
data-align={getColumnAlign(cell.column)}
|
|
99
|
+
// The checkbox lane is a fixed track, so it cannot take the cell padding
|
|
100
|
+
// the scale grows for text. See TMDataGridTable.module.css.
|
|
101
|
+
data-select-column={cell.column.id === SELECT_COLUMN_ID}
|
|
102
|
+
onContextMenu={onContextMenu}
|
|
48
103
|
className={[
|
|
49
104
|
classes.bodyCell,
|
|
50
105
|
layout.isBoundary && layout.pinnedAt === "left" ? sticky.stickyLeft : "",
|
|
@@ -62,19 +117,135 @@ function TMDataGridBodyCell({
|
|
|
62
117
|
zIndex: layout.pinnedAt ? 2 : undefined,
|
|
63
118
|
}}
|
|
64
119
|
>
|
|
65
|
-
<span className={classes.cellContent}>
|
|
66
|
-
|
|
67
|
-
|
|
120
|
+
<span className={classes.cellContent}>{renderCellContent(cell)}</span>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The virtualized body rows, wrapped in the single Menu that serves all of them
|
|
127
|
+
* when `rowContextMenu` is set.
|
|
128
|
+
*
|
|
129
|
+
* One instance for the whole body rather than one per row. A Mantine `Menu` is
|
|
130
|
+
* a `Popover`, and a closed Popover still runs its hooks on every render — and
|
|
131
|
+
* the body re-renders on every scroll frame, because that is how virtualization
|
|
132
|
+
* works. Wrapping each of ~20 mounted rows measured a fifth onto a body render;
|
|
133
|
+
* wrapping the group is the same behaviour for one instance.
|
|
134
|
+
*
|
|
135
|
+
* `display: contents` keeps the group out of the grid's layout while still
|
|
136
|
+
* taking the right-click, carrying `Menu.ContextMenu`'s handlers, and passing
|
|
137
|
+
* its inherited `user-select: none` down to the rows.
|
|
138
|
+
*/
|
|
139
|
+
function TMDataGridBodyRowGroup({
|
|
140
|
+
enabled,
|
|
141
|
+
menuContent,
|
|
142
|
+
menuProps,
|
|
143
|
+
onContextMenu,
|
|
144
|
+
onClose,
|
|
145
|
+
children,
|
|
146
|
+
}: {
|
|
147
|
+
enabled: boolean;
|
|
148
|
+
menuContent: ReactNode;
|
|
149
|
+
menuProps?: Omit<MenuProps, "opened" | "onChange" | "children">;
|
|
150
|
+
onContextMenu: () => void;
|
|
151
|
+
onClose: () => void;
|
|
152
|
+
children: ReactNode;
|
|
153
|
+
}) {
|
|
154
|
+
const rowGroup = (
|
|
155
|
+
<div
|
|
156
|
+
role="rowgroup"
|
|
157
|
+
style={{ display: "contents" }}
|
|
158
|
+
// Runs before Mantine's own handler, which `Menu.ContextMenu` composes on
|
|
159
|
+
// top of this one — so the row and cell are known by the time the
|
|
160
|
+
// dropdown is built and opened at the pointer.
|
|
161
|
+
onContextMenu={enabled ? onContextMenu : undefined}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
68
164
|
</div>
|
|
69
165
|
);
|
|
166
|
+
|
|
167
|
+
if (!enabled) return rowGroup;
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<Menu
|
|
171
|
+
// Open only once the render prop has returned something: a row it answers
|
|
172
|
+
// `null` for never opens an empty dropdown.
|
|
173
|
+
opened={menuContent !== null}
|
|
174
|
+
onChange={(opened) => {
|
|
175
|
+
if (!opened) onClose();
|
|
176
|
+
}}
|
|
177
|
+
position="bottom-start"
|
|
178
|
+
shadow="md"
|
|
179
|
+
width={220}
|
|
180
|
+
{...menuProps}
|
|
181
|
+
>
|
|
182
|
+
<Menu.ContextMenu>{rowGroup}</Menu.ContextMenu>
|
|
183
|
+
<Menu.Dropdown>{menuContent}</Menu.Dropdown>
|
|
184
|
+
</Menu>
|
|
185
|
+
);
|
|
70
186
|
}
|
|
71
187
|
|
|
188
|
+
/** What a `rowContextMenu` render prop is handed for the right-clicked row. */
|
|
189
|
+
export type TMDataGridRowContextMenuArgs<TData extends RowData> = {
|
|
190
|
+
table: Table<TMDataGridFeatures, TData>;
|
|
191
|
+
row: Row<TMDataGridFeatures, TData>;
|
|
192
|
+
/**
|
|
193
|
+
* The cell under the pointer. `null` only when the right-click landed on the
|
|
194
|
+
* row but on no cell — which a custom cell renderer that stops propagation
|
|
195
|
+
* can cause.
|
|
196
|
+
*/
|
|
197
|
+
cell: Cell<TMDataGridFeatures, TData, unknown> | null;
|
|
198
|
+
/**
|
|
199
|
+
* Closes the menu. `Menu.Item` already closes on click (Mantine's
|
|
200
|
+
* `closeOnItemClick`), so this is for content that isn't a menu item.
|
|
201
|
+
*/
|
|
202
|
+
close: () => void;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Builds the contents of a row's context menu — `Menu.Item`, `Menu.Label`,
|
|
207
|
+
* `Menu.Divider`, `Menu.Sub`, or any other node. The grid owns the `Menu` and
|
|
208
|
+
* its `Menu.Dropdown`; this fills the dropdown.
|
|
209
|
+
*/
|
|
210
|
+
export type TMDataGridRowContextMenu<TData extends RowData> = (
|
|
211
|
+
args: TMDataGridRowContextMenuArgs<TData>,
|
|
212
|
+
) => ReactNode;
|
|
213
|
+
|
|
72
214
|
export type TMDataGridTableProps<TData extends RowData> = {
|
|
73
215
|
/**
|
|
74
216
|
* Called when a body row is clicked. Runs in addition to row selection under
|
|
75
|
-
* `
|
|
217
|
+
* `selectionMode: "row"`, not instead of it.
|
|
76
218
|
*/
|
|
77
219
|
onRowClick?: (row: Row<TMDataGridFeatures, TData>) => void;
|
|
220
|
+
/**
|
|
221
|
+
* Contents of the menu a right-click on a row opens, at the pointer. Return
|
|
222
|
+
* `null` to leave a row without one — the browser's own menu stays suppressed
|
|
223
|
+
* either way.
|
|
224
|
+
*
|
|
225
|
+
* ```tsx
|
|
226
|
+
* <TMDataGrid.Table<Employee>
|
|
227
|
+
* rowContextMenu={({ row, close }) => (
|
|
228
|
+
* <>
|
|
229
|
+
* <Menu.Label>{row.original.firstName}</Menu.Label>
|
|
230
|
+
* <Menu.Item onClick={() => open(row.original.id)}>Open</Menu.Item>
|
|
231
|
+
* <Menu.Divider />
|
|
232
|
+
* <Menu.Item color="red" onClick={() => remove(row.original.id)}>
|
|
233
|
+
* Delete
|
|
234
|
+
* </Menu.Item>
|
|
235
|
+
* </>
|
|
236
|
+
* )}
|
|
237
|
+
* />
|
|
238
|
+
* ```
|
|
239
|
+
*
|
|
240
|
+
* Called during render, and only for the row whose menu is open — so it stays
|
|
241
|
+
* a pure function of the row, and cost per row does not matter.
|
|
242
|
+
*/
|
|
243
|
+
rowContextMenu?: TMDataGridRowContextMenu<TData>;
|
|
244
|
+
/**
|
|
245
|
+
* Passed to the Mantine `Menu` the grid wraps the row in — `width`, `shadow`,
|
|
246
|
+
* `position`, `transitionProps` and the rest. Its open state is the grid's.
|
|
247
|
+
*/
|
|
248
|
+
rowContextMenuProps?: Omit<MenuProps, "opened" | "onChange" | "children">;
|
|
78
249
|
};
|
|
79
250
|
|
|
80
251
|
/**
|
|
@@ -85,12 +256,17 @@ export type TMDataGridTableProps<TData extends RowData> = {
|
|
|
85
256
|
*/
|
|
86
257
|
export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
|
|
87
258
|
onRowClick,
|
|
259
|
+
rowContextMenu,
|
|
260
|
+
rowContextMenuProps,
|
|
88
261
|
}: TMDataGridTableProps<TData>) {
|
|
89
|
-
const { table, features, rowHeight, controlSize } =
|
|
262
|
+
const { table, ui, features, rowHeight, controlSize } =
|
|
263
|
+
useTMDataGridContext();
|
|
90
264
|
|
|
91
265
|
// The body depends on every state slice (sorting, filters, paging, sizing,
|
|
92
266
|
// visibility, selection), so it subscribes to the whole table store.
|
|
93
267
|
useSelector(table.store);
|
|
268
|
+
// The active row lives in the chrome store, so it needs its own subscription.
|
|
269
|
+
const highlightedRowId = useSelector(ui, (state) => state.highlightedRowId);
|
|
94
270
|
|
|
95
271
|
const { loading, noResultsLabel = "No rows match your filters" } =
|
|
96
272
|
table.options.meta ?? {};
|
|
@@ -99,9 +275,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
|
|
|
99
275
|
// Pagination off (the default): every filtered+sorted row, virtualized.
|
|
100
276
|
// Pagination on: the current page; under `manualPagination` TanStack returns
|
|
101
277
|
// the rows as delivered, so the same branch serves server paging.
|
|
102
|
-
const rows = features
|
|
103
|
-
? table.getPaginatedRowModel().rows
|
|
104
|
-
: table.getPrePaginatedRowModel().rows;
|
|
278
|
+
const rows = getDisplayedRows(table, features);
|
|
105
279
|
|
|
106
280
|
// A persisted pageIndex can outlive the data that produced it; TanStack only
|
|
107
281
|
// auto-resets on live filter/sort/data changes, not on restored state. The
|
|
@@ -128,9 +302,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
|
|
|
128
302
|
const paddingBottom =
|
|
129
303
|
virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
|
|
130
304
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
305
|
+
// The *Visible* variants throughout: `getLeftLeafColumns()` and friends
|
|
306
|
+
// include hidden columns, while the cells below come from
|
|
307
|
+
// `row.getLeftVisibleCells()`, which does not. Mixing the two would lay down
|
|
308
|
+
// a grid track for a column that renders no cell, shifting every column after
|
|
309
|
+
// it out of its header. The tree column is hidden exactly this way while
|
|
310
|
+
// nothing is grouped.
|
|
311
|
+
const leftLeafColumns = table.getLeftVisibleLeafColumns();
|
|
312
|
+
const rightLeafColumns = table.getRightVisibleLeafColumns();
|
|
313
|
+
const centerLeafColumns = table.getCenterVisibleLeafColumns();
|
|
134
314
|
|
|
135
315
|
const orderedColumns = [
|
|
136
316
|
...leftLeafColumns,
|
|
@@ -221,20 +401,113 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
|
|
|
221
401
|
|
|
222
402
|
const isEmpty = rows.length === 0;
|
|
223
403
|
|
|
224
|
-
// "row" mode: the row itself is the selection control
|
|
225
|
-
//
|
|
226
|
-
const selectsOnRowClick =
|
|
227
|
-
|
|
228
|
-
|
|
404
|
+
// "row" mode: the row itself is the selection control, with the modifier
|
|
405
|
+
// conventions of any desktop list — see resolveRowSelectionClick.
|
|
406
|
+
const selectsOnRowClick = features.rowClickSelects;
|
|
407
|
+
const rowIsInteractive =
|
|
408
|
+
selectsOnRowClick || features.highlightRow || onRowClick !== undefined;
|
|
229
409
|
|
|
230
|
-
|
|
231
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Group rows sit out every row-level gesture.
|
|
412
|
+
*
|
|
413
|
+
* Not squeamishness: TanStack builds a group row on top of its first leaf's
|
|
414
|
+
* record (`constructRow(table, id, leafRows[0].original, …)`), so an
|
|
415
|
+
* `onRowClick` firing here would hand a consumer a real-looking row that is
|
|
416
|
+
* the wrong one — a detail panel would open on an arbitrary child. The same
|
|
417
|
+
* goes for the highlight, which is what a detail panel follows.
|
|
418
|
+
*
|
|
419
|
+
* Selecting a group is still possible, through its checkbox, which resolves
|
|
420
|
+
* to the descendants rather than to the group itself. See
|
|
421
|
+
* getSelectableRowIds.
|
|
422
|
+
*/
|
|
423
|
+
const rowGesturesFor = (row: Row<TMDataGridFeatures, TMDataGridRowData>) =>
|
|
424
|
+
row.getIsGrouped() ? false : rowIsInteractive;
|
|
425
|
+
|
|
426
|
+
const handleRowActivate = (
|
|
427
|
+
row: Row<TMDataGridFeatures, TMDataGridRowData>,
|
|
428
|
+
modifiers: { toggle: boolean; extend: boolean },
|
|
429
|
+
) => {
|
|
430
|
+
if (selectsOnRowClick && row.getCanSelect()) {
|
|
431
|
+
const resolved = resolveRowSelectionClick({
|
|
432
|
+
rows,
|
|
433
|
+
rowId: row.id,
|
|
434
|
+
anchorRowId: ui.state.selectionAnchorRowId,
|
|
435
|
+
modifiers,
|
|
436
|
+
selection: table.store.state.rowSelection,
|
|
437
|
+
canReplaceSelection: true,
|
|
438
|
+
});
|
|
439
|
+
table.setRowSelection(resolved.selection);
|
|
440
|
+
ui.actions.setSelectionAnchor(resolved.anchorRowId);
|
|
441
|
+
}
|
|
442
|
+
// Set, never toggle: a second click on the highlighted row must not close
|
|
443
|
+
// the detail panel showing it. Clearing is
|
|
444
|
+
// `ui.actions.setHighlightedRow(null)`.
|
|
445
|
+
if (features.highlightRow) ui.actions.setHighlightedRow(row.id);
|
|
232
446
|
onRowClick?.(row as unknown as Row<TMDataGridFeatures, TData>);
|
|
233
447
|
};
|
|
234
448
|
|
|
449
|
+
// Which row's context menu is open, and which cell it was opened from.
|
|
450
|
+
const [contextMenuTarget, setContextMenuTarget] = useState<{
|
|
451
|
+
rowId: string;
|
|
452
|
+
columnId: string | null;
|
|
453
|
+
} | null>(null);
|
|
454
|
+
const closeContextMenu = () => setContextMenuTarget(null);
|
|
455
|
+
|
|
456
|
+
// Row and cell each record themselves on the way up, so that by the time the
|
|
457
|
+
// rowgroup's handler opens the menu it knows what was hit. Refs rather than
|
|
458
|
+
// state: written and read inside the same event, before the render it causes.
|
|
459
|
+
const contextMenuRowRef = useRef<string | null>(null);
|
|
460
|
+
const contextMenuColumnRef = useRef<string | null>(null);
|
|
461
|
+
|
|
462
|
+
const handleContextMenu = () => {
|
|
463
|
+
const rowId = contextMenuRowRef.current;
|
|
464
|
+
const columnId = contextMenuColumnRef.current;
|
|
465
|
+
contextMenuRowRef.current = null;
|
|
466
|
+
contextMenuColumnRef.current = null;
|
|
467
|
+
setContextMenuTarget(rowId === null ? null : { rowId, columnId });
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const contextMenuRow =
|
|
471
|
+
contextMenuTarget === null
|
|
472
|
+
? undefined
|
|
473
|
+
: rows.find((row) => row.id === contextMenuTarget.rowId);
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Built for the open row only, so a consumer's render prop is called once per
|
|
477
|
+
* opened menu rather than once per mounted row. `null` back from it means
|
|
478
|
+
* this row has no menu, which is also how the menu stays shut.
|
|
479
|
+
*
|
|
480
|
+
* The casts put back the concrete row type the context boundary erased — the
|
|
481
|
+
* same crossing `onRowClick` makes.
|
|
482
|
+
*/
|
|
483
|
+
const contextMenuContent =
|
|
484
|
+
rowContextMenu && contextMenuTarget && contextMenuRow
|
|
485
|
+
? rowContextMenu({
|
|
486
|
+
table: table as unknown as Table<TMDataGridFeatures, TData>,
|
|
487
|
+
row: contextMenuRow as unknown as Row<TMDataGridFeatures, TData>,
|
|
488
|
+
cell: (contextMenuRow
|
|
489
|
+
.getAllCells()
|
|
490
|
+
.find((cell) => cell.column.id === contextMenuTarget.columnId) ??
|
|
491
|
+
null) as unknown as Cell<TMDataGridFeatures, TData, unknown> | null,
|
|
492
|
+
close: closeContextMenu,
|
|
493
|
+
}) ?? null
|
|
494
|
+
: null;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The dropdown is anchored to a fixed viewport point, so it does not travel
|
|
498
|
+
* with the row when the body scrolls — and under virtualization the row it
|
|
499
|
+
* belongs to may unmount entirely. Closing is the honest answer, and matches
|
|
500
|
+
* what every desktop context menu does.
|
|
501
|
+
*/
|
|
502
|
+
const handleScroll = contextMenuTarget ? closeContextMenu : undefined;
|
|
503
|
+
|
|
235
504
|
return (
|
|
236
505
|
<div className={classes.tableWrapper}>
|
|
237
|
-
<div
|
|
506
|
+
<div
|
|
507
|
+
ref={scrollContainerRef}
|
|
508
|
+
className={classes.scrollContainer}
|
|
509
|
+
onScroll={handleScroll}
|
|
510
|
+
>
|
|
238
511
|
<div
|
|
239
512
|
role="table"
|
|
240
513
|
className={classes.grid}
|
|
@@ -268,43 +541,127 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
|
|
|
268
541
|
<div aria-hidden style={{ gridColumn: "1/-1", height: paddingTop }} />
|
|
269
542
|
)}
|
|
270
543
|
|
|
271
|
-
<
|
|
544
|
+
<TMDataGridBodyRowGroup
|
|
545
|
+
enabled={rowContextMenu !== undefined}
|
|
546
|
+
menuContent={contextMenuContent}
|
|
547
|
+
menuProps={rowContextMenuProps}
|
|
548
|
+
onContextMenu={handleContextMenu}
|
|
549
|
+
onClose={closeContextMenu}
|
|
550
|
+
>
|
|
272
551
|
{virtualItems.map((virtualItem) => {
|
|
273
552
|
const row = rows[virtualItem.index];
|
|
274
553
|
if (!row) return null;
|
|
554
|
+
const isGroupRow = row.getIsGrouped();
|
|
555
|
+
const isInteractive = rowGesturesFor(row);
|
|
556
|
+
const takesKeyboard =
|
|
557
|
+
!isGroupRow && (selectsOnRowClick || features.highlightRow);
|
|
275
558
|
return (
|
|
276
559
|
<div
|
|
277
560
|
key={virtualItem.key}
|
|
278
561
|
role="row"
|
|
279
562
|
aria-rowindex={virtualItem.index + headerGroups.length + 1}
|
|
280
563
|
data-testid={`dg-row-${row.id}`}
|
|
281
|
-
data-
|
|
282
|
-
//
|
|
564
|
+
// The tree's own rows. `data-grouped` rather than a class, to
|
|
565
|
+
// match how the rest of the row's state is published — and so
|
|
566
|
+
// a consumer can restyle them without reaching into modules.
|
|
567
|
+
data-grouped={isGroupRow}
|
|
568
|
+
data-depth={row.depth}
|
|
569
|
+
// Gated on the flag, not just on `getIsSelected()`: under
|
|
570
|
+
// `"highlight"` there is no selection, but `rowSelection` may
|
|
571
|
+
// still hold ids from before the mode changed — TanStack never
|
|
572
|
+
// prunes it — and a row must not report itself selected in a
|
|
573
|
+
// mode where selecting is not a thing.
|
|
574
|
+
data-selected={features.rowSelection && row.getIsSelected()}
|
|
575
|
+
// Being selected is state; painting it is a display choice, so
|
|
283
576
|
// the two are separate attributes.
|
|
577
|
+
data-selected-bg={
|
|
578
|
+
features.rowSelection &&
|
|
579
|
+
features.showSelectedBackground &&
|
|
580
|
+
row.getIsSelected()
|
|
581
|
+
}
|
|
582
|
+
// The highlighted row is its own concept, so its own attribute
|
|
583
|
+
// pair — `data-highlighted` / `aria-current` against
|
|
584
|
+
// `data-selected` / `aria-selected` for selection. Under
|
|
585
|
+
// `"checkboxAndHighlight"` a row can carry both.
|
|
586
|
+
//
|
|
587
|
+
// Not `data-active`: header cells already use that for
|
|
588
|
+
// sorted-or-filtered, and Mantine puts it on its own controls,
|
|
589
|
+
// so a consumer styling or querying rows by it would cast far
|
|
590
|
+
// wider than they meant to.
|
|
284
591
|
data-highlighted={
|
|
285
|
-
features.
|
|
592
|
+
features.highlightRow && row.id === highlightedRowId
|
|
286
593
|
}
|
|
287
|
-
|
|
594
|
+
// The menu is anchored to the rowgroup, so Mantine's own
|
|
595
|
+
// `data-expanded` lands there rather than on a row. This is
|
|
596
|
+
// what says which row the open menu is about.
|
|
597
|
+
data-context-menu={
|
|
598
|
+
contextMenuContent !== null &&
|
|
599
|
+
contextMenuTarget?.rowId === row.id
|
|
600
|
+
}
|
|
601
|
+
data-selects-on-click={selectsOnRowClick && !isGroupRow}
|
|
288
602
|
aria-selected={
|
|
289
603
|
features.rowSelection ? row.getIsSelected() : undefined
|
|
290
604
|
}
|
|
605
|
+
aria-current={
|
|
606
|
+
features.highlightRow && row.id === highlightedRowId
|
|
607
|
+
? true
|
|
608
|
+
: undefined
|
|
609
|
+
}
|
|
291
610
|
className={classes.bodyRow}
|
|
292
611
|
style={{
|
|
293
612
|
minHeight: rowHeight,
|
|
294
|
-
cursor:
|
|
613
|
+
cursor: isInteractive ? "pointer" : undefined,
|
|
295
614
|
}}
|
|
296
615
|
// Keyboard parity with the checkbox the row replaces.
|
|
297
|
-
tabIndex={
|
|
616
|
+
tabIndex={takesKeyboard ? 0 : undefined}
|
|
617
|
+
// Shift-click extends the browser's text selection across
|
|
618
|
+
// every row it passes. `user-select: none` would fix it too,
|
|
619
|
+
// but at the cost of ever copying a cell value, so the smear
|
|
620
|
+
// is stopped at the one gesture that causes it.
|
|
621
|
+
onMouseDown={
|
|
622
|
+
selectsOnRowClick && !isGroupRow
|
|
623
|
+
? (event) => {
|
|
624
|
+
if (event.shiftKey) event.preventDefault();
|
|
625
|
+
}
|
|
626
|
+
: undefined
|
|
627
|
+
}
|
|
298
628
|
onClick={
|
|
299
|
-
|
|
629
|
+
isInteractive
|
|
630
|
+
? (event) =>
|
|
631
|
+
handleRowActivate(row, {
|
|
632
|
+
toggle: event.ctrlKey || event.metaKey,
|
|
633
|
+
extend: event.shiftKey,
|
|
634
|
+
})
|
|
635
|
+
: undefined
|
|
300
636
|
}
|
|
301
637
|
onKeyDown={
|
|
302
|
-
|
|
638
|
+
takesKeyboard
|
|
303
639
|
? (event) => {
|
|
304
640
|
if (event.key !== " " && event.key !== "Enter") return;
|
|
305
641
|
if (event.target !== event.currentTarget) return;
|
|
306
642
|
event.preventDefault();
|
|
307
|
-
|
|
643
|
+
// Space/Enter toggle rather than replace, so a
|
|
644
|
+
// keyboard-only user can still build a selection.
|
|
645
|
+
handleRowActivate(row, {
|
|
646
|
+
toggle: true,
|
|
647
|
+
extend: event.shiftKey,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
: undefined
|
|
651
|
+
}
|
|
652
|
+
// Bubbles to the rowgroup, where the menu opens — so the row
|
|
653
|
+
// is recorded before the dropdown is built.
|
|
654
|
+
//
|
|
655
|
+
// Group rows record nothing, which leaves the ref null and the
|
|
656
|
+
// menu shut. Same reason they sit out `onRowClick`: TanStack
|
|
657
|
+
// builds a group row on its first child's record, so a render
|
|
658
|
+
// prop reaching for `row.original` would be handed a real
|
|
659
|
+
// employee that has nothing to do with the group. The browser
|
|
660
|
+
// menu stays suppressed over them either way.
|
|
661
|
+
onContextMenu={
|
|
662
|
+
rowContextMenu && !isGroupRow
|
|
663
|
+
? () => {
|
|
664
|
+
contextMenuRowRef.current = row.id;
|
|
308
665
|
}
|
|
309
666
|
: undefined
|
|
310
667
|
}
|
|
@@ -319,12 +676,19 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
|
|
|
319
676
|
cell={cell}
|
|
320
677
|
rowHeight={rowHeight}
|
|
321
678
|
layout={layoutFor(cell.column.id)}
|
|
679
|
+
onContextMenu={
|
|
680
|
+
rowContextMenu
|
|
681
|
+
? () => {
|
|
682
|
+
contextMenuColumnRef.current = cell.column.id;
|
|
683
|
+
}
|
|
684
|
+
: undefined
|
|
685
|
+
}
|
|
322
686
|
/>
|
|
323
687
|
))}
|
|
324
688
|
</div>
|
|
325
689
|
);
|
|
326
690
|
})}
|
|
327
|
-
</
|
|
691
|
+
</TMDataGridBodyRowGroup>
|
|
328
692
|
|
|
329
693
|
{isEmpty && loading === true && (
|
|
330
694
|
<div role="row" className={classes.messageRow}>
|
|
@@ -112,6 +112,9 @@ export function TMDataGridFilterButton() {
|
|
|
112
112
|
color={activeCount > 0 ? undefined : "gray"}
|
|
113
113
|
size={controlSize}
|
|
114
114
|
aria-label="Filters"
|
|
115
|
+
// Marks this as the panel's own toggle, so the panel's click-away
|
|
116
|
+
// handler leaves it alone and the button stays a toggle.
|
|
117
|
+
data-dg-filter-toggle
|
|
115
118
|
onClick={() => {
|
|
116
119
|
if (opened) {
|
|
117
120
|
ui.actions.closeFilterPanel();
|
|
@@ -10,12 +10,17 @@ export {
|
|
|
10
10
|
IconArrowLeft as MoveLeftIcon,
|
|
11
11
|
IconArrowRight as MoveRightIcon,
|
|
12
12
|
IconArrowUp as ArrowUpIcon,
|
|
13
|
+
IconChevronDown as ChevronDownIcon,
|
|
13
14
|
IconChevronLeft as ChevronLeftIcon,
|
|
14
15
|
IconChevronRight as ChevronRightIcon,
|
|
15
16
|
IconColumns3 as ColumnsIcon,
|
|
16
17
|
IconDotsVertical as DotsVerticalIcon,
|
|
17
18
|
IconEyeOff as EyeOffIcon,
|
|
18
19
|
IconFilter as FilterIcon,
|
|
20
|
+
IconFoldDown as ExpandAllIcon,
|
|
21
|
+
IconFoldUp as CollapseAllIcon,
|
|
22
|
+
IconIndentDecrease as UngroupIcon,
|
|
23
|
+
IconIndentIncrease as GroupIcon,
|
|
19
24
|
IconArrowBarToLeft as PinLeftIcon,
|
|
20
25
|
IconArrowBarToRight as PinRightIcon,
|
|
21
26
|
IconMenu2 as BurgerIcon,
|