@jielga/tmdatagrid 2.1.4 → 2.2.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 (48) hide show
  1. package/dist/index.d.ts +38 -2
  2. package/dist/index.js +1329 -1246
  3. package/dist/index.js.map +1 -1
  4. package/docs/cell-selection.md +1 -1
  5. package/docs/column-layout.md +13 -8
  6. package/docs/columns.md +57 -2
  7. package/docs/export.md +2 -2
  8. package/docs/filtering.md +105 -15
  9. package/docs/getting-started.md +40 -0
  10. package/docs/grouping.md +9 -6
  11. package/docs/loading-and-empty.md +29 -9
  12. package/docs/localization.md +130 -3
  13. package/docs/menu.md +1 -1
  14. package/docs/pagination.md +26 -7
  15. package/docs/row-details.md +36 -4
  16. package/docs/row-interaction.md +1 -1
  17. package/docs/row-selection.md +6 -7
  18. package/docs/scrolling.md +1 -1
  19. package/docs/server-side.md +3 -1
  20. package/docs/testing.md +18 -0
  21. package/docs/use-tm-data-grid.md +69 -34
  22. package/package.json +1 -1
  23. package/skills/appearance/SKILL.md +6 -4
  24. package/skills/appearance/references/labels.md +126 -0
  25. package/skills/cell-selection/SKILL.md +2 -2
  26. package/skills/columns/SKILL.md +42 -41
  27. package/skills/columns/references/columns-api.md +15 -1
  28. package/skills/data/SKILL.md +77 -50
  29. package/skills/editing/SKILL.md +1 -1
  30. package/skills/filtering/SKILL.md +71 -49
  31. package/skills/filtering/references/custom-filters.md +118 -0
  32. package/skills/getting-started/SKILL.md +37 -2
  33. package/skills/grouping/SKILL.md +16 -9
  34. package/skills/migrating-to-2/SKILL.md +1 -1
  35. package/skills/options/SKILL.md +131 -51
  36. package/skills/rows/SKILL.md +53 -31
  37. package/skills/server-side/SKILL.md +1 -1
  38. package/skills/testing/SKILL.md +12 -1
  39. package/skills/testing-components/SKILL.md +1 -1
  40. package/skills/testing-editing/SKILL.md +1 -1
  41. package/src/components/TMDataGridTable.tsx +241 -41
  42. package/src/core/autosize.ts +19 -7
  43. package/src/core/cellRange.ts +11 -2
  44. package/src/core/colSpan.ts +78 -0
  45. package/src/core/columnUtils.ts +7 -0
  46. package/src/core/filterOperators.ts +16 -1
  47. package/src/index.ts +4 -0
  48. package/src/useTMDataGrid.tsx +38 -0
@@ -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 { isFilterActive, isTMDataGridFilterValue } 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
@@ -1182,6 +1198,111 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1182
1198
  ...rightLeafColumns,
1183
1199
  ];
1184
1200
 
