@jielga/tmdatagrid 1.0.2 → 1.1.1

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.
@@ -17,6 +17,7 @@ import {
17
17
  type UIEvent,
18
18
  useCallback,
19
19
  useEffect,
20
+ useLayoutEffect,
20
21
  useRef,
21
22
  useState,
22
23
  } from "react";
@@ -24,14 +25,20 @@ import classes from "./TMDataGridTable.module.css";
24
25
  import sticky from "./sticky.module.css";
25
26
  import { type TMDataGridRowData, useTMDataGridContext } from "../TMDataGridContext";
26
27
  import { TMDataGridFilterPanel } from "./TMDataGridFilterPanel";
27
- import { TMDataGridHeaderCell } from "./TMDataGridHeaderCell";
28
+ import {
29
+ TMDataGridHeaderCell,
30
+ type TMDataGridColumnMenuItemsRenderer,
31
+ } from "./TMDataGridHeaderCell";
28
32
  import {
29
33
  TMDataGridCellEditor,
30
34
  type TMDataGridCellEditorClose,
31
35
  } from "./TMDataGridCellEditor";
32
36
  import { TMDataGridEntryRows } from "./TMDataGridEntryRows";
33
37
  import { getColumnAlign, isControlColumn } from "../core/columnUtils";
34
- import { getEditFieldName } from "../core/editEngine";
38
+ import {
39
+ getEditFieldName,
40
+ type TMDataGridEditState,
41
+ } from "../core/editEngine";
35
42
  import {
36
43
  getNextEditableCell,
37
44
  resolveCellMove,
@@ -386,7 +393,7 @@ function TMDataGridBodyCell({
386
393
 
387
394
  /**
388
395
  * The virtualized body rows, wrapped in the single Menu that serves all of them
389
- * when `rowContextMenu` is set.
396
+ * when `renderRowContextMenu` is set.
390
397
  *
391
398
  * One instance for the whole body rather than one per row. A Mantine `Menu` is
392
399
  * a `Popover`, and a closed Popover still runs its hooks on every render - and
@@ -447,7 +454,7 @@ function TMDataGridBodyRowGroup({
447
454
  );
448
455
  }
449
456
 
450
- /** What a `rowContextMenu` render prop is handed for the right-clicked row. */
457
+ /** What `renderRowContextMenu` is handed for the right-clicked row. */
451
458
  export type TMDataGridRowContextMenuArgs<TData extends RowData> = {
452
459
  table: Table<TMDataGridFeatures, TData>;
453
460
  row: Row<TMDataGridFeatures, TData>;
@@ -462,6 +469,14 @@ export type TMDataGridRowContextMenuArgs<TData extends RowData> = {
462
469
  * `closeOnItemClick`), so this is for content that isn't a menu item.
463
470
  */
464
471
  close: () => void;
472
+ /**
473
+ * The grid's own items for this menu: the cell copy and export items, under
474
+ * `cellSelection: "range"`, and `null` otherwise.
475
+ *
476
+ * Placing it decides the order. Ignore it and the grid keeps today's
477
+ * behavior, rendering its items above a divider and yours below.
478
+ */
479
+ internalItems: ReactNode;
465
480
  };
466
481
 
467
482
  /**
@@ -469,7 +484,7 @@ export type TMDataGridRowContextMenuArgs<TData extends RowData> = {
469
484
  * `Menu.Divider`, `Menu.Sub`, or any other node. The grid owns the `Menu` and
470
485
  * its `Menu.Dropdown`; this fills the dropdown.
471
486
  */
472
- export type TMDataGridRowContextMenu<TData extends RowData> = (
487
+ export type TMDataGridRowContextMenuRenderer<TData extends RowData> = (
473
488
  args: TMDataGridRowContextMenuArgs<TData>,
474
489
  ) => ReactNode;
475
490
 
@@ -509,7 +524,7 @@ export type TMDataGridTableProps<TData extends RowData> = {
509
524
  /** As {@link onCellClick}; a double-click that opens an editor still does. */
510
525
  onCellDoubleClick?: (args: TMDataGridCellEventArgs<TData>) => void;
511
526
  /**
512
- * As {@link onCellClick}; `rowContextMenu` and the cell-selection menu still
527
+ * As {@link onCellClick}; `renderRowContextMenu` and the cell-selection menu still
513
528
  * open. To suppress the browser's own menu on a grid with neither, call
514
529
  * `event.preventDefault()` here.
515
530
  */
@@ -575,7 +590,7 @@ export type TMDataGridTableProps<TData extends RowData> = {
575
590
  *
576
591
  * ```tsx
577
592
  * <TMDataGrid.Table<Employee>
578
- * rowContextMenu={({ row, close }) => (
593
+ * renderRowContextMenu={({ row, close }) => (
579
594
  * <>
580
595
  * <Menu.Label>{row.original.firstName}</Menu.Label>
581
596
  * <Menu.Item onClick={() => open(row.original.id)}>Open</Menu.Item>
@@ -588,10 +603,33 @@ export type TMDataGridTableProps<TData extends RowData> = {
588
603
  * />
589
604
  * ```
590
605
  *
606
+ * `internalItems` is the grid's own half - place it to control the order,
607
+ * ignore it and the grid keeps its items above a divider and yours below.
608
+ *
591
609
  * Called during render, and only for the row whose menu is open - so it stays
592
610
  * a pure function of the row, and cost per row does not matter.
593
611
  */
594
- rowContextMenu?: TMDataGridRowContextMenu<TData>;
612
+ renderRowContextMenu?: TMDataGridRowContextMenuRenderer<TData>;
613
+ /**
614
+ * Replaces a column menu's contents, taking the grid's own items and
615
+ * returning the full list.
616
+ *
617
+ * ```tsx
618
+ * <TMDataGrid.Table
619
+ * renderColumnMenuItems={({ column, internalItems }) => [
620
+ * ...internalItems,
621
+ * <Menu.Divider key="custom" />,
622
+ * <Menu.Item key="stats" onClick={() => showStats(column.id)}>
623
+ * Statistics
624
+ * </Menu.Item>,
625
+ * ]}
626
+ * />
627
+ * ```
628
+ *
629
+ * Returning an empty list leaves the column with no menu button, the same as
630
+ * a column whose every feature is switched off.
631
+ */
632
+ renderColumnMenuItems?: TMDataGridColumnMenuItemsRenderer;
595
633
  /**
596
634
  * Passed to the Mantine `Menu` the grid wraps the row in - `width`, `shadow`,
597
635
  * `position`, `transitionProps` and the rest. Its open state is the grid's.
@@ -651,7 +689,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
651
689
  onScrollToLeft,
652
690
  onScrollToRight,
653
691
  renderEmptyState,
654
- rowContextMenu,
692
+ renderRowContextMenu,
693
+ renderColumnMenuItems,
655
694
  rowContextMenuProps,
656
695
  cellExport,
657
696
  onReachEnd,
@@ -685,6 +724,9 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
685
724
  // Which cell has an editor open. The projection (dirty/error markers) is
686
725
  // subscribed per cell instead - see TMDataGridBodyCell.
687
726
  const editActive = useSelector(edit.store, (state) => state.active);
727
+ // Row mode opens whole rows and any number of them at once, so which rows
728
+ // are showing editors is the set of open forms, not the single active slot.
729
+ const editOpenRowIds = useSelector(edit.store, (state) => state.openRowIds);
688
730
  const newRowCount = useSelector(edit.store, (state) => state.newRows.length);
689
731
  const deletedRowIds = useSelector(edit.store, (state) => state.deletedRowIds);
690
732
 
@@ -1364,6 +1406,74 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1364
1406
  return seed;
1365
1407
  };
1366
1408
 
1409
+ /**
1410
+ * The row's first cell that edits, in visual order - where the caret goes
1411
+ * for a gesture that named no column, which is the edit lane's pencil.
1412
+ */
1413
+ const firstEditableColumnId = (rowId: string): string | null => {
1414
+ const row = rows.find((candidate) => candidate.id === rowId);
1415
+ if (row === undefined) return null;
1416
+ const cells = [
1417
+ ...row.getLeftVisibleCells(),
1418
+ ...row.getCenterVisibleCells(),
1419
+ ...row.getRightVisibleCells(),
1420
+ ];
1421
+ return (
1422
+ cells.find((cell) => edit.canEditCell(row, cell.column))?.column.id ??
1423
+ null
1424
+ );
1425
+ };
1426
+
1427
+ /**
1428
+ * The `active` the caret has already been placed for. Identity, not value:
1429
+ * `beginOn` publishes a fresh object per gesture, so re-opening the same
1430
+ * cell is a new intent, while every later render for the same gesture - a
1431
+ * remount as the virtualizer scrolls among open rows - is not.
1432
+ */
1433
+ const placedEditCaretRef = useRef<TMDataGridEditState["active"]>(null);
1434
+
1435
+ /**
1436
+ * Puts the caret in the cell the open gesture named, once per gesture.
1437
+ *
1438
+ * The grid does this rather than leaving it to the editor's `autoFocus`,
1439
+ * because only the built-ins honour that prop: a `meta.editor` is free to
1440
+ * ignore it, and then the row opened with the caret in none of its editors -
1441
+ * the ring on the cell and a Tab still needed to reach the input. Focus goes
1442
+ * to `editor-input` when the editor publishes it, otherwise to whatever is
1443
+ * focusable inside, which is what a custom editor renders.
1444
+ *
1445
+ * A layout effect, ahead of the focused-cell effect below: the ring follows
1446
+ * the caret there rather than fighting it.
1447
+ */
1448
+ useLayoutEffect(() => {
1449
+ if (editActive === null) {
1450
+ placedEditCaretRef.current = null;
1451
+ return;
1452
+ }
1453
+ if (placedEditCaretRef.current === editActive) return;
1454
+ const container = scrollContainerRef.current;
1455
+ if (container === null) return;
1456
+ // The lane's pencil names no column - the row's first editable cell.
1457
+ const columnId =
1458
+ editActive.columnId ?? firstEditableColumnId(editActive.rowId);
1459
+ if (columnId === null) {
1460
+ placedEditCaretRef.current = editActive;
1461
+ return;
1462
+ }
1463
+ const editor = container.querySelector<HTMLElement>(
1464
+ `[data-dg-part="editor"][data-row-id="${CSS.escape(editActive.rowId)}"]` +
1465
+ `[data-column-id="${CSS.escape(columnId)}"]`,
1466
+ );
1467
+ // Not mounted yet - the virtualizer is still bringing the row in. The
1468
+ // intent stays up, and the next render tries again.
1469
+ if (editor === null) return;
1470
+ placedEditCaretRef.current = editActive;
1471
+ const target =
1472
+ editor.querySelector<HTMLElement>('[data-dg-part="editor-input"]') ??
1473
+ editor.querySelector<HTMLElement>(FOCUSABLE_IN_CELL);
1474
+ target?.focus();
1475
+ });
1476
+
1367
1477
  /** DOM focus back onto a cell - after Escape, or a commit with nowhere to go. */
1368
1478
  const refocusCell = (position: TMDataGridCellPosition) => {
1369
1479
  const container = scrollContainerRef.current;
@@ -1374,12 +1484,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1374
1484
 
1375
1485
  const openEditor = (position: TMDataGridCellPosition) => {
1376
1486
  if (cellSelection) ui.actions.setFocusedCell(position);
1377
- // Row mode edits whole rows: any editable cell's open gesture opens the
1378
- // row, exactly as the lane's pencil does.
1379
- edit.begin({
1380
- rowId: position.rowId,
1381
- columnId: features.editMode === "row" ? null : position.columnId,
1382
- });
1487
+ // Row mode edits whole rows: any editable cell's open gesture opens all of
1488
+ // them, exactly as the lane's pencil does. The column still travels, as
1489
+ // the cell the caret belongs in - the one the gesture named. Dropping it
1490
+ // here sent the caret to the row's first editable cell instead, which the
1491
+ // focused-cell effect above then pulled back onto the cell just clicked,
1492
+ // leaving the row open with the caret in none of its editors.
1493
+ edit.begin({ rowId: position.rowId, columnId: position.columnId });
1383
1494
  };
1384
1495
 
1385
1496
  const handleEditorClose = (
@@ -1658,6 +1769,18 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1658
1769
  // Focus is already inside - on the control Enter stepped into. The cell is
1659
1770
  // where the ring belongs, so there is nothing to move.
1660
1771
  if (cellElement.contains(document.activeElement)) return;
1772
+ // An open editor outranks the ring, wherever it is. Row mode opens a whole
1773
+ // row from one gesture, so the caret legitimately sits in a cell other
1774
+ // than the one that gesture landed the ring on - the lane's pencil is the
1775
+ // plain case, focusing its own cell on the way to opening the row. Pulling
1776
+ // the caret back onto that cell would leave the row open with the caret in
1777
+ // none of its editors, and the user with nothing to type into.
1778
+ if (
1779
+ document.activeElement instanceof Element &&
1780
+ document.activeElement.closest('[data-dg-part="editor"]') !== null
1781
+ ) {
1782
+ return;
1783
+ }
1661
1784
 
1662
1785
  // The vertical half is the virtualizer's, and it has already been asked.
1663
1786
  cellElement.focus({ preventScroll: true });
@@ -1700,7 +1823,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1700
1823
  const columnIndex = orderedColumns.findIndex(
1701
1824
  (column) => column.id === columnId,
1702
1825
  );
1703
- if (!isWithinBounds(selectionBounds, rowIndex, columnIndex)) {
1826
+ // `rowIndex < 0` is a pinned row: those sit out the cell range, so
1827
+ // moving the selection onto one would resolve to no rectangle at all
1828
+ // and drop the selection the user could still see at the edge.
1829
+ if (
1830
+ rowIndex >= 0 &&
1831
+ !isWithinBounds(selectionBounds, rowIndex, columnIndex)
1832
+ ) {
1704
1833
  ui.actions.setFocusedCell({ rowId, columnId });
1705
1834
  setRangeTo({ rowId, columnId }, { extend: false });
1706
1835
  }
@@ -1709,34 +1838,24 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1709
1838
  setContextMenuTarget(rowId === null ? null : { rowId, columnId });
1710
1839
  };
1711
1840
 
1841
+ // The body list, then the edge blocks: pinning takes a row out of the
1842
+ // scrolling order, and unpinning is the first thing its menu exists to
1843
+ // offer, so a menu that could not resolve a pinned row would strand it
1844
+ // there. Searched in that order because the body holds nearly every row.
1712
1845
  const contextMenuRow =
1713
1846
  contextMenuTarget === null
1714
1847
  ? undefined
1715
- : rows.find((row) => row.id === contextMenuTarget.rowId);
1848
+ : (rows.find((row) => row.id === contextMenuTarget.rowId) ??
1849
+ pinnedTopRows.find((row) => row.id === contextMenuTarget.rowId) ??
1850
+ pinnedBottomRows.find((row) => row.id === contextMenuTarget.rowId));
1851
+
1852
+ const canRenderRowMenu =
1853
+ renderRowContextMenu !== undefined &&
1854
+ contextMenuTarget !== null &&
1855
+ contextMenuRow !== undefined &&
1856
+ !contextMenuRow.getIsGrouped();
1857
+
1716
1858
 
1717
- /**
1718
- * Built for the open row only, so a consumer's render prop is called once per
1719
- * opened menu rather than once per mounted row. `null` back from it means
1720
- * this row has no menu, which is also how the menu stays shut.
1721
- *
1722
- * The casts put back the concrete row type the context boundary erased - the
1723
- * same crossing `onRowClick` makes.
1724
- */
1725
- const rowMenuContent =
1726
- rowContextMenu &&
1727
- contextMenuTarget &&
1728
- contextMenuRow &&
1729
- !contextMenuRow.getIsGrouped()
1730
- ? rowContextMenu({
1731
- table: table as unknown as Table<TMDataGridFeatures, TData>,
1732
- row: contextMenuRow as unknown as Row<TMDataGridFeatures, TData>,
1733
- cell: (contextMenuRow
1734
- .getAllCells()
1735
- .find((cell) => cell.column.id === contextMenuTarget.columnId) ??
1736
- null) as unknown as Cell<TMDataGridFeatures, TData, unknown> | null,
1737
- close: closeContextMenu,
1738
- }) ?? null
1739
- : null;
1740
1859
 
1741
1860
  /**
1742
1861
  * The built-in half of the menu: what to do with the cells that are selected.
@@ -1780,18 +1899,56 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1780
1899
  </>
1781
1900
  ) : null;
1782
1901
 
1902
+ /**
1903
+ * The consumer's half, built for the open row only - so the render prop runs
1904
+ * once per opened menu rather than once per mounted row. `null` back from it
1905
+ * means this row has no menu, which is also how the menu stays shut.
1906
+ *
1907
+ * `internalItems` is a getter, and reading it is the signal: a consumer who
1908
+ * touches it has taken the composition over, so the grid stops prepending
1909
+ * its own half. One who never mentions it gets today's behavior, the cell
1910
+ * items above a divider. The alternative - a second prop declaring intent -
1911
+ * asks people to say twice what the placement already says.
1912
+ *
1913
+ * The casts put back the concrete row type the context boundary erased - the
1914
+ * same crossing `onRowClick` makes.
1915
+ */
1916
+ let tookOverMenu = false;
1917
+ const rowMenuContent = canRenderRowMenu
1918
+ ? (renderRowContextMenu({
1919
+ table: table as unknown as Table<TMDataGridFeatures, TData>,
1920
+ row: contextMenuRow as unknown as Row<TMDataGridFeatures, TData>,
1921
+ cell: (contextMenuRow
1922
+ ?.getAllCells()
1923
+ .find((cell) => cell.column.id === contextMenuTarget?.columnId) ??
1924
+ null) as unknown as Cell<TMDataGridFeatures, TData, unknown> | null,
1925
+ close: closeContextMenu,
1926
+ get internalItems() {
1927
+ tookOverMenu = true;
1928
+ return cellMenuContent;
1929
+ },
1930
+ }) ?? null)
1931
+ : null;
1932
+
1783
1933
  /**
1784
1934
  * The two halves, in the order they are about: the cells first, since the
1785
1935
  * right-click landed on one, then whatever the consumer offers for the row.
1936
+ * Unless the consumer took the composition over, in which case what they
1937
+ * returned is the whole menu.
1786
1938
  */
1787
- const contextMenuContent =
1788
- cellMenuContent === null && rowMenuContent === null ? null : (
1789
- <>
1790
- {cellMenuContent}
1791
- {cellMenuContent !== null && rowMenuContent !== null && <Menu.Divider />}
1792
- {rowMenuContent}
1793
- </>
1794
- );
1939
+ const contextMenuContent = tookOverMenu
1940
+ ? rowMenuContent
1941
+ : cellMenuContent === null && rowMenuContent === null
1942
+ ? null
1943
+ : (
1944
+ <>
1945
+ {cellMenuContent}
1946
+ {cellMenuContent !== null && rowMenuContent !== null && (
1947
+ <Menu.Divider />
1948
+ )}
1949
+ {rowMenuContent}
1950
+ </>
1951
+ );
1795
1952
 
1796
1953
  /**
1797
1954
  * Which edges the scroll position is currently at. Seeded "at top-left",
@@ -1908,6 +2065,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1908
2065
  >
1909
2066
  {headerGroup.headers.map((header) => (
1910
2067
  <TMDataGridHeaderCell
2068
+ renderColumnMenuItems={renderColumnMenuItems}
1911
2069
  key={header.id}
1912
2070
  header={header}
1913
2071
  layout={layoutFor(header.column.id)}
@@ -1929,8 +2087,8 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1929
2087
 
1930
2088
  <TMDataGridBodyRowGroup
1931
2089
  // Under `"range"` the grid has a menu of its own to offer, so the
1932
- // wrapper goes on even without a consumer's `rowContextMenu`.
1933
- enabled={rowContextMenu !== undefined || cellRangeSelection}
2090
+ // wrapper goes on even without a consumer's `renderRowContextMenu`.
2091
+ enabled={renderRowContextMenu !== undefined || cellRangeSelection}
1934
2092
  menuContent={contextMenuContent}
1935
2093
  menuProps={rowContextMenuProps}
1936
2094
  onContextMenu={handleContextMenu}
@@ -1959,17 +2117,13 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1959
2117
  ...row.getCenterVisibleCells(),
1960
2118
  ...row.getRightVisibleCells(),
1961
2119
  ];
1962
- // Row mode opens every editable cell of the row at once;
1963
- // exactly one of them may take the focus.
2120
+ // Row mode opens every editable cell of the row at once, and
2121
+ // rows accumulate: a second row opening leaves the first one
2122
+ // open, so every row holding a form shows its editors. Which of
2123
+ // them holds the caret is not decided here - see
2124
+ // placeEditCaret, which places it once per gesture.
1964
2125
  const rowEditing =
1965
- editActive !== null &&
1966
- editActive.rowId === row.id &&
1967
- editActive.columnId === null;
1968
- const firstEditableColumnId = rowEditing
1969
- ? (rowCells.find((cell) =>
1970
- edit.canEditCell(row, cell.column),
1971
- )?.column.id ?? null)
1972
- : null;
2126
+ features.editMode === "row" && editOpenRowIds.includes(row.id);
1973
2127
  // Cell selection takes the body's tab stop off the row and puts
1974
2128
  // it on a cell - two stops per row would make Tab a way of
1975
2129
  // walking the grid, which is what the arrow keys are for. Space
@@ -2110,14 +2264,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2110
2264
  // is recorded before the dropdown is built.
2111
2265
  //
2112
2266
  // A group row records itself only for the cell menu, which is
2113
- // about the cells it covers. `rowContextMenu` is still never
2267
+ // about the cells it covers. `renderRowContextMenu` is still never
2114
2268
  // called for one: TanStack builds a group row on its first
2115
2269
  // child's record, so a render prop reaching for `row.original`
2116
2270
  // would be handed a real employee that has nothing to do with
2117
2271
  // the group - see where the content is built. The browser menu
2118
2272
  // stays suppressed over them either way.
2119
2273
  onContextMenu={
2120
- (rowContextMenu && !isGroupRow) || cellRangeSelection
2274
+ (renderRowContextMenu && !isGroupRow) || cellRangeSelection
2121
2275
  ? () => {
2122
2276
  contextMenuRowRef.current = row.id;
2123
2277
  }
@@ -2149,10 +2303,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2149
2303
  cell={cell}
2150
2304
  row={row}
2151
2305
  takeSeedText={takeEditSeed}
2152
- autoFocus={
2153
- !rowEditing ||
2154
- cell.column.id === firstEditableColumnId
2155
- }
2306
+ autoFocus={!rowEditing}
2156
2307
  onClose={(close) =>
2157
2308
  handleEditorClose(
2158
2309
  { rowId: row.id, columnId: cell.column.id },
@@ -2252,7 +2403,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2252
2403
  : undefined
2253
2404
  }
2254
2405
  onContextMenu={
2255
- rowContextMenu || cellRangeSelection || onCellContextMenu
2406
+ renderRowContextMenu || cellRangeSelection || onCellContextMenu
2256
2407
  ? (event) => {
2257
2408
  contextMenuColumnRef.current = cell.column.id;
2258
2409
  if (isGroupRow) return;
@@ -118,9 +118,16 @@ export type TMDataGridEditRowProjection = {
118
118
  * reads a form's store directly - it is mounted for one cell at a time.
119
119
  */
120
120
  export type TMDataGridEditState = {
121
- /** The cell whose editor is open; `columnId: null` is a whole row (row mode). */
121
+ /**
122
+ * The cell the last open gesture named - where the caret goes. In row mode
123
+ * the whole row is editing whatever this says, so there it is only the
124
+ * caret's cell, and `null` for the lane's pencil, which names no column.
125
+ */
122
126
  active: { rowId: string; columnId: string | null } | null;
123
- /** Rows with a live form. In cell mode at most one; in batch, many. */
127
+ /**
128
+ * Rows with a live form. In cell mode at most one; in row, cellConfirm and
129
+ * batch, as many as the user opened - which rows are editing.
130
+ */
124
131
  openRowIds: ReadonlyArray<string>;
125
132
  rows: Record<string, TMDataGridEditRowProjection>;
126
133
  /** Phase 4 - rows being created, not yet in `data`. */
@@ -166,8 +173,13 @@ export type TMDataGridEditorArgs = {
166
173
  cancel: () => void;
167
174
  size: TMDataGridSize;
168
175
  /**
169
- * Whether this editor should take the focus on mount. True for a single
170
- * opened cell; in row mode only the row's first editable cell gets it.
176
+ * @deprecated The grid places the caret itself, once per open gesture, so
177
+ * honouring this is optional and it will go in the next major. It focuses
178
+ * `data-dg-part="editor-input"` when the editor publishes it and the first
179
+ * focusable element inside the editor otherwise, which is why a custom
180
+ * editor that ignores this prop still gets the caret. False under
181
+ * `editMode: "row"`, where a whole row of editors is mounted at once and
182
+ * only the named cell may take it.
171
183
  */
172
184
  autoFocus: boolean;
173
185
  /**
@@ -285,7 +297,11 @@ export type TMDataGridEditApi = {
285
297
  canEditCell: (row: ErasedRow, column: ErasedColumn) => boolean;
286
298
  /** Whether the row takes edits at all - the edit lane's pencil gate. */
287
299
  canEditRow: (row: ErasedRow) => boolean;
288
- /** Opens an editor. `columnId: null` opens the whole row (row mode). */
300
+ /**
301
+ * Opens an editor. In row mode the whole row opens either way and `columnId`
302
+ * only says which cell takes the caret; `null` (the lane's pencil) leaves it
303
+ * to the row's first editable cell.
304
+ */
289
305
  begin: (target: { rowId: string; columnId: string | null }) => void;
290
306
  /**
291
307
  * Commits one row - `form.handleSubmit` under the hood. Resolves `true`
@@ -636,30 +652,22 @@ export function createEditEngine(
636
652
 
637
653
  const begin: TMDataGridEditApi["begin"] = ({ rowId, columnId }) => {
638
654
  const { editMode } = getContext();
639
- const openElsewhere = [...forms.keys()].find((id) => id !== rowId);
640
- // Each mode's policy about the row being left:
655
+ // Each mode's policy about a row already open elsewhere:
641
656
  //
642
657
  // | Mode | Another row open |
643
658
  // | ---- | ---------------- |
644
659
  // | cell | committed - Sheets; a failed commit keeps holding the edit |
645
- // | row | dropped if pristine, otherwise the pencil is refused - a
646
- // row edit saves explicitly, so leaving must not save quietly |
647
- // | cellConfirm, batch | accumulates; every draft waits for its ✓ |
648
- if (openElsewhere !== undefined) {
649
- if (editMode === "cell") {
660
+ // | row, cellConfirm, batch | accumulates. Every draft waits for its own
661
+ // save, so a second row opening can neither discard the first nor be
662
+ // refused by it - nothing about one row's form bears on another's |
663
+ if (editMode === "cell") {
664
+ const openElsewhere = [...forms.keys()].find((id) => id !== rowId);
665
+ if (openElsewhere !== undefined) {
650
666
  void commit(openElsewhere).then((ok) => {
651
667
  if (ok) beginOn(rowId, columnId);
652
668
  });
653
669
  return;
654
670
  }
655
- if (editMode === "row") {
656
- const entry = forms.get(openElsewhere);
657
- const pristine =
658
- entry !== undefined &&
659
- diff(entry, entry.form.state.values as TMDataGridRowData).length === 0;
660
- if (!pristine) return;
661
- drop(openElsewhere);
662
- }
663
671
  }
664
672
  beginOn(rowId, columnId);
665
673
  };
@@ -66,7 +66,14 @@ export {
66
66
  type TMDataGridOptionsArgs,
67
67
  type TMDataGridOptionsSource,
68
68
  } from "./core/columnOptions";
69
- export { TMDataGridEditActions } from "./components/TMDataGridEditActions";
69
+ export {
70
+ TMDataGridEditActions,
71
+ type TMDataGridEditActionsActions,
72
+ type TMDataGridEditActionsControls,
73
+ type TMDataGridEditActionsProps,
74
+ type TMDataGridEditActionsSlotArgs,
75
+ type TMDataGridEditActionsState,
76
+ } from "./components/TMDataGridEditActions";
70
77
  export {
71
78
  clearedValueForType,
72
79
  getEditFieldName,
@@ -207,13 +214,21 @@ export type {
207
214
  TMDataGridCellEventArgs,
208
215
  TMDataGridCellNav,
209
216
  TMDataGridColumnLayout,
210
- TMDataGridRowContextMenu,
211
217
  TMDataGridRowContextMenuArgs,
218
+ TMDataGridRowContextMenuRenderer,
212
219
  TMDataGridRowStyle,
213
220
  TMDataGridTableProps,
214
221
  } from "./components/TMDataGridTable";
222
+ export type {
223
+ TMDataGridColumnMenuItemsArgs,
224
+ TMDataGridColumnMenuItemsRenderer,
225
+ } from "./components/TMDataGridHeaderCell";
215
226
  export {
216
227
  getTMDataGridPaginationApi,
217
228
  type TMDataGridFooterProps,
229
+ type TMDataGridPaginationActions,
218
230
  type TMDataGridPaginationApi,
231
+ type TMDataGridPaginationControls,
232
+ type TMDataGridPaginationSlotArgs,
233
+ type TMDataGridPaginationState,
219
234
  } from "./components/TMDataGridFooter";