@rozie-ui/data-table-lit 0.7.4 → 0.8.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/src/DataTable.ts CHANGED
@@ -6,7 +6,9 @@ import { ContextProvider, createContext } from '@lit/context';
6
6
  import { repeat } from 'lit/directives/repeat.js';
7
7
  import '@rozie-ui/popover-lit';
8
8
  import { isSafeKey, wrapAggregationFn, indexDefsById, editorKindWarning, collectGroupableLeafDefs } from './helpers/columnDefUtils';
9
+ import { columnRuleKeys } from './helpers/columnRuleUtils';
9
10
  import { applyUpdater, clamp, focusables } from './helpers/indexMath';
11
+ import { resolveFollow, sameIdSequence } from './helpers/activeFollow';
10
12
  import { escapeTsvField, parseTsv, tileGridToBox, tileIndex } from './helpers/tsvGrid';
11
13
  import { replaceRowValue, indexOfRowIn, replaceRowValues } from './helpers/rowValueUtils';
12
14
  import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
@@ -30,6 +32,7 @@ getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as mak
30
32
  // guard so they never execute when off — the import token is the only static virtual-core
31
33
  // presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
32
34
  import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
35
+ import { createHoldController, viewStatesEqual, CLEAN_VIEW_STATE } from './helpers/heldView';
33
36
 
34
37
  // table-core instance — top-level `let` referenced from hooks → React hoists to
35
38
  // useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
@@ -104,6 +107,12 @@ export interface DataTableHistoryChangePayload {
104
107
  canUndo: boolean;
105
108
  canRedo: boolean;
106
109
  }
110
+ /** The held-view state (`holdView`): whether the view is held right now, and how many rows the held view disagrees with. */
111
+ export interface DataTableViewState {
112
+ held: boolean;
113
+ filterMismatches: number;
114
+ sortMismatches: number;
115
+ }
107
116
  /** The `range-change` payload: the new range corners (both `null` when the range cleared). */
108
117
  export type DataTableRangeChangePayload = DataTableRange;
109
118
  /** The `row-activate` payload. `row` is the original data object and `index` its position in the rendered model. */
@@ -118,7 +127,7 @@ export interface DataTableVisibleRangeChangePayload {
118
127
  end: number;
119
128
  }
120
129
 
