@jielga/tmdatagrid 2.1.4 → 2.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 (57) hide show
  1. package/dist/index.d.ts +78 -5
  2. package/dist/index.js +2194 -1992
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/docs/cell-selection.md +1 -1
  6. package/docs/column-layout.md +13 -8
  7. package/docs/columns.md +59 -3
  8. package/docs/components.md +1 -0
  9. package/docs/export.md +2 -2
  10. package/docs/filtering.md +108 -15
  11. package/docs/getting-started.md +40 -0
  12. package/docs/grouping.md +9 -6
  13. package/docs/loading-and-empty.md +29 -9
  14. package/docs/localization.md +130 -3
  15. package/docs/menu.md +1 -1
  16. package/docs/pagination.md +26 -7
  17. package/docs/persistence.md +101 -2
  18. package/docs/row-details.md +41 -4
  19. package/docs/row-interaction.md +2 -2
  20. package/docs/row-selection.md +6 -7
  21. package/docs/row-styling.md +17 -0
  22. package/docs/scrolling.md +1 -1
  23. package/docs/server-side.md +3 -1
  24. package/docs/testing.md +18 -0
  25. package/docs/use-tm-data-grid.md +75 -36
  26. package/package.json +1 -1
  27. package/skills/appearance/SKILL.md +6 -4
  28. package/skills/appearance/references/labels.md +126 -0
  29. package/skills/cell-selection/SKILL.md +2 -2
  30. package/skills/columns/SKILL.md +44 -43
  31. package/skills/columns/references/columns-api.md +16 -1
  32. package/skills/data/SKILL.md +77 -50
  33. package/skills/editing/SKILL.md +1 -1
  34. package/skills/filtering/SKILL.md +94 -60
  35. package/skills/filtering/references/custom-filters.md +118 -0
  36. package/skills/getting-started/SKILL.md +37 -2
  37. package/skills/grouping/SKILL.md +16 -9
  38. package/skills/migrating-to-2/SKILL.md +1 -1
  39. package/skills/options/SKILL.md +206 -55
  40. package/skills/rows/SKILL.md +70 -39
  41. package/skills/server-side/SKILL.md +1 -1
  42. package/skills/testing/SKILL.md +12 -1
  43. package/skills/testing-components/SKILL.md +1 -1
  44. package/skills/testing-editing/SKILL.md +1 -1
  45. package/src/components/TMDataGridFilterPills.module.css +9 -7
  46. package/src/components/TMDataGridTable.module.css +15 -6
  47. package/src/components/TMDataGridTable.tsx +253 -41
  48. package/src/components/sticky.module.css +27 -7
  49. package/src/core/autosize.ts +19 -7
  50. package/src/core/cellRange.ts +11 -2
  51. package/src/core/colSpan.ts +78 -0
  52. package/src/core/columnUtils.ts +7 -0
  53. package/src/core/filterControls.ts +8 -0
  54. package/src/core/filterOperators.ts +29 -1
  55. package/src/core/persistence.ts +322 -26
  56. package/src/index.ts +5 -0
  57. package/src/useTMDataGrid.tsx +133 -14
@@ -13,7 +13,7 @@ description: >
13
13
  metadata:
14
14
  type: core
15
15
  library: '@jielga/tmdatagrid'
16
- library_version: '2.1.4'
16
+ library_version: '2.3.0'
17
17
  sources:
18
18
  - 'Jielga/react:packages/tmdatagrid/docs/testing.md'
19
19
  - 'Jielga/react:playwright/support/DataGrid.ts'
@@ -104,6 +104,17 @@ match the same rows, and the negative is `:not([data-new])` in CSS and
104
104
 
105
105
  **Keyed by both**: `editor`.
106
106
 
107
+ **The generated lanes** are columns with exported ids: `ROW_NUMBER_COLUMN_ID`
108
+ (`__rowNumber__`), `SELECT_COLUMN_ID` (`__select__`), `GROUP_COLUMN_ID`
109
+ (`__group__`), `DETAILS_COLUMN_ID` (`__details__`) and `EDIT_COLUMN_ID`
110
+ (`__edit__`). A lane's header is
111
+ `[data-dg-part="header"][data-column-id="__select__"]`, a lane cell
112
+ `[data-row-id="42"][data-column-id="__select__"]:not([data-dg-part])`, and the
113
+ control inside it has a row-keyed part: `select-row`, `details-toggle`,
114
+ `group-toggle`, the edit lane's buttons. `select-all` and `details-toggle-all`
115
+ sit in the lane headers. The checkbox parts are on the `<input>` itself, so
116
+ `check()` works on them directly. There is no `data-testid`.
117
+
107
118
  Inside a `filter-row` the controls are `filter-column`, `filter-operator` and
