@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.
- package/dist/index.d.ts +38 -2
- package/dist/index.js +1329 -1246
- package/dist/index.js.map +1 -1
- package/docs/cell-selection.md +1 -1
- package/docs/column-layout.md +13 -8
- package/docs/columns.md +57 -2
- package/docs/export.md +2 -2
- package/docs/filtering.md +105 -15
- package/docs/getting-started.md +40 -0
- package/docs/grouping.md +9 -6
- package/docs/loading-and-empty.md +29 -9
- package/docs/localization.md +130 -3
- package/docs/menu.md +1 -1
- package/docs/pagination.md +26 -7
- package/docs/row-details.md +36 -4
- package/docs/row-interaction.md +1 -1
- package/docs/row-selection.md +6 -7
- package/docs/scrolling.md +1 -1
- package/docs/server-side.md +3 -1
- package/docs/testing.md +18 -0
- package/docs/use-tm-data-grid.md +69 -34
- package/package.json +1 -1
- package/skills/appearance/SKILL.md +6 -4
- package/skills/appearance/references/labels.md +126 -0
- package/skills/cell-selection/SKILL.md +2 -2
- package/skills/columns/SKILL.md +42 -41
- package/skills/columns/references/columns-api.md +15 -1
- package/skills/data/SKILL.md +77 -50
- package/skills/editing/SKILL.md +1 -1
- package/skills/filtering/SKILL.md +71 -49
- package/skills/filtering/references/custom-filters.md +118 -0
- package/skills/getting-started/SKILL.md +37 -2
- package/skills/grouping/SKILL.md +16 -9
- package/skills/migrating-to-2/SKILL.md +1 -1
- package/skills/options/SKILL.md +131 -51
- package/skills/rows/SKILL.md +53 -31
- package/skills/server-side/SKILL.md +1 -1
- package/skills/testing/SKILL.md +12 -1
- package/skills/testing-components/SKILL.md +1 -1
- package/skills/testing-editing/SKILL.md +1 -1
- package/src/components/TMDataGridTable.tsx +241 -41
- package/src/core/autosize.ts +19 -7
- package/src/core/cellRange.ts +11 -2
- package/src/core/colSpan.ts +78 -0
- package/src/core/columnUtils.ts +7 -0
- package/src/core/filterOperators.ts +16 -1
- package/src/index.ts +4 -0
- 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 ??
|
|
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.
|
|
1336
|
-
//
|
|
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.
|
|
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
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
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(
|
|
2039
|
-
|
|
2040
|
-
|
|
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: {
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
2645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2901
|
-
|
|
3091
|
+
// A spanning cell stands for every column it
|
|
3092
|
+
// covers, wherever the focus was put.
|
|
3093
|
+
focused: isFocusedWithin(
|
|
2902
3094
|
row.id,
|
|
2903
|
-
|
|
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
|
-
:
|
|
2915
|
-
edges: boundsEdges(
|
|
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
|
}
|
package/src/core/autosize.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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 ??
|
|
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
|
|
package/src/core/cellRange.ts
CHANGED
|
@@ -70,7 +70,13 @@ export function resolveRangeBounds({
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/**
|
|
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:
|
|
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
|
+
}
|
package/src/core/columnUtils.ts
CHANGED
|
@@ -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
|
|