1201
+ // `meta.colSpan`, resolved per row over the displayed columns. Built from
1202
+ // the three leaf arrays rather than `column.getIsPinned()`, for the reason
1203
+ // the sticky offsets below give. `null` while no column asks to span, so a
1204
+ // plain grid never calls into it.
1205
+ const spanColumns = orderedColumns.some(
1206
+ (column) => column.columnDef.meta?.colSpan !== undefined,
1207
+ )
1208
+ ? orderedColumns.map((column, index) => ({
1209
+ id: column.id,
1210
+ colSpan: column.columnDef.meta?.colSpan,
1211
+ section: (index < leftLeafColumns.length
1212
+ ? "left"
1213
+ : index < leftLeafColumns.length + centerLeafColumns.length
1214
+ ? "center"
1215
+ : "right") as TMDataGridColumnSection,
1216
+ }))
1217
+ : null;
1218
+ /**
1219
+ * Row mode opens every editable cell of the row at once, and rows
1220
+ * accumulate: a second row opening leaves the first one open, so every row
1221
+ * holding a form shows its editors. Which of them holds the caret is not
1222
+ * decided here - see placeEditCaret, which places it once per gesture.
1223
+ *
1224
+ * A parked row is not one of them. It keeps its form, so it is still
1225
+ * "open", but it has been decided: it renders its draft through the
1226
+ * column's own renderer until `begin` reopens it. A committed entry row in
1227
+ * the body has a form too - every entry row does - but it is a value row
1228
+ * until reopened, and reopening takes it back to the entry block.
1229
+ *
1230
+ * Such a row also drops its spans: the form has an editor in every column.
1231
+ */
1232
+ const isRowEditing = (row: Row<TMDataGridFeatures, TMDataGridRowData>) =>
1233
+ features.editMode === "row" &&
1234
+ editOpenRowIdSet.has(row.id) &&
1235
+ !editDraftRowIdSet.has(row.id) &&
1236
+ !newRowIdSet.has(row.id);
1237
+ const colSpanCache = new Map<string, Array<number> | null>();
1238
+ /**
1239
+ * The spans of one row - see {@link resolveColSpans} - or `null` when every
1240
+ * cell is one column wide. Group rows keep their cells: the tree cell and
1241
+ * the aggregates are the grid's, not a record's.
1242
+ */
1243
+ const colSpansOf = (
1244
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
1245
+ ): Array<number> | null => {
1246
+ if (spanColumns === null || row.getIsGrouped() || isRowEditing(row)) {
1247
+ return null;
1248
+ }
1249
+ const cached = colSpanCache.get(row.id);
1250
+ if (cached !== undefined) return cached;
1251
+ const spans = resolveColSpans(spanColumns, (index) => {
1252
+ const colSpan = spanColumns[index]?.colSpan;
1253
+ return typeof colSpan === "function"
1254
+ ? colSpan({ row, table })
1255
+ : (colSpan ?? 1);
1256
+ });
1257
+ // The open cell editor lives in its column's cell. A span landing on that
1258
+ // column - the row expanding under the edit, say - would unmount the
1259
+ // editor without closing it, so the row keeps its cells until the edit
1260
+ // ends.
1261
+ const editIndex =
1262
+ editActive !== null && editActive.rowId === row.id
1263
+ ? orderedColumns.findIndex(
1264
+ (column) => column.id === editActive.columnId,
1265
+ )
1266
+ : -1;
1267
+ const resolved =
1268
+ spans.every((span) => span === 1) ||
1269
+ (editIndex >= 0 && spans[editIndex] === 0)
1270
+ ? null
1271
+ : spans;
1272
+ colSpanCache.set(row.id, resolved);
1273
+ return resolved;
1274
+ };
1275
+ /** Whether the column at `columnIndex` is covered by a span on `row`. */
1276
+ const isCoveredCell = (
1277
+ row: Row<TMDataGridFeatures, TMDataGridRowData>,
1278
+ columnIndex: number,
1279
+ ): boolean => {
1280
+ const spans = colSpansOf(row);
1281
+ return spans !== null && spans[columnIndex] === 0;
1282
+ };
1283
+ /**
1284
+ * A position moved onto the cell that renders it, under spans: the input
1285
+ * itself when its cell renders, so a caller can compare by identity. `row`
1286
+ * is the position's row; `undefined` for a pinned row, which is not in
1287
+ * `rows` and keeps its position as it is.
1288
+ */
1289
+ const toRenderedCell = (
1290
+ position: TMDataGridCellPosition,
1291
+ row: Row<TMDataGridFeatures, TMDataGridRowData> | undefined,
1292
+ ): TMDataGridCellPosition => {
1293
+ if (spanColumns === null || row === undefined) return position;
1294
+ const spans = colSpansOf(row);
1295
+ if (spans === null) return position;
1296
+ const columnIndex = orderedColumns.findIndex(
1297
+ (column) => column.id === position.columnId,
1298
+ );
1299
+ if (columnIndex < 0) return position;
1300
+ const anchorIndex = spanAnchorIndex(spans, columnIndex);
1301
+ if (anchorIndex === columnIndex) return position;
1302
+ const anchor = orderedColumns[anchorIndex];
1303
+ return anchor ? { ...position, columnId: anchor.id } : position;
1304
+ };
1305
+
1185
1306
  // Headers and cells must follow the same left → center → right order as the
1186
1307
  // column tracks, otherwise pinning a column would shuffle it out of its lane.
1187
1308
  const centerHeaderGroups = table.getCenterHeaderGroups();
@@ -1208,7 +1329,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1208
1329
  const isFixed = column.columnDef.minSize === column.columnDef.maxSize;
1209
1330
  const isResized = column.id in columnSizing;
1210
1331
  const isPinned = column.getIsPinned() !== false;