121
- export interface RozieDataTableEventMap extends Omit<HTMLElementEventMap, 'activecell-change' | 'cell-edit-commit' | 'expand-change' | 'filter-change' | 'group-change' | 'history-change' | 'page-change' | 'pin-change' | 'range-change' | 'reorder-change' | 'resize-change' | 'row-activate' | 'row-edit-commit' | 'selection-change' | 'sort-change' | 'visibility-change' | 'visible-range-change' | 'data-change' | 'sorting-change' | 'global-filter-change' | 'column-filters-change' | 'pagination-change' | 'expanded-change' | 'grouping-change' | 'row-selection-change' | 'column-visibility-change' | 'column-sizing-change' | 'column-order-change' | 'column-pinning-change'> {
130
+ export interface RozieDataTableEventMap extends Omit<HTMLElementEventMap, 'activecell-change' | 'cell-edit-commit' | 'expand-change' | 'filter-change' | 'group-change' | 'history-change' | 'page-change' | 'pin-change' | 'range-change' | 'reorder-change' | 'resize-change' | 'row-activate' | 'row-edit-commit' | 'selection-change' | 'sort-change' | 'visibility-change' | 'view-state-change' | 'visible-range-change' | 'data-change' | 'sorting-change' | 'global-filter-change' | 'column-filters-change' | 'pagination-change' | 'expanded-change' | 'grouping-change' | 'row-selection-change' | 'column-visibility-change' | 'column-sizing-change' | 'column-order-change' | 'column-pinning-change'> {
122
131
  'activecell-change': CustomEvent<DataTableActiveCellChangePayload>;
123
132
  'cell-edit-commit': CustomEvent<DataTableCellEditCommitPayload>;
124
133
  'expand-change': CustomEvent<ExpandedState>;
@@ -135,6 +144,7 @@ export interface RozieDataTableEventMap extends Omit<HTMLElementEventMap, 'activ
135
144
  'selection-change': CustomEvent<RowSelectionState>;
136
145
  'sort-change': CustomEvent<SortingState>;
137
146
  'visibility-change': CustomEvent<VisibilityState>;
147
+ 'view-state-change': CustomEvent<DataTableViewState>;
138
148
  'visible-range-change': CustomEvent<DataTableVisibleRangeChangePayload>;
139
149
  'data-change': CustomEvent<any[]>;
140
150
  'sorting-change': CustomEvent<any[]>;
@@ -683,7 +693,7 @@ export default class DataTable extends SignalWatcher(LitElement) {
683
693
  @property({ type: Array, attribute: 'data' }) _data_attr!: any[];
684
694
  private _dataControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'data-change', defaultValue: [], initialControlledValue: undefined });
685
695
  /**
686
- * Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, pinned?, width? }`. Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union). A `pinned` value of `'left'` or `'right'` is applied once as the table's initial `columnPinning` state — identical to `<Column pinned>` — which also REORDERS that column, since table-core orders visible cells `[left-pinned, center, right-pinned]`. A consumer who has already expressed a pin (an initial two-way `columnPinning` model value or an interactive pin) owns the slice and this declaration is ignored.
696
+ * Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, filterFn?, sortingFn?, pinned?, width? }` (`filterFn`/`sortingFn` take a table-core built-in name or a custom function). Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union). A `pinned` value of `'left'` or `'right'` is applied once as the table's initial `columnPinning` state — identical to `<Column pinned>` — which also REORDERS that column, since table-core orders visible cells `[left-pinned, center, right-pinned]`. A consumer who has already expressed a pin (an initial two-way `columnPinning` model value or an interactive pin) owns the slice and this declaration is ignored.
687
697
  */
688
698
  @property({ type: Array }) columns: any[] = [];
689
699
  /**
@@ -805,6 +815,10 @@ export default class DataTable extends SignalWatcher(LitElement) {
805
815
  * The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`.
806
816
  */
807
817
  @property({ type: Number, reflect: true, attribute: 'undo-limit' }) undoLimit: number = 100;
818
+ /**
819
+ * When `true`, the table holds its current view through its own writes (edit, paste, fill, cut, clear, the boolean toggle, undo and redo): rows keep the membership and order they had before the write and new values render in place, so an edited row does not jump or vanish under an active sort or filter. The view is re-derived (and both mismatch counts reset to 0) when `sorting`, `columnFilters` or `globalFilter` change, when `refreshView()` is called, when the dataset is swapped externally, or when `holdView` is toggled. Paging, page size, column visibility/order/pinning/sizing and expanding rows move through the held rows; a table write or `refreshView()` never changes the page (a sort, filter or grouping change returns to the first page). Falls back to live behaviour (with one console warning) while grouping is active, in `manual` mode, or with tree data (`getSubRows`). Passing a fresh `data` array that did not come from the table (for example reloading from the server after a save) is an external swap: it re-derives the view and clears undo history. Track mismatches with `view-state-change` / `getViewState()`.
820
+ */
821
+ @property({ type: Boolean, reflect: true, attribute: 'hold-view' }) holdView: boolean = false;
808
822
  /**
809
823
  * Opt-in windowing grammar: `false` (default, off — byte-identical to a non-virtual table) | `true` or `'rows'` (vertical row windowing; `true` is byte-behavior-identical to every existing consumer, zero churn) | `'columns'` (horizontal column windowing) | `'both'` (both axes windowed). Row windowing renders only the visible slice of rows inside a bounded `rdt-scroll` container (with leading/trailing spacer rows preserving total scroll height), windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome. Column windowing renders only the visible slice of leaf columns inside the same `rdt-scroll` container. An unrecognised string behaves as `false`.
810
824
  */
@@ -1144,7 +1158,8 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1144
1158
  this._disconnectCleanups.push(effect(() => { const __watchVal = (() => [this.sorting, this.globalFilter, this.columnFilters, this.pagination, // Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
1145
1159
  // changes at runtime (e.g. a server response updates the total) so getPageCount() and the
1146
1160
  // Next button availability track the new total.
1147
- this.rowCount, this.pageCount, this.rowSelection, this.expanded, this.expandable, this.grouping, this.groupable, this.columnVisibility, this.columnSizing, this.columnOrder, this.columnPinning, this.selectionMode, (this.data || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
1161
+ this.rowCount, this.pageCount, this.rowSelection, this.expanded, this.expandable, this.grouping, this.groupable, this.columnVisibility, this.columnSizing, this.columnOrder, this.columnPinning, this.selectionMode, // Held view: toggling holdView re-derives (reFeed → setActive).
1162
+ this.holdView, (this.data || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
1148
1163
  // even when the fresh array is the SAME length (a single-cell edit replaces one row
1149
1164
  // object → new array ref, identical length → the .length key alone would miss it). The
1150
1165
  // controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
@@ -1212,6 +1227,8 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1212
1227
  // call. One-shot and self-latching — see seedColumnPinning() in stateAssembly.rzts.
1213
1228
  this.seedColumnPinning();
1214
1229
  // Build the table instance HERE so the closures below capture the live `table`.
1230
+ const cols0 = this.tableColumns();
1231
+ this.lastColumnSig = this.columnSetSignature(cols0);
1215
1232
  this.table = createTable({
1216
1233
  // Plain value (NOT a `get data()` getter): an object-literal getter rebinds
1217
1234
  // `this` to the options object, and the Angular/Lit emitters resolve $props via
@@ -1222,11 +1239,12 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1222
1239
  // currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
1223
1240
  // (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
1224
1241
  data: this.feedData(),
1225
- columns: this.tableColumns(),
1242
+ columns: cols0,
1226
1243
  state: this.currentState(),
1227
1244
  getCoreRowModel: getCoreRowModel(),
1228
- getSortedRowModel: getSortedRowModel(),
1229
- getFilteredRowModel: getFilteredRowModel(),
1245
+ // Held view: the wrapped factories, ALWAYS (see holdCtrl's declaration).
1246
+ getSortedRowModel: this.heldSortedRowModel,
1247
+ getFilteredRowModel: this.heldFilteredRowModel,
1230
1248
  getPaginationRowModel: getPaginationRowModel(),
1231
1249
  // Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
1232
1250
  // (mirrors the other models) — inert when `expanded` is empty + no getSubRows
@@ -1311,6 +1329,42 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1311
1329
  // satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
1312
1330
  onStateChange: () => {}
1313
1331
  });
1332
+ // Page index (final review #1). table-core's autoResetPageIndex (on by default unless
1333
+ // manualPagination) queues `table.resetPageIndex()` in a microtask whenever the core,
1334
+ // filtered, sorted or grouped memo recomputes — which includes every table write (new data)
1335
+ // and refreshView()'s state copy — so a write on page 2 jumped back to page 1 and left the
1336
+ // active cell on another record. The page reset is explicit instead: reFeed returns to page 1
1337
+ // on a sort / columnFilters / globalFilter / grouping change. An external data swap keeps the
1338
+ // page (the #4 overflow clamp in refreshRowModel still pulls it back when the new data has
1339
+ // fewer pages): the swap is detected on different ticks per target (reFeed vs
1340
+ // maybeClearHistoryOnExternalSwap), so a reset there would race the auto-reset's re-write.
1341
+ // The queued auto-reset is NOT switched off (autoResetPageIndex: false): its pagination write
1342
+ // is a re-render tick that several React/Solid paths have come to rely on (measured: B23/WR-01
1343
+ // relocated-row focus, the column-virtual window, header tab stop, editor focus return all
1344
+ // regress without it — the focus polls run before the framework commits the re-sorted rows).
1345
+ // So it is kept, but it re-writes the CURRENT page (table-core's fed state — fresh on every
1346
+ // target, and already page 1 after an explicit reset) instead of turning to page 1.
1347
+ this.table.resetPageIndex = () => {
1348
+ if (!this.table) return;
1349
+ const pg = this.table.getState().pagination;
1350
+ this.writePagination({
1351
+ pageIndex: pg.pageIndex,
1352
+ pageSize: pg.pageSize
1353
+ });
1354
+ };
1355
+ // Held view: seed the sort/filter references the table was built with, so the first reFeed
1356
+ // (often the first write's own data re-feed) does not misread them as a view change and
1357
+ // release a just-captured hold.
1358
+ const st0 = this.currentState();
1359
+ this.lastHoldSorting = st0.sorting;
1360
+ this.lastHoldColumnFilters = st0.columnFilters;
1361
+ this.lastHoldGlobalFilter = st0.globalFilter;
1362
+ this.lastPageGrouping = st0.grouping;
1363
+ this.syncHoldActive(st0);
1364
+ // Seed the external-swap baseline with the mount-time dataset, so the first re-feed after an
1365
+ // early write (before any maybeClearHistoryOnExternalSwap ran) cannot misread the initial,
1366
+ // unstamped `$props.data` as a swap and release a just-captured hold.
1367
+ this.lastPropsData = this.data;
1314
1368
  this.refreshRowModel = () => {
1315
1369
  if (!this.table) return;
1316
1370
  // Capture fresh locals; never write a $data key then re-read it in the same fn
@@ -1340,15 +1394,32 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1340
1394
  // through here) — guarded internally on colsWindowed() && colVirtualizer, so this is a no-op
1341
1395
  // on every other path.
1342
1396
  this.remeasureColumnWindow();
1343
- // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
1344
- // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
1345
- // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
1346
- // B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
1397
+ // Part 3 + D-05: on every data change (re-sort/filter/paginate/page-size/writes — all
1398
+ // re-pull here) the active cell FOLLOWS its record by row id; where it cannot (paging,
1399
+ // expand/collapse, an external swap) it falls back to the D-05 clamp (same indices, clamped
1400
+ // if the grid shrank), and a record that left the view after a view change or a table write
1401
+ // parks on its column header (followActiveCell, gridKeydownHandlers.rzts). isGrid()-gated so
1402
+ // 'table' mode is untouched.
1403
+ // B8/B23: pass the FRESH bounds and ids derived from `nextRows` (NOT $data.rows, which is the
1347
1404
  // async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
1348
- // the range corners on React too — never re-reading the pre-change model.
1405
+ // the range corners on React too — never re-reading the pre-change model. The follow flags
1406
+ // are plain `let`s, read and cleared here (ROZ138-safe).
1349
1407
  const nextRowCount = nextRows.length;
1350
1408
  const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
1351
- this.clampActiveCell(nextRowCount, nextColCount);
1409
+ const nextIds = nextRows.map((r: any) => r.id);
1410
+ const fedData = this.table.options.data;
1411
+ const dataChanged = fedData !== this.lastFedData;
1412
+ this.lastFedData = fedData;
1413
+ const viewChangeOrWrite = this.pendingViewChange || this.pendingWrite && dataChanged;
1414
+ // Final review #8: new data that is NOT one of our writes (an external swap) without
1415
+ // `getRowId` — the row ids are source indices, meaningless across the swap, so following
1416
+ // them could land the active cell (or an open editor) on an unrelated record. Don't follow:
1417
+ // the D-05 clamp keeps today's position. Reads table.options (fresh on every target).
1418
+ const swapWithoutIds = dataChanged && !this.pendingWrite && !this.pendingViewChange && this.table.options.getRowId == null;
1419
+ this.pendingViewChange = false;
1420
+ if (dataChanged) this.pendingWrite = false;
1421
+ this.followActiveCell(swapWithoutIds ? null : this.prevRowIds, nextIds, viewChangeOrWrite, nextRowCount, nextColCount, this.pendingEditFollow != null);
1422
+ this.prevRowIds = nextIds;
1352
1423
  // #4: clamp a pageIndex that now points PAST the last page. When the consumer holds
1353
1424
  // pagination.pageIndex (controlled) and shrinks the data (filter / replace) so there are
1354
1425
  // fewer pages, the body renders blank ("Page 6 of 3" with Next disabled). Read table-core's
@@ -1362,11 +1433,11 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1362
1433
  // the clamp back through the pagination prop, the re-feed re-enters here with
1363
1434
  // pageIndex === pc - 1, so `pageIndex > pc - 1` is false → no re-emit; a consumer that
1364
1435
  // ignores the event triggers no further re-feed, so it stays a single emit either way.
1365
- // • No fight with table-core's autoResetPageIndex: that reset only fires on table-core's OWN
1366
- // setX mutations, which this fully-controlled-state architecture never calls (filters/data
1367
- // flow through setOptions), so reading the live state here can only fire on a genuine
1368
- // overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
1369
- // writing paginationDefault, with no regression since table-core does not auto-clamp here).
1436
+ // • table-core's auto page reset no longer turns the page (table.resetPageIndex is
1437
+ // overridden after createTable): the sort/filter/grouping page reset is explicit (reFeed),
1438
+ // and a table write, refreshView() or an external swap never turns the page. This
1439
+ // clamp therefore only fires on a genuine overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
1440
+ // writing paginationDefault).
1370
1441
  const pgState = this.table.getState().pagination;
1371
1442
  const pc = this.table.getPageCount();
1372
1443
  if (pc > 0 && pgState.pageIndex > pc - 1) {
@@ -1392,6 +1463,17 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1392
1463
  // across selection changes; a microtask defer covers React's post-render DOM patch.
1393
1464
  this.syncIndeterminate();
1394
1465
  if (typeof queueMicrotask !== 'undefined') queueMicrotask(this.syncIndeterminate);else Promise.resolve().then(this.syncIndeterminate);
1466
+ // Held view: edge-triggered view-state-change (only when held/filterMismatches/
1467
+ // sortMismatches changed). lastViewState is a plain `let` (ROZ138-safe).
1468
+ const vs = this.holdCtrl.viewState(this.table);
1469
+ if (!viewStatesEqual(vs, this.lastViewState)) {
1470
+ this.lastViewState = vs;
1471
+ this.dispatchEvent(new CustomEvent<DataTableViewState>("view-state-change", {
1472
+ detail: vs,
1473
+ bubbles: true,
1474
+ composed: true
1475
+ }));
1476
+ }
1395
1477
  };
1396
1478
 
1397
1479
  // initial pull
@@ -1547,10 +1629,11 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1547
1629
  }
1548
1630
 
1549
1631
  updated(changedProperties: Map<string, unknown>): void {
1550
- if (this.__rozieFirstUpdateDone && (changedProperties.has('rowCount') || changedProperties.has('pageCount') || changedProperties.has('expandable') || changedProperties.has('groupable') || changedProperties.has('selectionMode') || changedProperties.has('columns'))) { const __watchVal = untracked(() => [this.sorting, this.globalFilter, this.columnFilters, this.pagination, // Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
1632
+ if (this.__rozieFirstUpdateDone && (changedProperties.has('rowCount') || changedProperties.has('pageCount') || changedProperties.has('expandable') || changedProperties.has('groupable') || changedProperties.has('selectionMode') || changedProperties.has('holdView') || changedProperties.has('columns'))) { const __watchVal = untracked(() => [this.sorting, this.globalFilter, this.columnFilters, this.pagination, // Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
1551
1633
  // changes at runtime (e.g. a server response updates the total) so getPageCount() and the
1552
1634
  // Next button availability track the new total.
1553
- this.rowCount, this.pageCount, this.rowSelection, this.expanded, this.expandable, this.grouping, this.groupable, this.columnVisibility, this.columnSizing, this.columnOrder, this.columnPinning, this.selectionMode, (this.data || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
1635
+ this.rowCount, this.pageCount, this.rowSelection, this.expanded, this.expandable, this.grouping, this.groupable, this.columnVisibility, this.columnSizing, this.columnOrder, this.columnPinning, this.selectionMode, // Held view: toggling holdView re-derives (reFeed → setActive).
1636
+ this.holdView, (this.data || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
1554
1637
  // even when the fresh array is the SAME length (a single-cell edit replaces one row
1555
1638
  // object → new array ref, identical length → the .length key alone would miss it). The
1556
1639
  // controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
@@ -2022,6 +2105,108 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2022
2105
 
2023
2106
  restoringHistory: boolean = false;
2024
2107
 
2108
+ // ── Held view (`holdView`) ─────────────────────────────────────────────────────────────
2109
+ // ONE hold controller per instance (helpers/heldView.ts) and the two wrapped row-model
2110
+ // factories built ONCE from it, so createTable and every reFeed setOptions pass the SAME
2111
+ // function references (a fresh wrapper per re-feed would rebuild table-core's memoized
2112
+ // stage). Both wrapped factories are passed ALWAYS — not only while holding — because the
2113
+ // sort-mismatch count is only correct when wrapSorted observes the live sort. While the
2114
+ // controller is not holding, the wrappers return the base model unchanged (same identity),
2115
+ // so a table without `holdView` is behaviour-identical. Top-level `const`s: React lowers each
2116
+ // to a `useMemo(..., [])` (one instance per component). NOT `let`: React hoists a `let` to
2117
+ // `useRef(init)` and does not rewrite a sibling hoisted `let` read inside that initializer to
2118
+ // `.current`, so `let heldX = holdCtrl.wrapX(...)` would call `wrapX` on the ref object.
2119
+ holdCtrl = createHoldController();
2120
+
2121
+ heldFilteredRowModel = this.holdCtrl.wrapFiltered(getFilteredRowModel());
2122
+
2123
+ heldSortedRowModel = this.holdCtrl.wrapSorted(getSortedRowModel());
2124
+
2125
+ // Set when the view was re-derived (a sort/filter change, refreshView()) and by writeData on
2126
+ // every table write; consumed by the active-cell follow (Task 5). Plain `let`s, NOT $data —
2127
+ // written and read inside refreshRowModel's call graph (ROZ138 / React stale reads).
2128
+ pendingViewChange = false;
2129
+
2130
+ pendingWrite = false;
2131
+
2132
+ // Part 3 (active cell follows its record): the visible row ids at the end of the last
2133
+ // refreshRowModel, and the data array that refresh fed. pendingWrite is consumed only by the
2134
+ // refresh whose fed data CHANGED — in controlled `r-model:data` mode writeData's own
2135
+ // $data.dataDefault write re-feeds the OLD `$props.data` first, and the parent's echo arrives
2136
+ // a pass later; consuming the flag on that first, unchanged pass would turn the write's
2137
+ // vanished row into a clamp instead of a park.
2138
+ prevRowIds: string[] | null = null;
2139
+
2140
+ lastFedData: unknown = null;
2141
+
2142
+ // The last emitted view-state-change payload (refreshRowModel edge-triggers the emit).
2143
+ lastViewState = CLEAN_VIEW_STATE;
2144
+
2145
+ // reFeed bookkeeping: the sort/filter references fed last time (a change releases the hold)
2146
+ // and the one-shot fallback warning latch.
2147
+ lastHoldSorting: unknown = null;
2148
+
2149
+ lastHoldColumnFilters: unknown = null;
2150
+
2151
+ lastHoldGlobalFilter: unknown = null;
2152
+
2153
+ holdFallbackWarned = false;
2154
+
2155
+ // The grouping reference fed last time (a change returns to page 1 — see table.resetPageIndex).
2156
+ lastPageGrouping: unknown = null;
2157
+
2158
+ // The column-set signature fed last time (see columnSetSignature / reFeed).
2159
+ lastColumnSig = '';
2160
+
2161
+ // A signature of the column set as table-core's sort / filter stages see it: each column's id
2162
+ // and whether it can sort / filter (groups flattened, iteratively — no self-recursive const,
2163
+ // which React lowers to a useCallback). table-core memoizes those stages on the sorting /
2164
+ // columnFilters references and the upstream model only, NOT on the column defs; a stage that
2165
+ // first ran before a sorted/filtered column existed (a <Column> child registering after the
2166
+ // first pull, the norm on React/Solid/Lit) silently skipped that sort/filter and stays cached.
2167
+ columnSetSignature = (defs: any) => {
2168
+ let sig = '';
2169
+ const stack = (defs || []).slice().reverse();
2170
+ while (stack.length) {
2171
+ const d = stack.pop();
2172
+ if (!d) continue;
2173
+ sig += String(d.id) + (d.enableSorting ? '+' : '') + (d.enableColumnFilter ? '*' : '') + '|';
2174
+ if (Array.isArray(d.columns)) for (let i = d.columns.length - 1; i >= 0; i--) stack.push(d.columns[i]);
2175
+ }
2176
+ return sig;
2177
+ };
2178
+
2179
+ // The last `$props.data` reference maybeClearHistoryOnExternalSwap examined (seeded in
2180
+ // $onMount). Declared up here, before reFeed, so reFeed can call isExternalSwap() without a
2181
+ // forward reference (React useCallback ordering).
2182
+ lastPropsData: unknown = null;
2183
+
2184
+ // Read-only: has `$props.data` changed to a reference we did NOT write since the last
2185
+ // maybeClearHistoryOnExternalSwap? (reFeed uses it to release a held view before its refresh.)
2186
+ isExternalSwap = () => {
2187
+ const pd = this.data;
2188
+ if (pd === this.lastPropsData) return false;
2189
+ return !(pd != null && (pd as any)[this.DATA_WRITE_TOKEN_KEY] != null);
2190
+ };
2191
+
2192
+ // Whether the hold applies for state `st`: holdView on, and not one of the live-behaviour
2193
+ // fallbacks (grouping active, `manual`, tree data). Called from $onMount (before the first
2194
+ // pull) and from every reFeed; warns once when holdView is on but cannot hold.
2195
+ syncHoldActive = (st: any) => {
2196
+ const wanted = this.holdView === true;
2197
+ const active = wanted && this.manual !== true && this.getSubRows == null && !(st.grouping || []).length;
2198
+ // A setActive that RELEASES a hold (holdView on→off, or a fallback flipping the controller
2199
+ // inactive while it was holding) re-derives the live view: for the active-cell follow that
2200
+ // is a view change (a record the live view drops parks on its header).
2201
+ const wasHolding = this.holdCtrl.isHolding();
2202
+ this.holdCtrl.setActive(active);
2203
+ if (wasHolding && !this.holdCtrl.isHolding()) this.pendingViewChange = true;
2204
+ if (wanted && !active && !this.holdFallbackWarned) {
2205
+ this.holdFallbackWarned = true;
2206
+ console.warn('[rozie-data-table] holdView falls back to live behaviour while grouping is active, in manual mode, or with tree data (getSubRows); the view is not held.');
2207
+ }
2208
+ };
2209
+
2025
2210
  // Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
2026
2211
  // has not bound `expanded` and has not yet toggled any group, group-header rows default to
2027
2212
  // EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
@@ -2313,6 +2498,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2313
2498
  // a custom fn defensively wrapped (T-50-04).
2314
2499
  groupable: c.groupable !== false,
2315
2500
  aggregationFn: wrapAggregationFn(c.aggregationFn),
2501
+ // Per-column filterFn/sortingFn (built-in name or custom fn); absent when unset.
2502
+ ...columnRuleKeys(c, id),
2316
2503
  pinned: c.pinned != null ? c.pinned : '',
2317
2504
  width: c.width != null ? c.width : '',
2318
2505
  // A-01 — `width` alone is inert; table-core sizes from `size`. Spread so an
@@ -2408,6 +2595,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2408
2595
  // Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
2409
2596
  groupable: spec.groupable !== false,
2410
2597
  aggregationFn: wrapAggregationFn(spec.aggregationFn),
2598
+ ...columnRuleKeys(spec, id),
2411
2599
  pinned: spec.pinned != null ? spec.pinned : '',
2412
2600
  width: spec.width != null ? spec.width : '',
2413
2601
  // A-01 — see buildConfigDef; same mapping for the <Column> registry branch.
@@ -2764,6 +2952,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2764
2952
  this.recordSnapshot(this.currentData());
2765
2953
  this.emitHistoryChangeIfEdged(prevU, prevR);
2766
2954
  }
2955
+ // Held view (`holdView`): freeze the view as it is BEFORE this write (a no-op when the
2956
+ // controller is inactive or already holding), then note which source rows this write
2957
+ // replaced (the sort-mismatch count). Outside the `!restoringHistory` branch on purpose:
2958
+ // undo/redo replays are table writes too. pendingWrite is consumed by the active-cell
2959
+ // follow.
2960
+ if (this.table) this.holdCtrl.capture(this.table);
2961
+ this.holdCtrl.noteWrite(this.currentData() || [], Array.isArray(next) ? next : []);
2962
+ this.pendingWrite = true;
2767
2963
  const fresh = Array.isArray(next) ? next.slice() : next;
2768
2964
  try {
2769
2965
  Object.defineProperty(fresh, this.DATA_WRITE_TOKEN_KEY, {
@@ -4312,6 +4508,18 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4312
4508
  return this.lazyRowsActive() ? (row: any) => !this.rowIsLazyPlaceholder(row) : true;
4313
4509
  };
4314
4510
 
4511
+ // Funnel a pagination write the table makes on its own (reFeed's page reset). writePagination is echo-guarded by `programmatic`: a re-feed that runs
4512
+ // synchronously INSIDE another slice's funnel (a header filter input on the fine-grained
4513
+ // targets) would have it silently dropped, so defer it to a microtask then.
4514
+ writePaginationSoon = (next: any) => {
4515
+ if (!this.programmatic) {
4516
+ this.writePagination(next);
4517
+ return;
4518
+ }
4519
+ const run = () => this.writePagination(next);
4520
+ if (typeof queueMicrotask !== 'undefined') queueMicrotask(run);else Promise.resolve().then(run);
4521
+ };
4522
+
4315
4523
  // Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
4316
4524
  // re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
4317
4525
  reFeed = () => {
@@ -4322,11 +4530,78 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4322
4530
  // TRANSIENTLY-STALE `$props.data` and wrongly wipe a just-recorded edit's history. The reset is
4323
4531
  // keyed on the `$props.data` REFERENCE actually changing instead — see the $onUpdate backstop
4324
4532
  // below (`maybeClearHistoryOnExternalSwap`), which runs on all six targets.
4533
+ //
4534
+ // Held view: decide whether the hold applies (it falls back to live behaviour under
4535
+ // grouping, `manual`, and tree data), and re-derive on a sort/filter change. Fresh locals
4536
+ // only — the state object is read once here and passed to setOptions below.
4537
+ const st0 = this.currentState();
4538
+ this.syncHoldActive(st0);
4539
+ let st = st0;
4540
+ // Page reset (table-core's auto reset no longer turns the page — see table.resetPageIndex): a sort / filter / grouping change
4541
+ // returns to page 1. The first page is fed to table-core in THIS re-feed (a fresh state
4542
+ // object), so the re-derive — and the active-cell follow, which resolves against the current
4543
+ // page — already sees page 1; writePagination (after the refresh, below) carries it to the
4544
+ // consumer's pagination model. Skipped when already on page 1 (no spurious page-change) and
4545
+ // under `manual` (table-core never auto-reset there either).
4546
+ let pageReset: {
4547
+ pageIndex: number;
4548
+ pageSize: number;
4549
+ } | null = null;
4550
+ const groupingChanged = st0.grouping !== this.lastPageGrouping;
4551
+ this.lastPageGrouping = st0.grouping;
4552
+ const viewChanged = st0.sorting !== this.lastHoldSorting || st0.columnFilters !== this.lastHoldColumnFilters || st0.globalFilter !== this.lastHoldGlobalFilter;
4553
+ if (viewChanged) {
4554
+ this.lastHoldSorting = st0.sorting;
4555
+ this.lastHoldColumnFilters = st0.columnFilters;
4556
+ this.lastHoldGlobalFilter = st0.globalFilter;
4557
+ this.holdCtrl.release();
4558
+ this.pendingViewChange = true;
4559
+ }
4560
+ if ((viewChanged || groupingChanged) && this.manual !== true && st0.pagination && st0.pagination.pageIndex !== 0) {
4561
+ pageReset = {
4562
+ pageIndex: 0,
4563
+ pageSize: st0.pagination.pageSize
4564
+ };
4565
+ st = {
4566
+ ...st0,
4567
+ pagination: pageReset
4568
+ };
4569
+ }
4570
+ // The column set changed (a <Column> registered, a `columns` entry was added, a column became
4571
+ // sortable/filterable): table-core's sort and filter stages do not key on column defs, so a
4572
+ // sort or filter that referenced a column which did not exist yet stays skipped (an initial
4573
+ // `sorting` was ignored on React/Solid/Lit). Feed shallow copies of both arrays — the same
4574
+ // memo invalidation refreshView() uses — so they re-run against the new columns. Not a view
4575
+ // change: the hold (if any) is kept and the page is not reset.
4576
+ const cols = this.tableColumns();
4577
+ const colSig = this.columnSetSignature(cols);
4578
+ if (colSig !== this.lastColumnSig) {
4579
+ this.lastColumnSig = colSig;
4580
+ st = {
4581
+ ...st,
4582
+ sorting: [...(st.sorting || [])],
4583
+ columnFilters: [...(st.columnFilters || [])]
4584
+ };
4585
+ }
4586
+ // An external swap releases the hold BEFORE this re-feed's refresh, so the one
4587
+ // view-state-change it emits is already the re-derived state (never a stale held count
4588
+ // measured against the new dataset). Only the hold is touched here — the history clear and
4589
+ // the lastPropsData bookkeeping stay in maybeClearHistoryOnExternalSwap (keyed on the
4590
+ // `$props.data` reference; see its comment). isExternalSwap() is read-only and compares
4591
+ // against that same lastPropsData, so a transiently-stale `$props.data` (the reference
4592
+ // already recorded) never reads as a swap.
4593
+ if (this.holdCtrl.isHolding() && this.isExternalSwap()) this.holdCtrl.release();
4594
+ // An external swap is a reset, not one of our writes: drop a write flag the echo never
4595
+ // consumed (one-way `data`, no r-model), so the swap keeps the D-05 clamp fallback.
4596
+ if (this.pendingWrite && this.isExternalSwap()) this.pendingWrite = false;
4325
4597
  this.table.setOptions((prev: any) => ({
4326
4598
  ...prev,
4327
4599
  data: this.feedData(),
4328
- columns: this.tableColumns(),
4329
- state: this.currentState(),
4600
+ columns: cols,
4601
+ state: st,
4602
+ // Held view: the SAME wrapped factory references every re-feed (see holdCtrl).
4603
+ getFilteredRowModel: this.heldFilteredRowModel,
4604
+ getSortedRowModel: this.heldSortedRowModel,
4330
4605
  enableRowSelection: this.rowSelectionOption(),
4331
4606
  enableMultiRowSelection: this.selectionMode === 'multiple',
4332
4607
  // Re-pass the server-side page-count sources (#2) so a RUNTIME rowCount/pageCount change
@@ -4380,6 +4655,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4380
4655
  onColumnSizingInfoChange: this.onColumnSizingInfoChangeCb
4381
4656
  }));
4382
4657
  if (this.refreshRowModel) this.refreshRowModel();
4658
+ if (pageReset) this.writePaginationSoon(pageReset);
4383
4659
  };
4384
4660
 
4385
4661
  // LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
@@ -4404,15 +4680,24 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4404
4680
  // non-data tick (sort/filter/pagination) never touches `$props.data` → never clears. Called from
4405
4681
  // BOTH the coarse re-feed watch AND the $onUpdate backstop (Lit's @property `data` the effect-
4406
4682
  // tracked watch can't observe); both are ref-gated so the redundant call is an idempotent no-op.
4407
- lastPropsData: unknown = null;
4408
-
4409
4683
  maybeClearHistoryOnExternalSwap = () => {
4410
4684
  const pd = this.data;
4411
4685
  if (pd === this.lastPropsData) return; // $props.data did not change → not an external swap
4412
4686
  this.lastPropsData = pd;
4413
- if (!this.undoable) return;
4414
4687
  if (pd != null && (pd as any)[this.DATA_WRITE_TOKEN_KEY] != null) return; // descends from our write → keep
4415
- this.clearHistory();
4688
+ // An external swap is a reset, not one of our writes: drop a write flag no echo consumed
4689
+ // (one-way `data`). Here AND in reFeed, so it holds whichever of the two runs first on a
4690
+ // target (Lit's $onUpdate and React's/Solid's effect order run this one before reFeed, after
4691
+ // which reFeed's isExternalSwap() already reads false).
4692
+ this.pendingWrite = false;
4693
+ // A genuine external swap (runs regardless of `undoable`): it re-derives a held view (a
4694
+ // backstop — reFeed normally released it already, before its own refresh) …
4695
+ if (this.holdCtrl.isHolding()) {
4696
+ this.holdCtrl.release();
4697
+ if (this.refreshRowModel) this.refreshRowModel();
4698
+ }
4699
+ // … and clears undo history (only meaningful when `undoable`).
4700
+ if (this.undoable) this.clearHistory();
4416
4701
  };
4417
4702
 
4418
4703
  lastData: any = null;
@@ -5176,6 +5461,31 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5176
5461
  // DataTable.rozie) — so DataTable.rozie's $expose references them BY NAME with zero
5177
5462
  // indirection. This file stays the seam for verbs that need a rename/adapter, not a mandatory
5178
5463
  // stop for every $expose entry.
5464
+ // Held view (`holdView`). refreshView() re-derives the view from the current filters and
5465
+ // sort and resets both mismatch counts. It works with holdView off too: it forces custom
5466
+ // filter/sort functions to re-run. table-core's filter and sort stages memoize on the
5467
+ // columnFilters/sorting array REFERENCES (not on the column defs or the functions), and
5468
+ // release() alone touches neither — so feed table-core SHALLOW COPIES of both arrays, which
5469
+ // invalidates those memos. The next reFeed restores the original references (one harmless
5470
+ // extra recompute). pendingViewChange is the component-scope `let` the active-cell follow
5471
+ // consumes.
5472
+ refreshView: () => void = () => {
5473
+ this.holdCtrl.release();
5474
+ this.pendingViewChange = true;
5475
+ if (!this.table) return;
5476
+ this.table.setOptions((p: any) => ({
5477
+ ...p,
5478
+ state: {
5479
+ ...p.state,
5480
+ columnFilters: [...(p.state.columnFilters || [])],
5481
+ sorting: [...(p.state.sorting || [])]
5482
+ }
5483
+ }));
5484
+ if (this.refreshRowModel) this.refreshRowModel();
5485
+ };
5486
+
5487
+ getViewState: () => DataTableViewState = () => this.holdCtrl.viewState(this.table);
5488
+
5179
5489
  // interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
5180
5490
  // identical to phase 48 (roles fall back to the literals, tabindex drops).
5181
5491
  isGrid = () => this.interactionMode === 'grid';
@@ -6206,6 +6516,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6206
6516
  if (!tgt || !tgt.closest) return;
6207
6517
  const cellEl = tgt.closest('[data-grid-cell]');
6208
6518
  if (!cellEl) return;
6519
+ this.activeCellEngaged = true;
6209
6520
  const rowAttr = cellEl.getAttribute('data-row');
6210
6521
  const colAttr = cellEl.getAttribute('data-col-index');
6211
6522
  if (rowAttr == null || colAttr == null) return;
@@ -6507,11 +6818,101 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6507
6818
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
6508
6819
  };
6509
6820
 
6510
- // D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
6511
- // pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
6512
- // row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
6513
- // mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
6514
- clampActiveCell = (rowCount: any, colCount: any) => {
6821
+ // followOpenEditors: when a re-derive changed the rows under an open single-cell editor or
6822
+ // full-row editor, re-address it to its record's new index (re-seating editor focus there),
6823
+ // or — when the record left the view — close it WITHOUT writing (its draft belonged to a record
6824
+ // that is no longer on screen; the active cell, on the same record, parks). Returns true when an
6825
+ // editor is still open on a moved record (it, not the cell, owns DOM focus).
6826
+ followOpenEditors = (prevIds: any, nextIds: any) => {
6827
+ if (!prevIds || sameIdSequence(prevIds, nextIds)) return false;
6828
+ let owns = false;
6829
+ const er = this._editingRow.value;
6830
+ if (er >= 0) {
6831
+ const ef = resolveFollow(prevIds, er, nextIds, true);
6832
+ if (ef.kind === 'move') {
6833
+ this._editingRow.value = ef.index;
6834
+ this.focusEditorWhenReady(ef.index, this._editingCol.value, false);
6835
+ owns = true;
6836
+ } else if (ef.kind !== 'stay') {
6837
+ // Latch the session (an unmount blur must not commit the stale draft) and tear down.
6838
+ this.committedThisSession = true;
6839
+ this.editTransition = true;
6840
+ this.endEdit();
6841
+ this.editTransition = false;
6842
+ }
6843
+ }
6844
+ const rr = this._editingRowIndex.value;
6845
+ if (rr != null && rr >= 0) {
6846
+ const rf = resolveFollow(prevIds, rr, nextIds, true);
6847
+ if (rf.kind === 'move') {
6848
+ this._editingRowIndex.value = rf.index;
6849
+ owns = true;
6850
+ } else if (rf.kind !== 'stay') {
6851
+ this.editTransition = true;
6852
+ this.endRowEdit();
6853
+ this.editTransition = false;
6854
+ }
6855
+ }
6856
+ return owns;
6857
+ };
6858
+
6859
+ // The row id at a visible index / the visible index of a row id, read off table-core's LIVE
6860
+ // model (windowSource — the same index space as $data.rows), never the React-stale $data.rows.
6861
+ rowIdInModel = (index: any) => {
6862
+ const row = this.windowSource()[index];
6863
+ return row ? row.id : null;
6864
+ };
6865
+
6866
+ modelIndexOfRowId = (id: any) => {
6867
+ const rows = this.windowSource();
6868
+ for (let i = 0; i < rows.length; i++) if (rows[i].id === id) return i;
6869
+ return -1;
6870
+ };
6871
+
6872
+ // Part 3 (design 2026-11-06): the active cell follows its RECORD across every re-derive.
6873
+ // refreshRowModel hands over the previous and next visible row ids (fresh locals — never the
6874
+ // React-stale $data.rows) and whether the rows changed because of a view change (sort /
6875
+ // columnFilters / globalFilter / refreshView / a holdView release) or one of the table's own
6876
+ // writes. resolveFollow (helpers/activeFollow.ts) decides:
6877
+ // • stay / clamp → the D-05 clamp below, unchanged (clamp = the row is gone after navigation
6878
+ // or a reset: paging, page size, expand/collapse, an external data swap);
6879
+ // • move → re-seat the active row at the record's new index;
6880
+ // • park → the record left the view after a view change / write: park on its column header.
6881
+ // Skipped while a header is active (nothing to follow), on an empty model (B6 owns that), and
6882
+ // before the active cell was ever engaged (it is still just the D-04 entry cell, not a record).
6883
+ // The range is cleared when such a change altered row membership or order — a rectangle
6884
+ // cannot follow rows that are no longer adjacent. Cleared AFTER the clamp so clampRange's
6885
+ // (React-stale) corner write can never resurrect it. While the view is held the ids keep
6886
+ // their order, so the range stays.
6887
+ followActiveCell = (prevIds: any, nextIds: any, viewChangeOrWrite: any, rowCount: any, colCount: any, editFollowPending = false) => {
6888
+ // An OPEN editor stays on the record it was opened for (its editing pair is index-addressed
6889
+ // over the visible rows). Done first so the active-cell move below knows the editor owns focus.
6890
+ // NOT grid-gated: the editCell / editRow verbs open editors in 'table' mode too (final review
6891
+ // #2); only the active-cell part below is grid-only.
6892
+ const editorOwnsFocus = this.followOpenEditors(prevIds, nextIds);
6893
+ if (!this.isGrid()) return;
6894
+ let follow: any = null;
6895
+ if (this.activeCellEngaged && rowCount > 0 && !this._activeIsHeader.value && !this.gridEmptyFallback) {
6896
+ follow = resolveFollow(prevIds, this._activeRow.value, nextIds, viewChangeOrWrite);
6897
+ }
6898
+ // Final review #3: a view change or a table write that EMPTIES the grid — B6 parks the
6899
+ // active cell on the header fallback; when DOM focus was on the vanished body cell, move it
6900
+ // there too (never to <body>).
6901
+ const focusEmptyPark = rowCount <= 0 && viewChangeOrWrite;
6902
+ this.clampActiveCell(rowCount, colCount, follow, editFollowPending || editorOwnsFocus, focusEmptyPark);
6903
+ if (viewChangeOrWrite && !sameIdSequence(prevIds, nextIds)) this.clearRange();
6904
+ };
6905
+
6906
+ // D-05 (now the FALLBACK of the Part 3 follow — see followActiveCell above): clamp the active
6907
+ // cell to bounds on an underlying-data change whose record could not be followed for a
6908
+ // navigation / reset reason (paging, page size, expand/collapse, an external data swap), and
6909
+ // on every change that left it in place. KEEP the same indices; clamp ONLY when the grid shrank
6910
+ // — NO bounce-to-top. Where the record CAN be found it is followed first (`follow.kind ===
6911
+ // 'move'`), and a record that left the view after a view change or a table write parks on its
6912
+ // column header (`'park'`) instead of falling through to whichever row now holds its index.
6913
+ // Gated by isGrid() so 'table' mode is entirely untouched. Invoked at the rowModelVer bump
6914
+ // path (refreshRowModel → followActiveCell).
6915
+ clampActiveCell = (rowCount: any, colCount: any, follow: any = null, editFollowPending = false, focusEmptyPark = false) => {
6515
6916
  if (!this.isGrid()) return;
6516
6917
  // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
6517
6918
  // just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
@@ -6535,6 +6936,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6535
6936
  let recoverFocus = false;
6536
6937
  let doomedRow = -1;
6537
6938
  let doomedCol = 0;
6939
+ // Part 3: is DOM focus on ANY body cell of this grid (the follow re-focuses only then)?
6940
+ let focusOnBody = false;
6538
6941
  if (this.gridRoot) {
6539
6942
  const rootNode = this.gridRoot.getRootNode ? this.gridRoot.getRootNode() : null;
6540
6943
  const focusedEl = rootNode ? rootNode.activeElement : null;
@@ -6543,6 +6946,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6543
6946
  const fRowAttr = focusedCell.getAttribute('data-row');
6544
6947
  const fColAttr = focusedCell.getAttribute('data-col-index');
6545
6948
  if (fRowAttr != null && fRowAttr !== '__header') {
6949
+ focusOnBody = true;
6546
6950
  const fr = parseInt(fRowAttr, 10);
6547
6951
  const fc = parseInt(fColAttr, 10);
6548
6952
  if (Number.isFinite(fr) && fr > rowN - 1) {
@@ -6576,6 +6980,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6576
6980
  // Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
6577
6981
  // (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
6578
6982
  // the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
6983
+ // The one exception (final review #3, `focusEmptyPark`): a view change or a table write
6984
+ // emptied the grid while DOM focus was ON a body cell (or a just-committed edit expects it
6985
+ // back) — that focus is about to drop to <body>, so move it to the header fallback cell
6986
+ // (the roving tab stop). Never steals focus that is elsewhere.
6987
+ if (focusEmptyPark && (focusOnBody || editFollowPending)) this.recoverGridFocus('__header', 0, this.headerLeafLevel());
6579
6988
  return;
6580
6989
  }
6581
6990
  // B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
@@ -6586,6 +6995,51 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6586
6995
  this._activeIsHeader.value = false;
6587
6996
  this._activeRow.value = 0;
6588
6997
  }
6998
+ // Part 3 move: the record is still in the view at a NEW index — follow it. Emit the new
6999
+ // position (consumers tracking the active cell would otherwise hold a stale index) and, when
7000
+ // DOM focus was on a body cell of this grid, re-focus at the new position (which also scrolls
7001
+ // it into view). A pending B23 edit-follow owns focus instead (refreshRowModel resolves the
7002
+ // same row by identity). Fresh locals only — no $data re-read after the writes (ROZ138).
7003
+ if (follow && follow.kind === 'move') {
7004
+ const nextRow = follow.index;
7005
+ this._activeRow.value = nextRow;
7006
+ this.clampRange(rowN - 1, colN - 1);
7007
+ this.dispatchEvent(new CustomEvent<DataTableActiveCellChangePayload>("activecell-change", {
7008
+ detail: {
7009
+ rowIndex: this.toAbsRow(nextRow),
7010
+ colIndex: col,
7011
+ isHeader: false
7012
+ },
7013
+ bubbles: true,
7014
+ composed: true
7015
+ }));
7016
+ if (focusOnBody && !editFollowPending) this.focusCellWhenReady(nextRow, col);
7017
+ return;
7018
+ }
7019
+ // Part 3 park: the record left the view after a view change or one of the table's own
7020
+ // writes. Park on the column's leaf header (keeping the column) rather than falling through
7021
+ // to whichever record now sits at the old index — a typed character then lands on a header
7022
+ // and edits nothing. NOT the B6 empty-grid fallback (no gridEmptyFallback): this park is
7023
+ // user-visible and sticky until the person navigates. When DOM focus was on the vanished
7024
+ // cell (or a just-committed edit expects focus back), move focus to that header so it never
7025
+ // drops to <body> (the B25 problem).
7026
+ if (follow && follow.kind === 'park') {
7027
+ const level = this.headerLeafLevel();
7028
+ this._activeIsHeader.value = true;
7029
+ this._activeHeaderLevel.value = level;
7030
+ this.clampRange(rowN - 1, colN - 1);
7031
+ this.dispatchEvent(new CustomEvent<DataTableActiveCellChangePayload>("activecell-change", {
7032
+ detail: {
7033
+ rowIndex: null,
7034
+ colIndex: col,
7035
+ isHeader: true
7036
+ },
7037
+ bubbles: true,
7038
+ composed: true
7039
+ }));
7040
+ if (focusOnBody || editFollowPending) this.recoverGridFocus('__header', col, level);
7041
+ return;
7042
+ }
6589
7043
  if (!this._activeIsHeader.value) {
6590
7044
  const lastRow = rowN - 1;
6591
7045
  const maxRow = lastRow < 0 ? 0 : lastRow;
@@ -6740,6 +7194,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6740
7194
  // refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
6741
7195
  gridEmptyFallback = false;
6742
7196
 
7197
+ // Part 3: has the active cell ever been placed on a grid cell (a focusin on a cell — click, Tab-in,
7198
+ // keyboard nav, focusCell)? Until then the active cell is only the D-04 ENTRY cell, not a record
7199
+ // cursor, so a mount-time re-derive (columns registering, grouping engaging) must not drag it off
7200
+ // row 0 by following whichever record happened to sit there. Plain `let` (ROZ138-safe).
7201
+ activeCellEngaged = false;
7202
+
6743
7203
  // ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
6744
7204
  // A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
6745
7205
  // (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
@@ -8100,6 +8560,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
8100
8560
  // A new edit session starts — reset the sync idempotency latch so THIS session's eventual
8101
8561
  // commit is not silently no-op'd by a PRIOR session's already-set latch.
8102
8562
  this.committedThisSession = false;
8563
+ // A pointerdown OUTSIDE the grid that happened BEFORE this editor opened (a toolbar button
8564
+ // that calls the editCell verb) is not a click-away from it. Left set, onEditorBlur would
8565
+ // consume it on the first null-relatedTarget blur of this editor — e.g. the blur Solid/Lit
8566
+ // fire when a keyed re-render MOVES the editor's row (a re-sort under the open editor) —
8567
+ // and commit-and-close the editor (final review #2, measured on solid + lit).
8568
+ this.outsidePointerDown = false;
8103
8569
  this.setInvalid('');
8104
8570
  // Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
8105
8571
  // editor clears any row-edit state so isEditing never resolves both modes for one cell.
@@ -8918,15 +9384,28 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
8918
9384
  const fromRow = this._editingRow.value;
8919
9385
  const fromCol = this._editingCol.value;
8920
9386
  const target = e.shiftKey ? this.prevEditableCell(fromRow, fromCol) : this.nextEditableCell(fromRow, fromCol);
9387
+ // Part 3 (2026-11-06 design): the advance targets a RECORD, not an index. The commit below
9388
+ // can re-sort / re-filter the rows (a write under an active sort or filter on the edited
9389
+ // column), so capture the target row's id now — from table-core's live model, which agrees
9390
+ // with the rendered rows before the write — and resolve its index again after the commit.
9391
+ const targetRowId = target ? this.rowIdInModel(target.row) : null;
8921
9392
  // skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
8922
9393
  // straight into the next editable cell's editor below. Use the RETURN value (not a
8923
9394
  // re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
8924
9395
  // failure returns false and keeps the editor open (the user must fix the value first).
8925
9396
  const committed = this.commitEdit(undefined, true);
8926
- if (committed && target) {
8927
- this._activeRow.value = target.row;
9397
+ // Where the target record is NOW: on targets whose re-derive runs synchronously inside the
9398
+ // write this is already the re-derived index; elsewhere the model has not re-derived yet,
9399
+ // the index is unchanged, and the refresh that follows moves the open editor and the active
9400
+ // cell with the record (followActiveCell). -1: the record already left the view — open
9401
+ // nothing (the refresh parked the active cell on its header).
9402
+ const advanceRow = targetRowId != null ? this.modelIndexOfRowId(targetRowId) : -1;
9403
+ if (committed && target && advanceRow >= 0) {
9404
+ this._activeRow.value = advanceRow;
8928
9405
  this._activeColIndex.value = target.col;
8929
- this.beginEdit(target.row, target.col, null);
9406
+ this.beginEdit(advanceRow, target.col, null);
9407
+ } else if (committed && target) {
9408
+ // The target record left the view: nothing to advance into.
8930
9409
  } else if (committed) {
8931
9410
  // B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
8932
9411
  // grid by returning it to the just-committed cell instead of letting it drop to <body>.