@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.
Files changed (35) hide show
  1. package/dist/index.d.ts +397 -37
  2. package/dist/index.js +1550 -978
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +1 -1
  6. package/skills/columns/SKILL.md +1 -1
  7. package/skills/features/SKILL.md +1 -1
  8. package/skills/getting-started/SKILL.md +41 -6
  9. package/skills/options/SKILL.md +1 -1
  10. package/skills/server-side/SKILL.md +1 -1
  11. package/src/tmdatagrid/components/TMDataGrid.module.css +11 -1
  12. package/src/tmdatagrid/components/TMDataGrid.tsx +6 -0
  13. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +7 -1
  14. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +15 -0
  15. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +87 -10
  16. package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
  17. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +135 -0
  18. package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
  19. package/src/tmdatagrid/components/TMDataGridFooter.tsx +70 -42
  20. package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
  21. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +147 -0
  22. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +6 -0
  23. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +77 -1
  24. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +83 -6
  25. package/src/tmdatagrid/components/TMDataGridTable.module.css +38 -4
  26. package/src/tmdatagrid/components/TMDataGridTable.tsx +396 -32
  27. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +3 -0
  28. package/src/tmdatagrid/components/icons.ts +5 -0
  29. package/src/tmdatagrid/core/capabilities.ts +81 -11
  30. package/src/tmdatagrid/core/filterOperators.ts +25 -0
  31. package/src/tmdatagrid/core/grouping.ts +21 -0
  32. package/src/tmdatagrid/core/persistence.ts +12 -0
  33. package/src/tmdatagrid/core/rowSelection.ts +202 -0
  34. package/src/tmdatagrid/index.ts +26 -2
  35. 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 { useCallback, useEffect, useRef } from "react";
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
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
67
- </span>
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
- * `rowSelectionMode: "row"`, not instead of it.
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 } = useTMDataGridContext();
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.pagination
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
- const leftLeafColumns = table.getLeftLeafColumns();
132
- const rightLeafColumns = table.getRightLeafColumns();
133
- const centerLeafColumns = table.getCenterLeafColumns();
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 — clicking it toggles
225
- // that row and leaves the rest of the selection alone.
226
- const selectsOnRowClick =
227
- features.rowSelection && features.rowSelectionMode === "row";
228
- const rowIsInteractive = selectsOnRowClick || onRowClick !== undefined;
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
- const handleRowActivate = (row: Row<TMDataGridFeatures, TMDataGridRowData>) => {
231
- if (selectsOnRowClick && row.getCanSelect()) row.toggleSelected();
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 ref={scrollContainerRef} className={classes.scrollContainer}>
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
- <div role="rowgroup" style={{ display: "contents" }}>
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-selected={row.getIsSelected()}
282
- // Selection is state; the highlight is a display choice, so
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.highlightSelectedRows && row.getIsSelected()
592
+ features.highlightRow && row.id === highlightedRowId
286
593
  }
287
- data-selects-on-click={selectsOnRowClick}
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: rowIsInteractive ? "pointer" : undefined,
613
+ cursor: isInteractive ? "pointer" : undefined,
295
614
  }}
296
615
  // Keyboard parity with the checkbox the row replaces.
297
- tabIndex={selectsOnRowClick ? 0 : undefined}
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
- rowIsInteractive ? () => handleRowActivate(row) : undefined
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
- selectsOnRowClick
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
- handleRowActivate(row);
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
- </div>
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,