@jielga/tmdatagrid 2.0.0-beta.1 → 2.0.0-beta.2

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 (36) hide show
  1. package/dist/index.d.ts +60 -23
  2. package/dist/index.js +2020 -1704
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +1 -1
  6. package/skills/appearance/SKILL.md +1 -1
  7. package/skills/cell-selection/SKILL.md +1 -1
  8. package/skills/columns/SKILL.md +2 -2
  9. package/skills/data/SKILL.md +1 -1
  10. package/skills/editing/SKILL.md +68 -213
  11. package/skills/editing/references/common-mistakes.md +177 -0
  12. package/skills/editing/references/editing-api.md +39 -13
  13. package/skills/editing/references/editors-and-validation.md +5 -2
  14. package/skills/filtering/SKILL.md +1 -1
  15. package/skills/getting-started/SKILL.md +1 -1
  16. package/skills/grouping/SKILL.md +1 -1
  17. package/skills/options/SKILL.md +2 -2
  18. package/skills/rows/SKILL.md +1 -1
  19. package/skills/rows/references/rows-api.md +1 -1
  20. package/skills/server-side/SKILL.md +1 -1
  21. package/skills/testing/SKILL.md +3 -2
  22. package/src/tmdatagrid/components/TMDataGrid.module.css +7 -0
  23. package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +20 -8
  24. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +1 -1
  25. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +242 -86
  26. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +176 -77
  27. package/src/tmdatagrid/components/TMDataGridTable.module.css +47 -3
  28. package/src/tmdatagrid/components/TMDataGridTable.tsx +44 -4
  29. package/src/tmdatagrid/components/icons.ts +1 -0
  30. package/src/tmdatagrid/core/capabilities.ts +10 -0
  31. package/src/tmdatagrid/core/draftCellContext.ts +68 -0
  32. package/src/tmdatagrid/core/editEngine.ts +106 -32
  33. package/src/tmdatagrid/core/labels.ts +13 -1
  34. package/src/tmdatagrid/core/labelsSv.ts +4 -0
  35. package/src/tmdatagrid/index.ts +1 -1
  36. package/src/tmdatagrid/useTMDataGrid.tsx +39 -20