108
119
  `filter-value` - or `filter-value-from` / `filter-value-to` for `between` - and
109
120
  `filter-remove` is its ✕. None of them carries `data-column-id`; scope through
@@ -12,7 +12,7 @@ description: >
12
12
  metadata:
13
13
  type: core
14
14
  library: '@jielga/tmdatagrid'
15
- library_version: '2.1.4'
15
+ library_version: '2.3.0'
16
16
  sources:
17
17
  - 'Jielga/react:packages/tmdatagrid/docs/testing.md'
18
18
  - 'Jielga/react:packages/tmdatagrid/test/gallery/main.tsx'
@@ -12,7 +12,7 @@ description: >
12
12
  metadata:
13
13
  type: core
14
14
  library: '@jielga/tmdatagrid'
15
- library_version: '2.1.4'
15
+ library_version: '2.3.0'
16
16
  sources:
17
17
  - 'Jielga/react:packages/tmdatagrid/docs/testing.md'
18
18
  - 'Jielga/react:packages/tmdatagrid/docs/editing.md'
@@ -13,13 +13,15 @@
13
13
  shape readable against any surface. Scoped through `.filterPills` for the
14
14
  specificity to beat Mantine's own rule whichever stylesheet loads last. */
15
15
  .filterPills .pill {
16
- background-color: light-dark(
17
- var(--mantine-color-gray-1),
18
- var(--mantine-color-dark-5)
19
- );
20
- color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
21
- border: 1px solid
22
- light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
16
+ background-color: var(--mantine-color-gray-1);
17
+ color: var(--mantine-color-black);
18
+ border: 1px solid var(--mantine-color-gray-3);
19
+ }
20
+
21
+ :where([data-mantine-color-scheme="dark"]) .filterPills .pill {
22
+ background-color: var(--mantine-color-dark-5);
23
+ color: var(--mantine-color-dark-0);
24
+ border-color: var(--mantine-color-dark-3);
23
25
  }
24
26
 
25
27
  /* The label is a button so a pill can be clicked back open, but it must read as
@@ -84,7 +84,13 @@
84
84
  --row-bg: var(--dg-row-new-bg, var(--mantine-color-body));
85
85
  }
86
86
 
87
- .bodyRow:hover {
87
+ /* The hover tint, on the rows that carry `data-highlight-on-hover` - by default
88
+ the rows that answer a click or a right-click, see the Table's
89
+ `highlightOnHover`. `:where()` adds no specificity, so the rule stays at the
90
+ weight of the state rules around it: declared after the stripe and the entry
91
+ tint, which it wins, and before the selection and highlight rules, which win
92
+ over it. */
93
+ .bodyRow:where([data-highlight-on-hover="true"]):hover {
88
94
  --row-bg: var(--mantine-color-default-hover);
89
95
  }
90
96
 
@@ -320,7 +326,9 @@
320
326
  /* Base and tint, exactly as .bodyCell paints them. */
321
327
  background-color: var(--mantine-color-body);
322
328
  background-image: linear-gradient(var(--row-bg), var(--row-bg));
323
- padding: var(--dg-padding);
329
+ /* No padding: the panel is the user's content, and a nested grid or a tab
330
+ strip wants the full width. Content that should line up with the cell
331
+ text pads itself with var(--dg-padding). */
324
332
  /* The row above may be the selection control, which turns text selection off
325
333
  for everything inside it. A panel is content, so it takes both back. */
326
334
  user-select: text;
@@ -477,14 +485,15 @@
477
485
  the browser's default so the colour is the grid's to state - override with
478
486
  `--dg-match-highlight-bg` on the grid. */
479
487
  .matchHighlight {
480
- background-color: var(
481
- --dg-match-highlight-bg,
482
- light-dark(var(--mantine-color-yellow-2), rgba(250, 176, 5, 0.35))
483
- );
488
+ background-color: var(--dg-match-highlight-bg, var(--mantine-color-yellow-2));
484
489
  color: inherit;
485
490
  border-radius: 2px;
486
491
  }
487
492
 