1211
- const minWidth = column.columnDef.minSize ?? 80;
1332
+ const minWidth = column.columnDef.minSize ?? DEFAULT_COLUMN_MIN_SIZE;
1212
1333
  if (isFixed || isResized || isPinned) {
1213
1334
  return {
1214
1335
  id: column.id,
@@ -1269,6 +1390,31 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1269
1390
  }
1270
1391
  const layoutFor = (columnId: string) =>
1271
1392
  columnLayout.get(columnId) ?? UNPINNED_LAYOUT;
1393
+ /**
1394
+ * The layout of a cell spanning from `columnId` to the column at
1395
+ * `lastIndex`. A span never leaves its pinned block, so only the sticky
1396
+ * offset and the boundary shadow change: a right-pinned cell sticks at the
1397
+ * offset of the last column it covers, and the cell covering a block's
1398
+ * boundary column carries its shadow.
1399
+ */
1400
+ const spanLayout = (
1401
+ firstIndex: number,
1402
+ lastIndex: number,
1403
+ ): TMDataGridColumnLayout => {
1404
+ const first = layoutFor(orderedColumns[firstIndex]!.id);
1405
+ if (first.pinnedAt === false) return first;
1406
+ const covered = orderedColumns
1407
+ .slice(firstIndex, lastIndex + 1)
1408
+ .map((column) => layoutFor(column.id));
1409
+ return {
1410
+ ...first,
1411
+ offset:
1412
+ first.pinnedAt === "right"
1413
+ ? (covered.at(-1)?.offset ?? first.offset)
1414
+ : first.offset,
1415
+ isBoundary: covered.some((layout) => layout.isBoundary),
1416
+ };
1417
+ };
1272
1418
 
1273
1419
  /**
1274
1420
  * A running resize drag paints itself, straight onto the grid element.
@@ -1332,11 +1478,20 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1332
1478
  // Pinned rows count as content: with every visible row pinned to an edge,
1333
1479
  // an empty-state message beside them would contradict what is on screen.
1334
1480
  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.
1481
+ // Which emptiness this is. A column filter counts by the rule the pills, the
1482
+ // header indicator and the page reset use, so the grid has one definition
1483
+ // of "active": an envelope counts once `isFilterActive` says so - the
1484
+ // half-typed `{ operator, value: "" }` the filter panel seeds filters
1485
+ // nothing - and a value that is not an envelope always counts, since a raw
1486
+ // value written by consumer code narrows the rows as far as the grid can
1487
+ // tell. The body already subscribes to the whole table store, so these
1488
+ // reads are reactive.
1337
1489
  const emptyGlobalFilter = table.store.state.globalFilter;
1338
1490
  const hasActiveFilters =
1339
- table.store.state.columnFilters.length > 0 ||
1491
+ table.store.state.columnFilters.some(
1492
+ (entry) =>
1493
+ !isTMDataGridFilterValue(entry.value) || isFilterActive(entry.value),
1494
+ ) ||
1340
1495
  (typeof emptyGlobalFilter === "string"
1341
1496
  ? emptyGlobalFilter.trim() !== ""
1342
1497
  : emptyGlobalFilter != null);
@@ -1612,11 +1767,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1612
1767
  findCellElement(container, tabStopCell)?.focus({ preventScroll: true });
1613
1768
  };
1614
1769
 
1615
- const isCellAt = (
1616
- position: TMDataGridCellPosition | null,
1617
- rowId: string,
1618
- columnId: string,
1619
- ) => position !== null && position.rowId === rowId && position.columnId === columnId;
1770
+ /**
1771
+ * Whether the focused cell is on this row, in columns `first` to `last` -
1772
+ * one column for most cells, every column it covers for a spanning one.
1773
+ */
1774
+ const isFocusedWithin = (rowId: string, first: number, last: number) =>
1775
+ focusedCell !== null &&
1776
+ focusedCell.rowId === rowId &&
1777
+ focusedColumnIndex >= first &&
1778
+ focusedColumnIndex <= last;
1620
1779
 
1621
1780
  // The rectangle, resolved from ids to the indices the cells are rendered at.
1622
1781
  // `null` whenever a corner has been filtered or hidden away - the range keeps
@@ -1771,7 +1930,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1771
1930
  const row = rows[coords.rowIndex];
1772
1931
  const column = orderedColumns[coords.columnIndex];
1773
1932
  if (!row || !column) return;
1774
- const cell = { rowId: row.id, columnId: column.id };
1933
+ // A column covered by a span on this row has no cell; the one covering it
1934
+ // takes the focus.
1935
+ const cell = toRenderedCell({ rowId: row.id, columnId: column.id }, row);
1775
1936
  // The state moves; the effect below is what makes the DOM follow, because
1776
1937
  // the element for an off-screen row does not exist yet to be focused.
1777
1938
  wantsCellFocusRef.current = true;
@@ -1911,12 +2072,15 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1911
2072
  direction: move,
1912
2073
  rowCount: rows.length,
1913
2074
  columnCount: orderedColumns.length,
2075
+ // A column covered by a span on its row has no cell to hold an editor,
2076
+ // and a move onto it would land back on the spanning cell just left.
1914
2077
  isEditable: (coords) => {
1915
2078
  const row = rows[coords.rowIndex];
1916
2079
  const column = orderedColumns[coords.columnIndex];
1917
2080
  return (
1918
2081
  row !== undefined &&
1919
2082
  column !== undefined &&
2083
+ !isCoveredCell(row, coords.columnIndex) &&
1920
2084
  edit.canEditCell(row, column)
1921
2085
  );
1922
2086
  },
@@ -2035,10 +2199,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2035
2199
  const nextCell =
2036
2200
  container === null
2037
2201
  ? null
2038
- : findCellElement(container, {
2039
- rowId: nextRow.id,
2040
- columnId: nextColumn.id,
2041
- });
2202
+ : findCellElement(
2203
+ container,
2204
+ toRenderedCell(
2205
+ { rowId: nextRow.id, columnId: nextColumn.id },
2206
+ nextRow,
2207
+ ),
2208
+ );
2042
2209
  if (nextCell !== null) nextCell.focus({ preventScroll: true });
2043
2210
  else target.blur();
2044
2211
  return;
@@ -2175,10 +2342,19 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2175
2342
  return;
2176
2343
  }