@@ -53,7 +53,7 @@ function isEntryCellEditable(
53
53
  * every other draft, keyed by their temporary ids.
54
54
  *
55
55
  * Enter commits the entry (`onRowAdd`) under the immediate modes and parks
56
- * it under batch, where `submitAll` reports it in `added`; Escape discards
56
+ * it under draft, where `submitAll` reports it in `added`; Escape discards
57
57
  * it. The edit lane, when present, offers the same pair as buttons.
58
58
  */
59
59
  export function TMDataGridEntryRows({
@@ -65,11 +65,23 @@ export function TMDataGridEntryRows({
65
65
  layoutFor: (columnId: string) => TMDataGridColumnLayout;
66
66
  rowHeight: number;
67
67
  }) {
68
- const { table, edit } = useTMDataGridContext();
68
+ const { table, edit, features } = useTMDataGridContext();
69
69
  const newRows = useSelector(edit.store, (state) => state.newRows);
70
+ // The reopen gesture: `begin` on a confirmed entry row flips it back to
71
+ // editors and names the cell double-clicked - where the caret goes.
72
+ const activeEntry = useSelector(edit.store, (state) =>
73
+ state.active !== null &&
74
+ state.newRows.some(
75
+ (newRow) => newRow.tempId === state.active?.rowId && !newRow.confirmed,
76
+ )
77
+ ? state.active
78
+ : null,
79
+ );
70
80
  const blockRef = useRef<HTMLDivElement>(null);
71
81
  /** The entry row the caret has already been placed in. */
72
82
  const focusedTempIdRef = useRef<string | null>(null);
83
+ /** The reopen target (`rowId:columnId`) the caret has already landed in. */
84
+ const focusedActiveRef = useRef<string | null>(null);
73
85
 
74
86
  /**
75
87
  * The caret goes into a row the moment `edit.addRow()` opens it, landing in
@@ -96,14 +108,44 @@ export function TMDataGridEntryRows({
96
108
  focusEditorContent(editor);
97
109
  });
98
110
 
99
- // The seed values, frozen at addRow: the live values belong to the forms,
100
- // which the editors read directly - this table only provides row and cell
101
- // identity.
111
+ /**
112
+ * The caret for a reopen: `begin` on a confirmed row re-arms its editors
113
+ * and the double-clicked cell's editor should hold the caret, not the
114
+ * row's first. Keyed separately from the add effect above - the row was
115
+ * already focused once when it was added.
116
+ */
117
+ useLayoutEffect(() => {
118
+ if (activeEntry === null) {
119
+ focusedActiveRef.current = null;
120
+ return;
121
+ }
122
+ const key = `${activeEntry.rowId}:${activeEntry.columnId ?? ""}`;
123
+ if (focusedActiveRef.current === key) return;
124
+ const block = blockRef.current;
125
+ if (block === null) return;
126
+ const editor = block.querySelector<HTMLElement>(
127
+ activeEntry.columnId !== null
128
+ ? `[data-dg-part="editor"][data-row-id="${CSS.escape(activeEntry.rowId)}"][data-column-id="${CSS.escape(activeEntry.columnId)}"]`
129
+ : `[data-dg-part="editor"][data-row-id="${CSS.escape(activeEntry.rowId)}"]`,
130
+ );
131
+ // Not mounted yet; the next render tries again, as the add effect does.
132
+ if (editor === null) return;
133
+ focusedActiveRef.current = key;
134
+ focusEditorContent(editor);
135
+ });
136
+
137
+ // Unconfirmed rows keep the seed values frozen at addRow - the live values
138
+ // belong to the forms, which the editors read directly, and this table only
139
+ // provides row and cell identity. A confirmed row's cells render values, so
140
+ // there the draft itself is the row; the memo recomputes on every
141
+ // confirm/reopen because either flips `newRows`' identity.
102
142
  const data = useMemo(
103
143
  () =>
104
144
  newRows.map(
105
- ({ tempId }) =>
106
- (edit.getForm(tempId)?.options.defaultValues ??
145
+ ({ tempId, confirmed }) =>
146
+ ((confirmed
147
+ ? edit.getForm(tempId)?.state.values
148
+ : edit.getForm(tempId)?.options.defaultValues) ??
107
149
  {}) as TMDataGridRowData,
108
150
  ),
109
151
  [newRows, edit],
@@ -119,77 +161,134 @@ export function TMDataGridEntryRows({
119
161
  if (newRows.length === 0) return null;
120
162
 
121
163
  const entryRows = entryTable.getCoreRowModel().rows;
164
+ const confirmedById = new Map(
165
+ newRows.map((newRow) => [newRow.tempId, newRow.confirmed]),
166
+ );
167
+ // A row being *typed* into is always sticky - it exists nowhere else to
168
+ // scroll back to. A confirmed row joins the scrolling flow unless
169
+ // `newRowsSticky` keeps it pinned, so entering many rows cannot fill the
170
+ // viewport with sticky chrome.
171
+ const stickyRows = entryRows.filter(
172
+ (entryRow) =>
173
+ features.editNewRowsSticky || confirmedById.get(entryRow.id) !== true,
174
+ );
175
+ const flowRows = features.editNewRowsSticky
176
+ ? []
177
+ : entryRows.filter((entryRow) => confirmedById.get(entryRow.id) === true);
178
+
179
+ const renderEntryRow = (
180
+ entryRow: (typeof entryRows)[number],
181
+ pinnedZ: string,
182
+ ) => {
183
+ const cellsById = new Map(
184
+ entryRow.getAllCells().map((cell) => [cell.column.id, cell]),
185
+ );
186
+ const confirmed = confirmedById.get(entryRow.id) === true;
187
+ return (
188
+ <div
189
+ key={entryRow.id}
190
+ role="row"
191
+ data-dg-part="entry-row"
192
+ data-row-id={entryRow.id}
193
+ data-new
194
+ data-confirmed={confirmed}
195
+ className={classes.entryRow}
196
+ >
197
+ {orderedColumns.map((column) => {
198
+ const cell = cellsById.get(column.id);
199
+ const layout = layoutFor(column.id);
200
+ const editable =
201
+ cell !== undefined && isEntryCellEditable(entryRow, column);
202
+ return (
203
+ <div
204
+ key={column.id}
205
+ role="cell"
206
+ data-column-id={column.id}
207
+ data-align={getColumnAlign(column)}
208
+ data-control-column={isControlColumn(column.id)}
209
+ // A confirmed row re-opens where it is double-clicked, the
210
+ // same gesture a body cell answers.
211
+ onDoubleClick={
212
+ confirmed && !isControlColumn(column.id)
213
+ ? () =>
214
+ edit.begin({
215
+ rowId: entryRow.id,
216
+ columnId: editable ? column.id : null,
217
+ })
218
+ : undefined
219
+ }
220
+ className={[
221
+ classes.entryCell,
222
+ layout.isBoundary && layout.pinnedAt === "left"
223
+ ? sticky.stickyLeft
224
+ : "",
225
+ layout.isBoundary && layout.pinnedAt === "right"
226
+ ? sticky.stickyRight
227
+ : "",
228
+ ]
229
+ .filter(Boolean)
230
+ .join(" ")}
231
+ style={{
232
+ minHeight: rowHeight,
233
+ left: layout.pinnedAt === "left" ? layout.offset : undefined,
234
+ right: layout.pinnedAt === "right" ? layout.offset : undefined,
235
+ position: layout.pinnedAt ? "sticky" : undefined,
236
+ zIndex: layout.pinnedAt ? pinnedZ : undefined,
237
+ }}
238
+ >
239
+ {cell !== undefined && column.id === EDIT_COLUMN_ID ? (
240
+ // The lane's cell - the entry row's controls.
241
+ flexRender(cell.column.columnDef.cell, cell.getContext())
242
+ ) : confirmed && cell !== undefined ? (
243
+ // Entered, awaiting Save all: a value row through the
244
+ // columns' own renderers, over the draft the memo above
245
+ // fed this table.
246
+ <span className={classes.cellContent}>
247
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
248
+ </span>
249
+ ) : editable && cell !== undefined ? (
250
+ <TMDataGridCellEditor
251
+ cell={cell}
252
+ row={entryRow}
253
+ takeSeedText={() => undefined}
254
+ onClose={() => {}}
255
+ inEntryBlock
256
+ />
257
+ ) : null}
258
+ </div>
259
+ );
260
+ })}
261
+ </div>
262
+ );
263
+ };
122
264
 
123
265
  return (
124
- <div
125
- ref={blockRef}
126
- role="rowgroup"
127
- data-dg-entry-block
128
- className={classes.entryBlock}
129
- >
130
- {entryRows.map((entryRow) => {
131
- const cellsById = new Map(
132
- entryRow.getAllCells().map((cell) => [cell.column.id, cell]),
133
- );
134
- return (
135
- <div
136
- key={entryRow.id}
137
- role="row"
138
- data-dg-part="entry-row"
139
- data-row-id={entryRow.id}
140
- className={classes.entryRow}
141
- >
142
- {orderedColumns.map((column) => {
143
- const cell = cellsById.get(column.id);
144
- const layout = layoutFor(column.id);
145
- const editable =
146
- cell !== undefined && isEntryCellEditable(entryRow, column);
147
- return (
148
- <div
149
- key={column.id}
150
- role="cell"
151
- data-column-id={column.id}
152
- data-align={getColumnAlign(column)}
153
- data-control-column={isControlColumn(column.id)}
154
- className={[
155
- classes.entryCell,
156
- layout.isBoundary && layout.pinnedAt === "left"
157
- ? sticky.stickyLeft
158
- : "",
159
- layout.isBoundary && layout.pinnedAt === "right"
160
- ? sticky.stickyRight
161
- : "",
162
- ]
163
- .filter(Boolean)
164
- .join(" ")}
165
- style={{
166
- minHeight: rowHeight,
167
- left: layout.pinnedAt === "left" ? layout.offset : undefined,
168
- right:
169
- layout.pinnedAt === "right" ? layout.offset : undefined,
170
- position: layout.pinnedAt ? "sticky" : undefined,
171
- zIndex: layout.pinnedAt
172
- ? "var(--dg-z-pinned-row-pinned-cell, 5)"
173
- : undefined,
174
- }}
175
- >
176
- {editable && cell !== undefined ? (
177
- <TMDataGridCellEditor
178
- cell={cell}
179
- row={entryRow}
180
- takeSeedText={() => undefined}
181
- onClose={() => {}}
182
- />
183
- ) : cell !== undefined && column.id === EDIT_COLUMN_ID ? (
184
- // The lane's cell - the entry row's ✓/✕ pair.
185
- flexRender(cell.column.columnDef.cell, cell.getContext())
186
- ) : null}
187
- </div>
188
- );
189
- })}
190
- </div>
191
- );
192
- })}
193
- </div>
266
+ <>
267
+ {stickyRows.length > 0 && (
268
+ <div
269
+ ref={blockRef}
270
+ role="rowgroup"
271
+ data-dg-entry-block
272
+ className={classes.entryBlock}
273
+ >
274
+ {stickyRows.map((entryRow) =>
275
+ renderEntryRow(entryRow, "var(--dg-z-pinned-row-pinned-cell, 5)"),
276
+ )}
277
+ </div>
278
+ )}
279
+ {flowRows.length > 0 && (
280
+ <div
281
+ role="rowgroup"
282
+ data-dg-entry-flow-block
283
+ className={classes.entryFlowBlock}
284
+ >
285
+ {/* In flow, so a pinned cell stacks like a body row's, under the
286
+ sticky blocks it scrolls past. */}
287
+ {flowRows.map((entryRow) =>
288
+ renderEntryRow(entryRow, "var(--dg-z-pinned-cell, 2)"),
289
+ )}
290
+ </div>
291
+ )}
292
+ </>
194
293
  );
195
294
  }
@@ -329,12 +329,18 @@
329
329
  padding: var(--mantine-spacing-xl);
330
330
  }
331
331
 
332
- /* Marked deleted under batch: struck through and inert until submitAll
333
- reports it. Interaction off wholesale - a row about to go away must not
334
- keep taking edits. */
332
+ /* Marked deleted under draft: struck through and inert until submitAll
333
+ reports it - a row about to go away must not keep taking edits. Interaction
334
+ comes off the data cells, not the row: `pointer-events: none` on the row
335
+ would also kill the edit lane's Restore, which is the one control the row
336
+ still needs (jsdom does not enforce pointer-events, so only a real browser
337
+ ever saw that bug). */
335
338
  .bodyRow[data-deleted="true"] {
336
339
  text-decoration: line-through;
337
340
  opacity: 0.55;
341
+ }
342
+
343
+ .bodyRow[data-deleted="true"] .bodyCell:not([data-control-column="true"]) {
338
344
  pointer-events: none;
339
345
  }
340
346
 
@@ -359,6 +365,15 @@
359
365
  grid-template-columns: subgrid;
360
366
  }
361
367
 
368
+ /* Confirmed entry rows in the scrolling flow (the default; `newRowsSticky`
369
+ keeps them in the sticky block instead). Same tracks, no stickiness - they
370
+ sit above the body rows and scroll with them. */
371
+ .entryFlowBlock {
372
+ display: grid;
373
+ grid-column: 1 / -1;
374
+ grid-template-columns: subgrid;
375
+ }
376
+
362
377
  /* The pinned edge blocks - user-pinned rows held at the top or bottom of the
363
378
  body, on the entry block's sticky mechanics: the block is the sticky
364
379
  element, its rows flow normally inside it. The top block stacks under the
@@ -407,6 +422,35 @@
407
422
  justify-content: center;
408
423
  }
409
424
 
425
+ /* A confirmed entry row - entered, awaiting Save all. It reads as a value
426
+ row: text padding, the body's border weight, and the "new" tint instead of
427
+ the editing underline. */
428
+ .entryRow[data-confirmed="true"] .entryCell {
429
+ padding: 0 var(--dg-padding);
430
+ border-bottom: 1px solid var(--mantine-color-default-border);
431
+ background-color: var(--dg-row-new-bg, var(--mantine-color-body));
432
+ }
433
+
434
+ .entryRow[data-confirmed="true"] .entryCell[data-control-column="true"] {
435
+ padding: 0;
436
+ }
437
+
438
+ /* Draft mode's change marker in the edit lane - dimmed beside the buttons
439
+ that act, red when validation blocks the row. */
440
+ .rowStateIndicator {
441
+ display: inline-flex;
442
+ align-items: center;
443
+ justify-content: center;
444
+ width: 22px;
445
+ height: 22px;
446
+ flex: 0 0 auto;
447
+ color: var(--mantine-color-dimmed);
448
+ }
449
+
450
+ .rowStateIndicator[data-error] {
451
+ color: var(--mantine-color-error);
452
+ }
453
+
410
454
  .entryCell[data-align="right"] {
411
455
  justify-content: flex-end;
412
456
  }
@@ -35,6 +35,7 @@ import {
35
35
  } from "./TMDataGridCellEditor";
36
36
  import { TMDataGridEntryRows } from "./TMDataGridEntryRows";
37
37
  import { getColumnAlign, isControlColumn } from "../core/columnUtils";
38
+ import { draftCellContext } from "../core/draftCellContext";
38
39
  import {
39
40
  getEditFieldName,
40
41
  type TMDataGridEditState,
@@ -266,6 +267,7 @@ function TMDataGridBodyCell({
266
267
  const { table, edit } = useTMDataGridContext();
267
268
  const fieldName = getEditFieldName(cell.column);
268
269
  const cellRowId = cell.row.id;
270
+ const isControl = isControlColumn(cell.column.id);
269
271
  const isDirty = useSelector(edit.store, (state) => {
270
272
  if (fieldName === null) return false;
271
273
  const projection = state.rows[cellRowId];
@@ -276,6 +278,13 @@ function TMDataGridBodyCell({
276
278
  const projection = state.rows[cellRowId];
277
279
  return projection !== undefined && projection.errorFields.includes(fieldName);
278
280
  });
281
+ // The whole draft, not one field: a computed or custom renderer may read any
282
+ // field off the row, so every data cell of a drafted row repaints together
283
+ // when its draft moves - and only then, `values` being reference-stable
284
+ // across meta-only form events. `undefined` everywhere else.
285
+ const draftValues = useSelector(edit.store, (state) =>
286
+ isControl ? undefined : state.rows[cellRowId]?.values,
287
+ );
279
288
  return (
280
289
  <div
281
290
  // `gridcell` rather than `cell` is what tells a screen reader the arrow
@@ -319,7 +328,7 @@ function TMDataGridBodyCell({
319
328
  data-align={getColumnAlign(cell.column)}
320
329
  // A control lane is a fixed track, so it cannot take the cell padding the
321
330
  // scale grows for text. See isControlColumn.
322
- data-control-column={isControlColumn(cell.column.id)}
331
+ data-control-column={isControl}
323
332
  onContextMenu={onContextMenu}
324
333
  className={[
325
334
  classes.bodyCell,
@@ -354,6 +363,17 @@ function TMDataGridBodyCell({
354
363
  {editor ?? (
355
364
  <span className={classes.cellContent}>
356
365
  {(() => {
366
+ // A row with a live form shows its draft, not `data`: the column's
367
+ // own renderer over a context reading the form's values. Parked
368
+ // drafts - draft mode, cellConfirm's kept-on-blur - are the point;
369
+ // group cells are excluded because no group row ever has a form.
370
+ const draftContext =
371
+ draftValues !== undefined &&
372
+ contentOverride === undefined &&
373
+ !cell.getIsPlaceholder() &&
374
+ !cell.getIsAggregated()
375
+ ? draftCellContext(cell, draftValues)
376
+ : undefined;
357
377
  // Highlighting reproduces the *default* renderer, value then
358
378
  // `toString`, with the matched parts marked. A column with its own
359
379
  // `cell` is excluded: its renderer is not the default one, and the
@@ -365,11 +385,17 @@ function TMDataGridBodyCell({
365
385
  !cell.getIsAggregated() &&
366
386
  cell.column.columnDef.cell === table.getDefaultColumnDef().cell
367
387
  ) {
368
- const value = cell.renderValue();
388
+ const value =
389
+ draftContext !== undefined
390
+ ? draftContext.renderValue()
391
+ : cell.renderValue();
369
392
  if (typeof value === "string" || typeof value === "number") {
370
393
  return withMatchHighlights(String(value), highlightNeedles);
371
394
  }
372
395
  }
396
+ if (draftContext !== undefined) {
397
+ return flexRender(cell.column.columnDef.cell, draftContext);
398
+ }
373
399
  return contentOverride ?? renderCellContent(cell);
374
400
  })()}
375
401
  </span>
@@ -716,6 +742,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
716
742
  const editOpenRowIds = useSelector(edit.store, (state) => state.openRowIds);
717
743
  const newRowCount = useSelector(edit.store, (state) => state.newRows.length);
718
744
  const deletedRowIds = useSelector(edit.store, (state) => state.deletedRowIds);
745
+ // Rows carrying a dirty draft, for the row-level marker. Derived from the
746
+ // same projections the cells subscribe to; the array's identity churns with
747
+ // `openRowIds`' own per-publish churn, so this adds no new render class.
748
+ const editDirtyRowIds = useSelector(edit.store, (state) =>
749
+ state.openRowIds.filter(
750
+ (id) => (state.rows[id]?.dirtyFields.length ?? 0) > 0,
751
+ ),
752
+ );
719
753
 
720
754
  const { loading, noResultsLabel = labels.noResults } =
721
755
  table.options.meta ?? {};
@@ -1497,7 +1531,7 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
1497
1531
  close: TMDataGridCellEditorClose,
1498
1532
  ) => {
1499
1533
  // Where the keyboard goes: Enter moves down, Tab to the next editable
1500
- // cell (the deferring batch variants move the same way, draft in tow) -
1534
+ // cell (the deferring draft variants move the same way, draft in tow) -
1501
1535
  // everything else, and a saved row, goes back to where the edit was.
1502
1536
  const move =
1503
1537
  close.via === "enter"
@@ -2188,11 +2222,17 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2188
2222
  data-highlighted={
2189
2223
  features.highlightRow && row.id === highlightedRowId
2190
2224
  }
2191
- // Marked deleted under batch: struck through and inert
2225
+ // Marked deleted under draft: struck through and inert
2192
2226
  // until submitAll reports it, or the mark is toggled back.
2193
2227
  data-deleted={
2194
2228
  deletedRowIds.length > 0 && deletedRowIds.includes(row.id)
2195
2229
  }
2230
+ // Carrying a dirty draft - the row-level face of the cells'
2231
+ // own data-dirty markers, for row-scoped styling.
2232
+ data-dirty={
2233
+ editDirtyRowIds.length > 0 &&
2234
+ editDirtyRowIds.includes(row.id)
2235
+ }
2196
2236
  // The menu is anchored to the rowgroup, so Mantine's own
2197
2237
  // `data-expanded` lands there rather than on a row. This is
2198
2238
  // what says which row the open menu is about.
@@ -27,6 +27,7 @@ export {
27
27
  IconArrowBackUp as RestoreIcon,
28
28
  IconCheck as CheckIcon,
29
29
  IconMenu2 as BurgerIcon,
30
+ IconPlus as PlusIcon,
30
31
  IconTrash as TrashIcon,
31
32
  IconPencil as PencilIcon,
32
33
  IconPinnedOff as PinOffIcon,
@@ -122,6 +122,13 @@ export type TMDataGridFeatureFlags = {
122
122
  editing: boolean;
123
123
  /** The commit policy, or `null` while editing is off. */
124
124
  editMode: TMDataGridEditMode | null;
125
+ /**
126
+ * Whether a confirmed entry row stays pinned in the sticky entry block
127
+ * until Save all - `editing.newRowsSticky`, draft mode only. Off by
128
+ * default: a confirmed row joins the scrolling flow instead, so entering
129
+ * many rows cannot fill the viewport with sticky chrome.
130
+ */
131
+ editNewRowsSticky: boolean;
125
132
  /** The generated row-number gutter - `enableRowNumbers`. Off by default. */
126
133
  rowNumbers: boolean;
127
134
  /**
@@ -204,6 +211,9 @@ export function readFeatureFlags<TData extends RowData>(
204
211
  grouping: options.enableGrouping ?? options.manualPagination !== true,
205
212
  editing: options.editing !== undefined,
206
213
  editMode: options.editing?.mode ?? null,
214
+ editNewRowsSticky:
215
+ options.editing?.mode === "draft" &&
216
+ options.editing.newRowsSticky === true,
207
217
  rowNumbers: options.enableRowNumbers === true,
208
218
  // A predicate counts as on - some rows may still pin.
209
219
  rowPinning:
@@ -0,0 +1,68 @@
1
+ import type { Cell, CellContext, Row } from "@tanstack/react-table";
2
+ import type { TMDataGridRowData } from "../TMDataGridContext";
3
+ import type { TMDataGridFeatures } from "../useTMDataGrid";
4
+
5
+ type ErasedCell = Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
6
+ type ErasedRow = Row<TMDataGridFeatures, TMDataGridRowData>;
7
+ type ErasedCellContext = CellContext<
8
+ TMDataGridFeatures,
9
+ TMDataGridRowData,
10
+ unknown
11
+ >;
12
+
13
+ /**
14
+ * The row internals the draft wrapper overrides. Private to table-core, but
15
+ * load-bearing for it: `row.getValue` reads `_valuesCache` before running
16
+ * `column.accessorFn(row.original, row.index)`, so owning fresh caches and a
17
+ * draft `original` is what makes every accessor - key, function, dot path -
18
+ * re-derive from the draft. Pinned by draftCellContext.test.ts, which is the
19
+ * tripwire for a beta bump moving these.
20
+ */
21
+ type RowInternals = {
22
+ original: TMDataGridRowData;
23
+ _valuesCache: Record<string, unknown>;
24
+ _uniqueValuesCache: Record<string, unknown>;
25
+ };
26
+
27
+ /**
28
+ * A cell render context whose values come from a row's edit draft instead of
29
+ * `data` - how a parked draft is shown through the column's own `cell`
30
+ * renderer.
31
+ *
32
+ * v9 constructs rows and cells as `Object.create(prototype)` instances whose
33
+ * API methods dispatch on `this`, so a wrapper inheriting the real object but
34
+ * owning `original` and empty value caches serves draft values from the same
35
+ * code paths the real row uses. The context is hand-built rather than taken
36
+ * from the wrapper's `getContext()`, which is memoized per instance and would
37
+ * be inherited from the real cell.
38
+ *
39
+ * Limitation, accepted: instance-memoized traversals (`row.getAllCells()` and
40
+ * friends) are inherited already-bound to the real row, so a renderer walking
41
+ * `ctx.row` beyond `original` / `getValue()` / `renderValue()` sees committed
42
+ * values. The documented renderer surface sees the draft.
43
+ */
44
+ export function draftCellContext(
45
+ cell: ErasedCell,
46
+ draftValues: TMDataGridRowData,
47
+ ): ErasedCellContext {
48
+ const draftRow = Object.create(cell.row) as ErasedRow;
49
+ const internals = draftRow as unknown as RowInternals;
50
+ internals.original = draftValues;
51
+ internals._valuesCache = Object.create(null) as Record<string, unknown>;
52
+ internals._uniqueValuesCache = Object.create(null) as Record<string, unknown>;
53
+
54
+ const draftCell = Object.create(cell) as ErasedCell;
55
+ (draftCell as { row: ErasedRow }).row = draftRow;
56
+
57
+ return {
58
+ table: cell.table,
59
+ column: cell.column,
60
+ row: draftRow,
61
+ cell: draftCell,
62
+ getValue: () => draftRow.getValue(cell.column.id),
63
+ renderValue: () =>
64
+ draftRow.getValue(cell.column.id) ??
65
+ cell.table.options.renderFallbackValue ??
66
+ null,
67
+ };
68
+ }