493
+ :where([data-mantine-color-scheme="dark"]) .matchHighlight {
494
+ background-color: var(--dg-match-highlight-bg, rgba(250, 176, 5, 0.35));
495
+ }
496
+
488
497
  /* The summary row - one sticky row of column footers along the bottom edge,
489
498
  rendered only while at least one column defines `footer`. Same subgrid
490
499
  placement as the header, so its cells sit exactly on the body's tracks.
@@ -41,6 +41,7 @@ import {
41
41
  } from "./TMDataGridCellEditor";
42
42
  import { TMDataGridEntryRows } from "./TMDataGridEntryRows";
43
43
  import {
44
+ DEFAULT_COLUMN_MIN_SIZE,
44
45
  getColumnAlign,
45
46
  isControlColumn,
46
47
  isGeneratedColumn,
@@ -76,6 +77,12 @@ import {
76
77
  writeExportFile,
77
78
  } from "../core/export";
78
79
  import { autosizeColumn, hasMountedCells } from "../core/autosize";
80
+ import {
81
+ resolveColSpans,
82
+ spanAnchorIndex,
83
+ spanEndIndex,
84
+ type TMDataGridColumnSection,
85
+ } from "../core/colSpan";
79
86
  import {
80
87
  getDisplayedRows,
81
88
  getSelectableRowIds,
@@ -83,6 +90,7 @@ import {
83
90
  resolveRowSelectionClick,
84
91
  } from "../core/rowSelection";
85
92
  import { readPinnedRows } from "../core/rowPinning";
93
+ import { filterNarrowsRows } from "../core/filterOperators";
86
94
  import { buildMatchNeedles, findMatchRanges } from "../core/matchHighlight";
87
95
  import { ROW_NUMBER_COLUMN_ID } from "./TMDataGridRowNumberColumn";
88
96
  import { SearchIcon } from "./icons";
@@ -253,6 +261,7 @@ function withMatchHighlights(
253
261
  function TMDataGridBodyCell({
254
262
  cell,
255
263
  rowHeight,
264
+ colSpan = 1,
256
265
  layout,
257
266
  nav,
258
267
  editor,
@@ -267,6 +276,8 @@ function TMDataGridBodyCell({
267
276
  }: {
268
277
  cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
269
278
  rowHeight: number;
279
+ /** Columns this cell covers - see `meta.colSpan`. */
280
+ colSpan?: number;
270
281
  layout: TMDataGridColumnLayout;
271
282
  nav?: TMDataGridCellNav;
272
283
  /** The mounted editor, when this is the cell being edited. */