2177
2344
 
2345
+ // Right steps off a spanning cell from the last column it covers, or it
2346
+ // would land back on itself.
2347
+ const spans = rows[rowIndex] ? colSpansOf(rows[rowIndex]) : null;
2178
2348
  const next = resolveCellMove({
2179
2349
  key: event.key,
2180
2350
  ctrlKey: event.ctrlKey || event.metaKey,
2181
- from: { rowIndex, columnIndex },
2351
+ from: {
2352
+ rowIndex,
2353
+ columnIndex:
2354
+ spans !== null && event.key === "ArrowRight"
2355
+ ? spanEndIndex(spans, columnIndex)
2356
+ : columnIndex,
2357
+ },
2182
2358
  rowCount: rows.length,
2183
2359
  columnCount: orderedColumns.length,
2184
2360
  pageRows: Math.max(
@@ -2192,7 +2368,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2192
2368
  // through at the last row would scroll the body out from under a focus that
2193
2369
  // did not move.
2194
2370
  event.preventDefault();
2195
- if (next.rowIndex === rowIndex && next.columnIndex === columnIndex) return;
2371
+ // A move landing on this cell again - clamped at an edge, or onto a column
2372
+ // this cell covers - changes nothing, the rectangle included.
2373
+ const landing =
2374
+ spans === null ? next.columnIndex : spanAnchorIndex(spans, next.columnIndex);
2375
+ if (next.rowIndex === rowIndex && landing === columnIndex) return;
2196
2376
  // Shift extends the rectangle instead of collapsing it - the same key that
2197
2377
  // extends a text selection, a file list and a spreadsheet range.
2198
2378
  moveFocusedCell(next, { extend: event.shiftKey && cellRangeSelection });
@@ -2230,6 +2410,23 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2230
2410
  // never in this grid.
2231
2411
  const activeElement = container.ownerDocument.activeElement;
2232
2412
 
2413
+ // The column the focus sits on has just been covered by a span - the row
2414
+ // expanded under it, say. Its cell is gone, and the browser dropped the
2415
+ // focus with it, so the state follows the cell that now stands for the
2416
+ // column, and the render after this one puts the focus there. A focus
2417
+ // that had moved elsewhere on the page stays there: only the ring moves.
2418
+ const rendered = toRenderedCell(focusedCell, rows[focusedRowIndex]);
2419
+ if (rendered !== focusedCell) {
2420
+ if (
2421
+ activeElement === null ||
2422
+ activeElement === container.ownerDocument.body
2423
+ ) {
2424
+ wantsCellFocusRef.current = true;
2425
+ }
2426
+ ui.actions.setFocusedCell(rendered);
2427
+ return;
2428
+ }
2429
+
2233
2430
  // Never take the focus from elsewhere on the page. A consumer moving the
2234
2431
  // cell while the user is typing in a form somewhere means "move the ring",
2235
2432
  // not "move the caret" - the scroll above already did the visible part.
@@ -2250,7 +2447,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2250
2447
  return;
2251
2448
  }
2252
2449
 
2253
- const cellElement = findCellElement(container, focusedCell);
2450
+ const cellElement = findCellElement(container, rendered);
2254
2451
  // Still scrolling. The intent stays up, and the next render tries again.
2255
2452
  if (cellElement === null) return;
2256
2453
  wantsCellFocusRef.current = false;
@@ -2641,23 +2838,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2641
2838
  ...row.getCenterVisibleCells(),
2642
2839
  ...row.getEndVisibleCells(),
2643
2840
  ];
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);
2841
+ const rowEditing = isRowEditing(row);
2842
+ const spans = colSpansOf(row);
2661
2843
  // Cell selection takes the body's tab stop off the row and puts