@@ -332,6 +343,10 @@ function TMDataGridBodyCell({
332
343
  // than always.
333
344
  role={nav ? "gridcell" : "cell"}
334
345
  aria-colindex={nav?.columnIndex}
346
+ aria-colspan={colSpan > 1 ? colSpan : undefined}
347
+ // Published for styling and for autosize, which leaves a spanning cell
348
+ // out of its column's measurement.
349
+ data-col-span={colSpan > 1 ? colSpan : undefined}
335
350
  tabIndex={nav?.tabIndex}
336
351
  // The marker the delegated key handler tests: a key event whose target is
337
352
  // a cell is the grid's, one from inside a cell belongs to whatever holds
@@ -381,6 +396,7 @@ function TMDataGridBodyCell({
381
396
  .join(" ")}
382
397
  style={{
383
398
  minHeight: rowHeight,
399
+ gridColumn: colSpan > 1 ? `span ${colSpan}` : undefined,
384
400
  left: layout.pinnedAt === "left" ? layout.offset : undefined,
385
401
  right: layout.pinnedAt === "right" ? layout.offset : undefined,
386
402
  // The focus ring is drawn inside the cell's own box, so the cell has to
@@ -615,6 +631,14 @@ export type TMDataGridTableProps<TData extends RowData> = {
615
631
  * stay put while the virtualizer mounts and unmounts rows.
616
632
  */
617
633
  striped?: boolean;
634
+ /**
635
+ * Whether a body row takes the hover tint under the pointer. Unset, a row
636
+ * takes it when the row itself answers a gesture: `selectionMode: "row"`,
637
+ * a highlight mode, `onRowClick` or `renderRowContextMenu`. Group rows sit
638
+ * out those gestures and so sit out the tint too. `true` tints every row,
639
+ * `false` none. The row carries `data-highlight-on-hover` while it applies.
640
+ */
641
+ highlightOnHover?: boolean;
618
642
  /**
619
643
  * Called when the scroll *arrives* at an edge - once per arrival, not per
620
644
  * scroll event, and not on mount (the grid starts at the top-left edge).
@@ -735,6 +759,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
735
759
  rowClassName,
736
760
  rowStyle,
737
761
  striped = false,
762
+ highlightOnHover,
738
763
  onScrollToTop,
739
764
  onScrollToBottom,
740
765
  onScrollToLeft,
@@ -1182,6 +1207,111 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1182
1207
  ...rightLeafColumns,
1183
1208
  ];
1184
1209
 
1210
+ // `meta.colSpan`, resolved per row over the displayed columns. Built from
1211
+ // the three leaf arrays rather than `column.getIsPinned()`, for the reason
1212
+ // the sticky offsets below give. `null` while no column asks to span, so a
1213
+ // plain grid never calls into it.
1214
+ const spanColumns = orderedColumns.some(
1215
+ (column) => column.columnDef.meta?.colSpan !== undefined,
1216
+ )
1217
+ ? orderedColumns.map((column, index) => ({
1218
+ id: column.id,
1219
+ colSpan: column.columnDef.meta?.colSpan,
1220
+ section: (index < leftLeafColumns.length
1221
+ ? "left"
1222
+ : index < leftLeafColumns.length + centerLeafColumns.length
1223
+ ? "center"
1224
+ : "right") as TMDataGridColumnSection,
1225
+ }))
1226
+ : null;
1227
+ /**
1228
+ * Row mode opens every editable cell of the row at once, and rows
1229
+ * accumulate: a second row opening leaves the first one open, so every row
1230
+ * holding a form shows its editors. Which of them holds the caret is not
1231
+ * decided here - see placeEditCaret, which places it once per gesture.
1232
+ *
1233
+ * A parked row is not one of them. It keeps its form, so it is still
1234
+ * "open", but it has been decided: it renders its draft through the
1235
+ * column's own renderer until `begin` reopens it. A committed entry row in
1236
+ * the body has a form too - every entry row does - but it is a value row
1237
+ * until reopened, and reopening takes it back to the entry block.
1238
+ *
1239
+ * Such a row also drops its spans: the form has an editor in every column.
1240
+ */
1241
+ const isRowEditing = (row: Row<TMDataGridFeatures, TMDataGridRowData>) =>
1242
+ features.editMode === "row" &&
1243
+ editOpenRowIdSet.has(row.id) &&
1244
+ !editDraftRowIdSet.has(row.id) &&
1245
+ !newRowIdSet.has(row.id);
1246
+ const colSpanCache = new Map<string, Array<number> | null>();
1247
+ /**
1248
+ * The spans of one row - see {@link resolveColSpans} - or `null` when every
1249
+ * cell is one column wide. Group rows keep their cells: the tree cell and
1250
+ * the aggregates are the grid's, not a record's.
1251
+ */
1252
+ const colSpansOf = (
1253
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
1254
+ ): Array<number> | null => {
1255
+ if (spanColumns === null || row.getIsGrouped() || isRowEditing(row)) {
1256
+ return null;
1257
+ }
1258
+ const cached = colSpanCache.get(row.id);
1259
+ if (cached !== undefined) return cached;
1260
+ const spans = resolveColSpans(spanColumns, (index) => {
1261
+ const colSpan = spanColumns[index]?.colSpan;
1262
+ return typeof colSpan === "function"
1263
+ ? colSpan({ row, table })
1264
+ : (colSpan ?? 1);
1265
+ });
1266
+ // The open cell editor lives in its column's cell. A span landing on that
1267
+ // column - the row expanding under the edit, say - would unmount the
1268
+ // editor without closing it, so the row keeps its cells until the edit
1269
+ // ends.
1270
+ const editIndex =
1271
+ editActive !== null && editActive.rowId === row.id
1272
+ ? orderedColumns.findIndex(
1273
+ (column) => column.id === editActive.columnId,
1274
+ )
1275
+ : -1;
1276
+ const resolved =
1277
+ spans.every((span) => span === 1) ||
1278
+ (editIndex >= 0 && spans[editIndex] === 0)
1279
+ ? null
1280
+ : spans;
1281
+ colSpanCache.set(row.id, resolved);
1282
+ return resolved;
1283
+ };
1284
+ /** Whether the column at `columnIndex` is covered by a span on `row`. */
1285
+ const isCoveredCell = (
1286
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
1287
+ columnIndex: number,
1288
+ ): boolean => {
1289
+ const spans = colSpansOf(row);
1290
+ return spans !== null && spans[columnIndex] === 0;
1291
+ };
1292
+ /**
1293
+ * A position moved onto the cell that renders it, under spans: the input
1294
+ * itself when its cell renders, so a caller can compare by identity. `row`
1295
+ * is the position's row; `undefined` for a pinned row, which is not in
1296
+ * `rows` and keeps its position as it is.
1297
+ */
1298
+ const toRenderedCell = (
1299
+ position: TMDataGridCellPosition,
1300
+ row: Row<TMDataGridFeatures, TMDataGridRowData> | undefined,
1301
+ ): TMDataGridCellPosition => {
1302
+ if (spanColumns === null || row === undefined) return position;
1303
+ const spans = colSpansOf(row);
1304
+ if (spans === null) return position;
1305
+ const columnIndex = orderedColumns.findIndex(
1306
+ (column) => column.id === position.columnId,
1307
+ );
1308
+ if (columnIndex < 0) return position;
1309
+ const anchorIndex = spanAnchorIndex(spans, columnIndex);
1310
+ if (anchorIndex === columnIndex) return position;
1311
+ const anchor = orderedColumns[anchorIndex];
1312
+ return anchor ? { ...position, columnId: anchor.id } : position;
1313
+ };
1314
+
1185
1315
  // Headers and cells must follow the same left → center → right order as the
1186
1316
  // column tracks, otherwise pinning a column would shuffle it out of its lane.
1187
1317
  const centerHeaderGroups = table.getCenterHeaderGroups();
@@ -1208,7 +1338,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1208
1338
  const isFixed = column.columnDef.minSize === column.columnDef.maxSize;
1209
1339
  const isResized = column.id in columnSizing;
1210
1340
  const isPinned = column.getIsPinned() !== false;
1211
- const minWidth = column.columnDef.minSize ?? 80;
1341
+ const minWidth = column.columnDef.minSize ?? DEFAULT_COLUMN_MIN_SIZE;
1212
1342
  if (isFixed || isResized || isPinned) {
1213
1343
  return {
1214
1344
  id: column.id,
@@ -1269,6 +1399,31 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1269
1399
  }
1270
1400
  const layoutFor = (columnId: string) =>
1271
1401
  columnLayout.get(columnId) ?? UNPINNED_LAYOUT;
1402
+ /**
1403
+ * The layout of a cell spanning from `columnId` to the column at
1404
+ * `lastIndex`. A span never leaves its pinned block, so only the sticky
1405
+ * offset and the boundary shadow change: a right-pinned cell sticks at the
1406
+ * offset of the last column it covers, and the cell covering a block's
1407
+ * boundary column carries its shadow.
1408
+ */
1409
+ const spanLayout = (
1410
+ firstIndex: number,
1411
+ lastIndex: number,
1412
+ ): TMDataGridColumnLayout => {
1413
+ const first = layoutFor(orderedColumns[firstIndex]!.id);
1414
+ if (first.pinnedAt === false) return first;
1415
+ const covered = orderedColumns
1416
+ .slice(firstIndex, lastIndex + 1)
1417
+ .map((column) => layoutFor(column.id));
1418
+ return {
1419
+ ...first,
1420
+ offset:
1421
+ first.pinnedAt === "right"
1422
+ ? (covered.at(-1)?.offset ?? first.offset)
1423
+ : first.offset,
1424
+ isBoundary: covered.some((layout) => layout.isBoundary),
1425
+ };
1426
+ };
1272
1427
 
1273
1428
  /**
1274
1429
  * A running resize drag paints itself, straight onto the grid element.
@@ -1332,11 +1487,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1332
1487
  // Pinned rows count as content: with every visible row pinned to an edge,
1333
1488
  // an empty-state message beside them would contradict what is on screen.
1334
1489
  const isEmpty = rows.length === 0 && pinnedRowCount === 0;
1335
- // Which emptiness this is. The body already subscribes to the whole table
1336
- // store, so these reads are reactive.
1490
+ // Which emptiness this is. A column filter counts by the rule the pills, the
1491
+ // header indicator and the page reset use, so the grid has one definition
1492
+ // of "active" - see `filterNarrowsRows`. The half-typed
1493
+ // `{ operator, value: "" }` the filter panel seeds filters nothing. The body
1494
+ // already subscribes to the whole table store, so these reads are reactive.
1337
1495
  const emptyGlobalFilter = table.store.state.globalFilter;
1338
1496
  const hasActiveFilters =
1339
- table.store.state.columnFilters.length > 0 ||
1497
+ table.store.state.columnFilters.some(filterNarrowsRows) ||
1340
1498
  (typeof emptyGlobalFilter === "string"
1341
1499
  ? emptyGlobalFilter.trim() !== ""
1342
1500
  : emptyGlobalFilter != null);
@@ -1612,11 +1770,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1612
1770
  findCellElement(container, tabStopCell)?.focus({ preventScroll: true });
1613
1771
  };
1614
1772
 
1615
- const isCellAt = (
1616
- position: TMDataGridCellPosition | null,
1617
- rowId: string,
1618
- columnId: string,
1619
- ) => position !== null && position.rowId === rowId && position.columnId === columnId;
1773
+ /**
1774
+ * Whether the focused cell is on this row, in columns `first` to `last` -
1775
+ * one column for most cells, every column it covers for a spanning one.
1776
+ */
1777
+ const isFocusedWithin = (rowId: string, first: number, last: number) =>
1778
+ focusedCell !== null &&
1779
+ focusedCell.rowId === rowId &&
1780
+ focusedColumnIndex >= first &&
1781
+ focusedColumnIndex <= last;
1620
1782
 
1621
1783
  // The rectangle, resolved from ids to the indices the cells are rendered at.
1622
1784
  // `null` whenever a corner has been filtered or hidden away - the range keeps
@@ -1771,7 +1933,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1771
1933
  const row = rows[coords.rowIndex];
1772
1934
  const column = orderedColumns[coords.columnIndex];
1773
1935
  if (!row || !column) return;
1774
- const cell = { rowId: row.id, columnId: column.id };
1936
+ // A column covered by a span on this row has no cell; the one covering it
1937
+ // takes the focus.
1938
+ const cell = toRenderedCell({ rowId: row.id, columnId: column.id }, row);
1775
1939
  // The state moves; the effect below is what makes the DOM follow, because
1776
1940
  // the element for an off-screen row does not exist yet to be focused.
1777
1941
  wantsCellFocusRef.current = true;
@@ -1911,12 +2075,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1911
2075
  direction: move,
1912
2076
  rowCount: rows.length,
1913
2077
  columnCount: orderedColumns.length,
2078
+ // A column covered by a span on its row has no cell to hold an editor,
2079
+ // and a move onto it would land back on the spanning cell just left.
1914
2080
  isEditable: (coords) => {
1915
2081
  const row = rows[coords.rowIndex];
1916
2082
  const column = orderedColumns[coords.columnIndex];
1917
2083
  return (
1918
2084
  row !== undefined &&
1919
2085
  column !== undefined &&
2086
+ !isCoveredCell(row, coords.columnIndex) &&
1920
2087
  edit.canEditCell(row, column)
1921
2088
  );
1922
2089
  },
@@ -2035,10 +2202,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2035
2202
  const nextCell =
2036
2203
  container === null
2037
2204
  ? null
2038
- : findCellElement(container, {
2039
- rowId: nextRow.id,
2040
- columnId: nextColumn.id,
2041
- });
2205
+ : findCellElement(
2206
+ container,
2207
+ toRenderedCell(
2208
+ { rowId: nextRow.id, columnId: nextColumn.id },
2209
+ nextRow,
2210
+ ),
2211
+ );
2042
2212
  if (nextCell !== null) nextCell.focus({ preventScroll: true });
2043
2213
  else target.blur();
2044
2214
  return;
@@ -2175,10 +2345,19 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2175
2345
  return;
2176
2346
  }
2177
2347
 
2348
+ // Right steps off a spanning cell from the last column it covers, or it
2349
+ // would land back on itself.
2350
+ const spans = rows[rowIndex] ? colSpansOf(rows[rowIndex]) : null;
2178
2351
  const next = resolveCellMove({
2179
2352
  key: event.key,
2180
2353
  ctrlKey: event.ctrlKey || event.metaKey,
2181
- from: { rowIndex, columnIndex },
2354
+ from: {
2355
+ rowIndex,
2356
+ columnIndex:
2357
+ spans !== null && event.key === "ArrowRight"
2358
+ ? spanEndIndex(spans, columnIndex)
2359
+ : columnIndex,
2360
+ },
2182
2361
  rowCount: rows.length,
2183
2362
  columnCount: orderedColumns.length,
2184
2363
  pageRows: Math.max(
@@ -2192,7 +2371,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2192
2371
  // through at the last row would scroll the body out from under a focus that
2193
2372
  // did not move.
2194
2373
  event.preventDefault();
2195
- if (next.rowIndex === rowIndex && next.columnIndex === columnIndex) return;
2374
+ // A move landing on this cell again - clamped at an edge, or onto a column
2375
+ // this cell covers - changes nothing, the rectangle included.
2376
+ const landing =
2377
+ spans === null ? next.columnIndex : spanAnchorIndex(spans, next.columnIndex);
2378
+ if (next.rowIndex === rowIndex && landing === columnIndex) return;
2196
2379
  // Shift extends the rectangle instead of collapsing it - the same key that
2197
2380
  // extends a text selection, a file list and a spreadsheet range.
2198
2381
  moveFocusedCell(next, { extend: event.shiftKey && cellRangeSelection });
@@ -2230,6 +2413,23 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2230
2413
  // never in this grid.
2231
2414
  const activeElement = container.ownerDocument.activeElement;
2232
2415
 
2416
+ // The column the focus sits on has just been covered by a span - the row
2417
+ // expanded under it, say. Its cell is gone, and the browser dropped the
2418
+ // focus with it, so the state follows the cell that now stands for the
2419
+ // column, and the render after this one puts the focus there. A focus
2420
+ // that had moved elsewhere on the page stays there: only the ring moves.
2421
+ const rendered = toRenderedCell(focusedCell, rows[focusedRowIndex]);
2422
+ if (rendered !== focusedCell) {
2423
+ if (
2424
+ activeElement === null ||
2425
+ activeElement === container.ownerDocument.body
2426
+ ) {
2427
+ wantsCellFocusRef.current = true;
2428
+ }
2429
+ ui.actions.setFocusedCell(rendered);
2430
+ return;
2431
+ }
2432
+
2233
2433
  // Never take the focus from elsewhere on the page. A consumer moving the
2234
2434
  // cell while the user is typing in a form somewhere means "move the ring",
2235
2435
  // not "move the caret" - the scroll above already did the visible part.
@@ -2250,7 +2450,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2250
2450
  return;
2251
2451
  }
2252
2452
 
2253
- const cellElement = findCellElement(container, focusedCell);
2453
+ const cellElement = findCellElement(container, rendered);
2254
2454
  // Still scrolling. The intent stays up, and the next render tries again.
2255
2455
  if (cellElement === null) return;
2256
2456
  wantsCellFocusRef.current = false;
@@ -2636,28 +2836,21 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2636
2836
  ) => {
2637
2837
  const isGroupRow = row.getIsGrouped();
2638
2838
  const isInteractive = rowGesturesFor(row);
2839
+ // The hover tint promises that the row answers the pointer, so
2840
+ // by default it follows the gestures the row takes: the ones
2841
+ // behind `isInteractive`, and the context menu - which a group
2842
+ // row sits out as well, see the row's onContextMenu below.
2843
+ const highlightsOnHover =
2844
+ highlightOnHover ??
2845
+ (isInteractive ||
2846
+ (renderRowContextMenu !== undefined && !isGroupRow));
2639
2847
  const rowCells = [
2640
2848
  ...row.getStartVisibleCells(),
2641
2849
  ...row.getCenterVisibleCells(),
2642
2850
  ...row.getEndVisibleCells(),
2643
2851
  ];
2644
- // Row mode opens every editable cell of the row at once, and
2645
- // rows accumulate: a second row opening leaves the first one
2646
- // open, so every row holding a form shows its editors. Which of
2647
- // them holds the caret is not decided here - see
2648
- // placeEditCaret, which places it once per gesture.
2649
- //
2650
- // A parked row is not one of them. It keeps its form, so it is
2651
- // still "open", but it has been decided: it renders its draft
2652
- // through the column's own renderer until `begin` reopens it.
2653
- // A committed entry row in the body has a form too - every
2654
- // entry row does - but it is a value row until reopened, and
2655
- // reopening takes it back to the entry block.
2656
- const rowEditing =
2657
- features.editMode === "row" &&
2658
- editOpenRowIdSet.has(row.id) &&
2659
- !editDraftRowIdSet.has(row.id) &&
2660
- !newRowIdSet.has(row.id);
2852
+ const rowEditing = isRowEditing(row);
2853
+ const spans = colSpansOf(row);
2661
2854
  // Cell selection takes the body's tab stop off the row and puts
2662
2855
  // it on a cell - two stops per row would make Tab a way of
2663
2856
  // walking the grid, which is what the arrow keys are for. Space
@@ -2758,6 +2951,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2758
2951
  data-selects-on-click={
2759
2952
  (selectsOnRowClick && !isGroupRow) || undefined
2760
2953
  }
2954
+ data-highlight-on-hover={highlightsOnHover || undefined}
2761
2955
  aria-selected={
2762
2956
  features.rowSelection ? row.getIsSelected() : undefined
2763
2957
  }
@@ -2835,6 +3029,10 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2835
3029
  }
2836
3030
  >
2837
3031
  {rowCells.map((cell, cellIndex) => {
3032
+ // Covered by a spanning cell to its left - see colSpansOf.
3033
+ if (spans !== null && spans[cellIndex] === 0) return null;
3034
+ const colSpan = spans?.[cellIndex] ?? 1;
3035
+ const lastIndex = cellIndex + colSpan - 1;
2838
3036
  const isEditingCell =
2839
3037
  (editActive !== null &&
2840
3038
  editActive.rowId === row.id &&
@@ -2845,7 +3043,12 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2845
3043
  key={cell.id}
2846
3044
  cell={cell}
2847
3045
  rowHeight={rowHeight}
2848
- layout={layoutFor(cell.column.id)}
3046
+ colSpan={colSpan}
3047
+ layout={
3048
+ colSpan > 1
3049
+ ? spanLayout(cellIndex, lastIndex)
3050
+ : layoutFor(cell.column.id)
3051
+ }
2849
3052
  contentOverride={
2850
3053
  rowNumberById !== null &&
2851
3054
  cell.column.id === ROW_NUMBER_COLUMN_ID
@@ -2897,10 +3100,12 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2897
3100
  ? {
2898
3101
  rowId: row.id,
2899
3102
  columnIndex: cellIndex + 1,
2900
- focused: isCellAt(
2901
- focusedCell,
3103
+ // A spanning cell stands for every column it
3104
+ // covers, wherever the focus was put.
3105
+ focused: isFocusedWithin(
2902
3106
  row.id,
2903
- cell.column.id,
3107
+ cellIndex,
3108
+ lastIndex,
2904
3109
  ),
2905
3110
  // Focusable by click and by the grid, never by
2906
3111
  // Tab - the guards are the way in. See
@@ -2908,11 +3113,18 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2908
3113
  tabIndex: -1,
2909
3114
  // "Selected" means "in the block Ctrl+C would
2910
3115
  // take" - which under `"single"` is the one cell
2911
- // the user is standing on.
3116
+ // the user is standing on. A spanning cell is in
3117
+ // it by its first column alone - see
3118
+ // isWithinBounds.
2912
3119
  selected: cellRangeSelection
2913
3120
  ? isWithinBounds(rangeBounds, viewIndex, cellIndex)
2914
- : isCellAt(focusedCell, row.id, cell.column.id),
2915
- edges: boundsEdges(rangeBounds, viewIndex, cellIndex),
3121
+ : isFocusedWithin(row.id, cellIndex, lastIndex),
3122
+ edges: boundsEdges(
3123
+ rangeBounds,
3124
+ viewIndex,
3125
+ cellIndex,
3126
+ lastIndex,
3127
+ ),
2916
3128
  }
2917
3129
  : undefined
2918
3130
  }
@@ -5,9 +5,11 @@
5
5
  A box-shadow is drawn per cell, and its blur fades in at each cell's own top
6
6
  and bottom edge - stacked down a column that reads as banding at every row
7
7
  seam. Instead: a gradient pseudo element pinned to `top: 0; bottom: 0` so
8
- consecutive cells butt together into one unbroken band. Mantine sets
9
- `color-scheme` on the root, so light-dark() resolves without a second
10
- selector.
8
+ consecutive cells butt together into one unbroken band.
9
+
10
+ The dark scheme is a second rule under Mantine's `data-mantine-color-scheme`
11
+ attribute, not the `light-dark` function: scripts/package-stylesheets.test.mjs
12
+ says why.
11
13
 
12
14
  No border. A 1px line the colour of the grid's own borders drew a hard rule
13
15
  between the pinned lanes and the data, so the pinned columns read as a
@@ -31,8 +33,8 @@
31
33
  left: 100%;
32
34
  background: linear-gradient(
33
35
  to right,
34
- light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.26)),
35
- light-dark(rgba(0, 0, 0, 0.025), rgba(0, 0, 0, 0.09)) 45%,
36
+ rgba(0, 0, 0, 0.07),
37
+ rgba(0, 0, 0, 0.025) 45%,
36
38
  transparent
37
39
  );
38
40
  }
@@ -41,8 +43,26 @@
41
43
  right: 100%;
42
44
  background: linear-gradient(
43
45
  to left,
44
- light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.26)),
45
- light-dark(rgba(0, 0, 0, 0.025), rgba(0, 0, 0, 0.09)) 45%,
46
+ rgba(0, 0, 0, 0.07),
47
+ rgba(0, 0, 0, 0.025) 45%,
48
+ transparent
49
+ );
50
+ }
51
+
52
+ :where([data-mantine-color-scheme="dark"]) .stickyLeft::after {
53
+ background: linear-gradient(
54
+ to right,
55
+ rgba(0, 0, 0, 0.26),
56
+ rgba(0, 0, 0, 0.09) 45%,
57
+ transparent
58
+ );
59
+ }
60
+
61
+ :where([data-mantine-color-scheme="dark"]) .stickyRight::after {
62
+ background: linear-gradient(
63
+ to left,
64
+ rgba(0, 0, 0, 0.26),
65
+ rgba(0, 0, 0, 0.09) 45%,
46
66
  transparent
47
67
  );
48
68
  }