2662
2844
  // it on a cell - two stops per row would make Tab a way of
2663
2845
  // walking the grid, which is what the arrow keys are for. Space
@@ -2835,6 +3017,10 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2835
3017
  }
2836
3018
  >
2837
3019
  {rowCells.map((cell, cellIndex) => {
3020
+ // Covered by a spanning cell to its left - see colSpansOf.
3021
+ if (spans !== null && spans[cellIndex] === 0) return null;
3022
+ const colSpan = spans?.[cellIndex] ?? 1;
3023
+ const lastIndex = cellIndex + colSpan - 1;
2838
3024
  const isEditingCell =
2839
3025
  (editActive !== null &&
2840
3026
  editActive.rowId === row.id &&
@@ -2845,7 +3031,12 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2845
3031
  key={cell.id}
2846
3032
  cell={cell}
2847
3033
  rowHeight={rowHeight}
2848
- layout={layoutFor(cell.column.id)}
3034
+ colSpan={colSpan}
3035
+ layout={
3036
+ colSpan > 1
3037
+ ? spanLayout(cellIndex, lastIndex)
3038
+ : layoutFor(cell.column.id)
3039
+ }
2849
3040
  contentOverride={
2850
3041
  rowNumberById !== null &&
2851
3042
  cell.column.id === ROW_NUMBER_COLUMN_ID
@@ -2897,10 +3088,12 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2897
3088
  ? {
2898
3089
  rowId: row.id,
2899
3090
  columnIndex: cellIndex + 1,
2900
- focused: isCellAt(
2901
- focusedCell,
3091
+ // A spanning cell stands for every column it
3092
+ // covers, wherever the focus was put.
3093
+ focused: isFocusedWithin(
2902
3094
  row.id,
2903
- cell.column.id,
3095
+ cellIndex,
3096
+ lastIndex,
2904
3097
  ),
2905
3098
  // Focusable by click and by the grid, never by
2906
3099
  // Tab - the guards are the way in. See
@@ -2908,11 +3101,18 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2908
3101
  tabIndex: -1,
2909
3102
  // "Selected" means "in the block Ctrl+C would
2910
3103
  // take" - which under `"single"` is the one cell
2911
- // the user is standing on.
3104
+ // the user is standing on. A spanning cell is in
3105
+ // it by its first column alone - see
3106
+ // isWithinBounds.
2912
3107
  selected: cellRangeSelection
2913
3108
  ? isWithinBounds(rangeBounds, viewIndex, cellIndex)
2914
- : isCellAt(focusedCell, row.id, cell.column.id),
2915
- edges: boundsEdges(rangeBounds, viewIndex, cellIndex),
3109
+ : isFocusedWithin(row.id, cellIndex, lastIndex),
3110
+ edges: boundsEdges(
3111
+ rangeBounds,
3112
+ viewIndex,
3113
+ cellIndex,
3114
+ lastIndex,
3115
+ ),
2916
3116
  }
2917
3117
  : undefined
2918
3118
  }
@@ -1,6 +1,7 @@
1
1
  import type { RowData } from "@tanstack/react-table";
2
2
  import type { TMDataGridRowData } from "../TMDataGridContext";
3
3
  import type { TMDataGridTable } from "../useTMDataGrid";
4
+ import { DEFAULT_COLUMN_MIN_SIZE } from "./columnUtils";
4
5
  import { isHTMLElement } from "./dom";
5
6
 
6
7
  /**
@@ -40,30 +41,41 @@ function contentSpanWidth(content: HTMLElement): number {
40
41
  return width > 0 ? width : content.scrollWidth;
41
42
  }
42
43
 
43
- /** One column's body cells that are currently in the DOM. */
44
+ /**
45
+ * One column's body cells that are currently in the DOM.
46
+ *
47
+ * `measurable` leaves out the cells spanning several columns (`meta.colSpan`):
48
+ * such a cell says nothing about this one column's width.
49
+ */
44
50
  function mountedCells(
45
51
  container: HTMLElement,
46
52
  columnId: string,
53
+ { measurable }: { measurable: boolean },
47
54
  ): NodeListOf<HTMLElement> {
48
55
  const id = CSS.escape(columnId);
56
+ const unspanned = measurable ? ":not([data-col-span])" : "";
49
57
  return container.querySelectorAll<HTMLElement>(
50
- `[role="cell"][data-column-id="${id}"],` +
51
- `[role="gridcell"][data-column-id="${id}"]`,
58
+ `[role="cell"][data-column-id="${id}"]${unspanned},` +
59
+ `[role="gridcell"][data-column-id="${id}"]${unspanned}`,
52
60
  );
53
61
  }
54
62
 
55
63
  /**
56
- * Whether this column has any body cell in the DOM to measure.
64
+ * Whether this column has any body cell in the DOM.
57
65
  *
58
66
  * A caller sizing a column on its own schedule rather than from a gesture has
59
67
  * to ask: the virtualizer mounts its first rows a render *after* the grid
60
68
  * itself mounts, and a measurement taken before that fits the header alone.
69
+ *
70
+ * A spanning cell counts. A column whose every mounted cell spans has its
71
+ * rows, and measuring it fits the header, which is the width it gets; left
72
+ * out, the column would wait for cells that never come.
61
73
  */
62
74
  export function hasMountedCells(
63
75
  container: HTMLElement,
64
76
  columnId: string,
65
77
  ): boolean {
66
- return mountedCells(container, columnId).length > 0;
78
+ return mountedCells(container, columnId, { measurable: false }).length > 0;
67
79
  }
68
80
 
69
81
  /**
@@ -85,7 +97,7 @@ export function measureColumnContentWidth({
85
97
  }): number {
86
98
  let widest = 0;
87
99
 
88
- for (const cell of mountedCells(container, columnId)) {
100
+ for (const cell of mountedCells(container, columnId, { measurable: true })) {
89
101
  const content = cell.firstElementChild;
90
102
  if (!isHTMLElement(content)) continue;
91
103
  const styles = getComputedStyle(cell);
@@ -143,7 +155,7 @@ export function autosizeColumn<TData extends RowData>({
143
155
  const measured = measureColumnContentWidth({ container, columnId });
144
156
  if (measured <= 0) return false;
145
157
 
146
- const minSize = column.columnDef.minSize ?? 80;
158
+ const minSize = column.columnDef.minSize ?? DEFAULT_COLUMN_MIN_SIZE;
147
159
  const maxSize = column.columnDef.maxSize ?? Number.POSITIVE_INFINITY;
148
160
  const width = Math.round(Math.min(Math.max(measured, minSize), maxSize));
149
161
 
@@ -70,7 +70,13 @@ export function resolveRangeBounds({
70
70
  };
71
71
  }
72
72
 
73
- /** Whether a cell is inside the rectangle. */
73
+ /**
74
+ * Whether a cell is inside the rectangle.
75
+ *
76
+ * A cell spanning several columns (`meta.colSpan`) is addressed by its first
77
+ * column: it is inside when that column is, and a rectangle over only the
78
+ * columns it covers leaves it out.
79
+ */
74
80
  export function isWithinBounds(
75
81
  bounds: TMDataGridRangeBounds | null,
76
82
  rowIndex: number,
@@ -105,14 +111,17 @@ export function boundsEdges(
105
111
  bounds: TMDataGridRangeBounds | null,
106
112
  rowIndex: number,
107
113
  columnIndex: number,
114
+ lastColumnIndex: number = columnIndex,
108
115
  ): { top: boolean; bottom: boolean; left: boolean; right: boolean } | null {
109
116
  if (!isWithinBounds(bounds, rowIndex, columnIndex) || bounds === null) {
110
117
  return null;
111
118
  }
119
+ // A cell spanning columns up to `lastColumnIndex` is one box: the
120
+ // rectangle's right edge falling anywhere inside it is drawn on its side.
112
121
  return {
113
122
  top: rowIndex === bounds.top,
114
123
  bottom: rowIndex === bounds.bottom,
115
124
  left: columnIndex === bounds.left,
116
- right: columnIndex === bounds.right,
125
+ right: bounds.right <= lastColumnIndex,
117
126
  };
118
127
  }
@@ -0,0 +1,78 @@
1
+ import { isGeneratedColumn } from "./columnUtils";
2
+
3
+ /** Which pinned block a displayed column sits in. */
4
+ export type TMDataGridColumnSection = "left" | "center" | "right";
5
+
6
+ /**
7
+ * How wide each displayed cell of one row is, in columns.
8
+ *
9
+ * `spans[i]` is `n >= 1` for a cell that renders and covers `n` tracks from
10
+ * column `i` rightwards, and `0` for a cell covered by one to its left, which
11
+ * does not render. The array runs in display order, the order of
12
+ * `orderedColumns`, and its entries always sum to the column count.
13
+ *
14
+ * A span stops short at the first column it may not cover:
15
+ *
16
+ * - a generated lane - the checkbox, tree, details and edit lanes stay
17
+ * where they are whatever a data cell asks for, which is also why
18
+ * `Infinity` reads as "to the last of my columns";
19
+ * - the edge of the cell's pinned block - a pinned cell is sticky at the
20
+ * offset of its own column, and one stretching into the scrolling centre
21
+ * would slide over the cells it is meant to leave.
22
+ *
23
+ * A generated lane never spans, and a request below 1, or one that is not a
24
+ * number, is 1.
25
+ */
26
+ export function resolveColSpans(
27
+ columns: ReadonlyArray<{ id: string; section: TMDataGridColumnSection }>,
28
+ requestedSpan: (columnIndex: number) => number,
29
+ ): Array<number> {
30
+ const spans: Array<number> = [];
31
+ let index = 0;
32
+ while (index < columns.length) {
33
+ const column = columns[index]!;
34
+ const requested = isGeneratedColumn(column.id) ? 1 : requestedSpan(index);
35
+ const wanted =
36
+ typeof requested === "number" && requested >= 1
37
+ ? Math.floor(requested)
38
+ : 1;
39
+ let span = 1;
40
+ while (span < wanted) {
41
+ const next = columns[index + span];
42
+ if (
43
+ next === undefined ||
44
+ next.section !== column.section ||
45
+ isGeneratedColumn(next.id)
46
+ ) {
47
+ break;
48
+ }
49
+ span += 1;
50
+ }
51
+ spans.push(span);
52
+ for (let covered = 1; covered < span; covered += 1) spans.push(0);
53
+ index += span;
54
+ }
55
+ return spans;
56
+ }
57
+
58
+ /**
59
+ * The column a position on a row lands on once spans are applied: itself
60
+ * when its cell renders, otherwise the spanning cell covering it.
61
+ */
62
+ export function spanAnchorIndex(
63
+ spans: ReadonlyArray<number>,
64
+ columnIndex: number,
65
+ ): number {
66
+ let index = columnIndex;
67
+ while (index > 0 && spans[index] === 0) index -= 1;
68
+ return index;
69
+ }
70
+
71
+ /** The last column the cell at `columnIndex` covers - itself when unspanned. */
72
+ export function spanEndIndex(
73
+ spans: ReadonlyArray<number>,
74
+ columnIndex: number,
75
+ ): number {
76
+ const anchor = spanAnchorIndex(spans, columnIndex);
77
+ return anchor + Math.max(1, spans[anchor] ?? 1) - 1;
78
+ }
@@ -160,6 +160,13 @@ export function isColumnReorderable(column: ColumnLike): boolean {
160
160
  return column.parent === undefined;
161
161
  }
162
162
 
163
+ /**
164
+ * The `minSize` of a column that sets none: the floor of its fluid track, of a
165
+ * resize drag and of its pinned width. Passed to TanStack as
166
+ * `defaultColumn.minSize`, which otherwise fills in 20.
167
+ */
168
+ export const DEFAULT_COLUMN_MIN_SIZE = 80;
169
+
163
170
  /** The list length from which a column chooser shows its search box. */
164
171
  export const COLUMN_SEARCH_MIN = 6;
165
172