@rozie-ui/data-table-solid 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.tsx CHANGED
@@ -4,7 +4,9 @@ import { Key } from '@solid-primitives/keyed';
4
4
  import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass, rozieContext, rozieDisplay, rozieSelectValue } from '@rozie/runtime-solid';
5
5
  import Popover from '@rozie-ui/popover-solid';
6
6
  import { isSafeKey, wrapAggregationFn, indexDefsById, editorKindWarning, collectGroupableLeafDefs } from './helpers/columnDefUtils';
7
+ import { columnRuleKeys } from './helpers/columnRuleUtils';
7
8
  import { applyUpdater, clamp, focusables } from './helpers/indexMath';
9
+ import { resolveFollow, sameIdSequence } from './helpers/activeFollow';
8
10
  import { escapeTsvField, parseTsv, tileGridToBox, tileIndex } from './helpers/tsvGrid';
9
11
  import { replaceRowValue, indexOfRowIn, replaceRowValues } from './helpers/rowValueUtils';
10
12
  import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
@@ -28,6 +30,7 @@ getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as mak
28
30
  // guard so they never execute when off — the import token is the only static virtual-core
29
31
  // presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
30
32
  import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
33
+ import { createHoldController, viewStatesEqual, CLEAN_VIEW_STATE } from './helpers/heldView';
31
34
 
32
35
  // table-core instance — top-level `let` referenced from hooks → React hoists to
33
36
  // useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
@@ -102,6 +105,12 @@ export interface DataTableHistoryChangePayload {
102
105
  canUndo: boolean;
103
106
  canRedo: boolean;
104
107
  }
108
+ /** The held-view state (`holdView`): whether the view is held right now, and how many rows the held view disagrees with. */
109
+ export interface DataTableViewState {
110
+ held: boolean;
111
+ filterMismatches: number;
112
+ sortMismatches: number;
113
+ }
105
114
  /** The `range-change` payload: the new range corners (both `null` when the range cleared). */
106
115
  export type DataTableRangeChangePayload = DataTableRange;
107
116
  /** The `row-activate` payload. `row` is the original data object and `index` its position in the rendered model. */
@@ -545,7 +554,7 @@ interface DataTableProps {
545
554
  defaultData?: any[];
546
555
  onDataChange?: (data: any[]) => void;
547
556
  /**
548
- * 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.
557
+ * 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.
549
558
  */
550
559
  columns?: any[];
551
560
  /**
@@ -666,6 +675,10 @@ interface DataTableProps {
666
675
  * The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`.
667
676
  */
668
677
  undoLimit?: number;
678
+ /**
679
+ * 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()`.
680
+ */
681
+ holdView?: boolean;
669
682
  /**
670
683
  * 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`.
671
684
  */
@@ -698,6 +711,7 @@ interface DataTableProps {
698
711
  onSelectionChange?: (payload: RowSelectionState) => void;
699
712
  onSortChange?: (payload: SortingState) => void;
700
713
  onVisibilityChange?: (payload: VisibilityState) => void;
714
+ onViewStateChange?: (payload: DataTableViewState) => void;
701
715
  onVisibleRangeChange?: (payload: DataTableVisibleRangeChangePayload) => void;
702
716
  // D-131: default slot resolved via children() at body top
703
717
  children?: JSX.Element;
@@ -751,13 +765,15 @@ export interface DataTableHandle {
751
765
  canUndo: () => boolean;
752
766
  canRedo: () => boolean;
753
767
  clearHistory: () => void;
768
+ refreshView: () => void;
769
+ getViewState: () => DataTableViewState;
754
770
  }
755
771
 
756
772
  export default function DataTable(_props: DataTableProps): JSX.Element {
757
- const _merged = mergeProps({ columns: (() => [])() as any[], selectionMode: 'none', manual: false, rowCount: null, pageCount: null, expandable: false, getRowId: null, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', singleClickEdit: false, undoable: false, undoLimit: 100, virtual: false, estimateRowHeight: 40, autoMeasure: false, maxHeight: '' }, _props);
758
- const [local, attrs] = splitProps(_merged, ['data', 'columns', 'selectionMode', 'sorting', 'globalFilter', 'columnFilters', 'pagination', 'manual', 'rowCount', 'pageCount', 'expandable', 'expanded', 'getRowId', 'getSubRows', 'groupable', 'grouping', 'rowSelection', 'columnVisibility', 'columnSizing', 'columnOrder', 'columnPinning', 'stickyHeader', 'interactionMode', 'singleClickEdit', 'undoable', 'undoLimit', 'virtual', 'estimateRowHeight', 'autoMeasure', 'maxHeight', 'children', 'ref', 'onActivecellChange', 'onCellEditCommit', 'onExpandChange', 'onFilterChange', 'onGroupChange', 'onHistoryChange', 'onPageChange', 'onPinChange', 'onRangeChange', 'onReorderChange', 'onResizeChange', 'onRowActivate', 'onRowEditCommit', 'onSelectionChange', 'onSortChange', 'onVisibilityChange', 'onVisibleRangeChange', 'groupBarSlot', 'selectAllSlot', 'placeholderSlot', 'selectCellSlot', 'detailSlot', 'colHeaderSlot', 'filterSlot', 'cellSlot', 'editorSlot', 'slots']);
773
+ const _merged = mergeProps({ columns: (() => [])() as any[], selectionMode: 'none', manual: false, rowCount: null, pageCount: null, expandable: false, getRowId: null, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', singleClickEdit: false, undoable: false, undoLimit: 100, holdView: false, virtual: false, estimateRowHeight: 40, autoMeasure: false, maxHeight: '' }, _props);
774
+ const [local, attrs] = splitProps(_merged, ['data', 'columns', 'selectionMode', 'sorting', 'globalFilter', 'columnFilters', 'pagination', 'manual', 'rowCount', 'pageCount', 'expandable', 'expanded', 'getRowId', 'getSubRows', 'groupable', 'grouping', 'rowSelection', 'columnVisibility', 'columnSizing', 'columnOrder', 'columnPinning', 'stickyHeader', 'interactionMode', 'singleClickEdit', 'undoable', 'undoLimit', 'holdView', 'virtual', 'estimateRowHeight', 'autoMeasure', 'maxHeight', 'children', 'ref', 'onActivecellChange', 'onCellEditCommit', 'onExpandChange', 'onFilterChange', 'onGroupChange', 'onHistoryChange', 'onPageChange', 'onPinChange', 'onRangeChange', 'onReorderChange', 'onResizeChange', 'onRowActivate', 'onRowEditCommit', 'onSelectionChange', 'onSortChange', 'onVisibilityChange', 'onViewStateChange', 'onVisibleRangeChange', 'groupBarSlot', 'selectAllSlot', 'placeholderSlot', 'selectCellSlot', 'detailSlot', 'colHeaderSlot', 'filterSlot', 'cellSlot', 'editorSlot', 'slots']);
759
775
  const resolved = () => local.children;
760
- onMount(() => { local.ref?.({ sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, scrollToRow, getScrollElement, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut, undo, redo, canUndo, canRedo, clearHistory }); });
776
+ onMount(() => { local.ref?.({ sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, scrollToRow, getScrollElement, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut, undo, redo, canUndo, canRedo, clearHistory, refreshView, getViewState }); });
761
777
 
762
778
  const __ctx_data_table_columns = rozieContext("data-table:columns");
763
779
  const [data, setData] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'data', []);
@@ -832,7 +848,9 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
832
848
  // Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
833
849
  // changes at runtime (e.g. a server response updates the total) so getPageCount() and the
834
850
  // Next button availability track the new total.
835
- local.rowCount, local.pageCount, rowSelection(), expanded(), local.expandable, grouping(), local.groupable, columnVisibility(), columnSizing(), columnOrder(), columnPinning(), local.selectionMode, (data() || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
851
+ local.rowCount, local.pageCount, rowSelection(), expanded(), local.expandable, grouping(), local.groupable, columnVisibility(), columnSizing(), columnOrder(), columnPinning(), local.selectionMode,
852
+ // Held view: toggling holdView re-derives (reFeed → setActive).
853
+ local.holdView, (data() || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
836
854
  // even when the fresh array is the SAME length (a single-cell edit replaces one row
837
855
  // object → new array ref, identical length → the .length key alone would miss it). The
838
856
  // controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
@@ -871,6 +889,8 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
871
889
  // call. One-shot and self-latching — see seedColumnPinning() in stateAssembly.rzts.
872
890
  seedColumnPinning();
873
891
  // Build the table instance HERE so the closures below capture the live `table`.
892
+ const cols0 = tableColumns();
893
+ lastColumnSig = columnSetSignature(cols0);
874
894
  table = createTable({
875
895
  // Plain value (NOT a `get data()` getter): an object-literal getter rebinds
876
896
  // `this` to the options object, and the Angular/Lit emitters resolve $props via
@@ -881,11 +901,12 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
881
901
  // currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
882
902
  // (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
883
903
  data: feedData(),
884
- columns: tableColumns(),
904
+ columns: cols0,
885
905
  state: currentState(),
886
906
  getCoreRowModel: getCoreRowModel(),
887
- getSortedRowModel: getSortedRowModel(),
888
- getFilteredRowModel: getFilteredRowModel(),
907
+ // Held view: the wrapped factories, ALWAYS (see holdCtrl's declaration).
908
+ getSortedRowModel: heldSortedRowModel,
909
+ getFilteredRowModel: heldFilteredRowModel,
889
910
  getPaginationRowModel: getPaginationRowModel(),
890
911
  // Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
891
912
  // (mirrors the other models) — inert when `expanded` is empty + no getSubRows
@@ -970,6 +991,42 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
970
991
  // satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
971
992
  onStateChange: () => {}
972
993
  });
994
+ // Page index (final review #1). table-core's autoResetPageIndex (on by default unless
995
+ // manualPagination) queues `table.resetPageIndex()` in a microtask whenever the core,
996
+ // filtered, sorted or grouped memo recomputes — which includes every table write (new data)
997
+ // and refreshView()'s state copy — so a write on page 2 jumped back to page 1 and left the
998
+ // active cell on another record. The page reset is explicit instead: reFeed returns to page 1
999
+ // on a sort / columnFilters / globalFilter / grouping change. An external data swap keeps the
1000
+ // page (the #4 overflow clamp in refreshRowModel still pulls it back when the new data has
1001
+ // fewer pages): the swap is detected on different ticks per target (reFeed vs
1002
+ // maybeClearHistoryOnExternalSwap), so a reset there would race the auto-reset's re-write.
1003
+ // The queued auto-reset is NOT switched off (autoResetPageIndex: false): its pagination write
1004
+ // is a re-render tick that several React/Solid paths have come to rely on (measured: B23/WR-01
1005
+ // relocated-row focus, the column-virtual window, header tab stop, editor focus return all
1006
+ // regress without it — the focus polls run before the framework commits the re-sorted rows).
1007
+ // So it is kept, but it re-writes the CURRENT page (table-core's fed state — fresh on every
1008
+ // target, and already page 1 after an explicit reset) instead of turning to page 1.
1009
+ table.resetPageIndex = () => {
1010
+ if (!table) return;
1011
+ const pg = table.getState().pagination;
1012
+ writePagination({
1013
+ pageIndex: pg.pageIndex,
1014
+ pageSize: pg.pageSize
1015
+ });
1016
+ };
1017
+ // Held view: seed the sort/filter references the table was built with, so the first reFeed
1018
+ // (often the first write's own data re-feed) does not misread them as a view change and
1019
+ // release a just-captured hold.
1020
+ const st0 = currentState();
1021
+ lastHoldSorting = st0.sorting;
1022
+ lastHoldColumnFilters = st0.columnFilters;
1023
+ lastHoldGlobalFilter = st0.globalFilter;
1024
+ lastPageGrouping = st0.grouping;
1025
+ syncHoldActive(st0);
1026
+ // Seed the external-swap baseline with the mount-time dataset, so the first re-feed after an
1027
+ // early write (before any maybeClearHistoryOnExternalSwap ran) cannot misread the initial,
1028
+ // unstamped `$props.data` as a swap and release a just-captured hold.
1029
+ lastPropsData = data();
973
1030
  refreshRowModel = () => {
974
1031
  if (!table) return;
975
1032
  // Capture fresh locals; never write a $data key then re-read it in the same fn
@@ -999,15 +1056,32 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
999
1056
  // through here) — guarded internally on colsWindowed() && colVirtualizer, so this is a no-op
1000
1057
  // on every other path.
1001
1058
  remeasureColumnWindow();
1002
- // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
1003
- // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
1004
- // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
1005
- // B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
1059
+ // Part 3 + D-05: on every data change (re-sort/filter/paginate/page-size/writes — all
1060
+ // re-pull here) the active cell FOLLOWS its record by row id; where it cannot (paging,
1061
+ // expand/collapse, an external swap) it falls back to the D-05 clamp (same indices, clamped
1062
+ // if the grid shrank), and a record that left the view after a view change or a table write
1063
+ // parks on its column header (followActiveCell, gridKeydownHandlers.rzts). isGrid()-gated so
1064
+ // 'table' mode is untouched.
1065
+ // B8/B23: pass the FRESH bounds and ids derived from `nextRows` (NOT $data.rows, which is the
1006
1066
  // async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
1007
- // the range corners on React too — never re-reading the pre-change model.
1067
+ // the range corners on React too — never re-reading the pre-change model. The follow flags
1068
+ // are plain `let`s, read and cleared here (ROZ138-safe).
1008
1069
  const nextRowCount = nextRows.length;
1009
1070
  const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
1010
- clampActiveCell(nextRowCount, nextColCount);
1071
+ const nextIds = nextRows.map((r: any) => r.id);
1072
+ const fedData = table.options.data;
1073
+ const dataChanged = fedData !== lastFedData;
1074
+ lastFedData = fedData;
1075
+ const viewChangeOrWrite = pendingViewChange || pendingWrite && dataChanged;
1076
+ // Final review #8: new data that is NOT one of our writes (an external swap) without
1077
+ // `getRowId` — the row ids are source indices, meaningless across the swap, so following
1078
+ // them could land the active cell (or an open editor) on an unrelated record. Don't follow:
1079
+ // the D-05 clamp keeps today's position. Reads table.options (fresh on every target).
1080
+ const swapWithoutIds = dataChanged && !pendingWrite && !pendingViewChange && table.options.getRowId == null;
1081
+ pendingViewChange = false;
1082
+ if (dataChanged) pendingWrite = false;
1083
+ followActiveCell(swapWithoutIds ? null : prevRowIds, nextIds, viewChangeOrWrite, nextRowCount, nextColCount, pendingEditFollow != null);
1084
+ prevRowIds = nextIds;
1011
1085
  // #4: clamp a pageIndex that now points PAST the last page. When the consumer holds
1012
1086
  // pagination.pageIndex (controlled) and shrinks the data (filter / replace) so there are
1013
1087
  // fewer pages, the body renders blank ("Page 6 of 3" with Next disabled). Read table-core's
@@ -1021,11 +1095,11 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1021
1095
  // the clamp back through the pagination prop, the re-feed re-enters here with
1022
1096
  // pageIndex === pc - 1, so `pageIndex > pc - 1` is false → no re-emit; a consumer that
1023
1097
  // ignores the event triggers no further re-feed, so it stays a single emit either way.
1024
- // • No fight with table-core's autoResetPageIndex: that reset only fires on table-core's OWN
1025
- // setX mutations, which this fully-controlled-state architecture never calls (filters/data
1026
- // flow through setOptions), so reading the live state here can only fire on a genuine
1027
- // overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
1028
- // writing paginationDefault, with no regression since table-core does not auto-clamp here).
1098
+ // • table-core's auto page reset no longer turns the page (table.resetPageIndex is
1099
+ // overridden after createTable): the sort/filter/grouping page reset is explicit (reFeed),
1100
+ // and a table write, refreshView() or an external swap never turns the page. This
1101
+ // clamp therefore only fires on a genuine overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
1102
+ // writing paginationDefault).
1029
1103
  const pgState = table.getState().pagination;
1030
1104
  const pc = table.getPageCount();
1031
1105
  if (pc > 0 && pgState.pageIndex > pc - 1) {
@@ -1051,6 +1125,13 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1051
1125
  // across selection changes; a microtask defer covers React's post-render DOM patch.
1052
1126
  syncIndeterminate();
1053
1127
  if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate);else Promise.resolve().then(syncIndeterminate);
1128
+ // Held view: edge-triggered view-state-change (only when held/filterMismatches/
1129
+ // sortMismatches changed). lastViewState is a plain `let` (ROZ138-safe).
1130
+ const vs = holdCtrl.viewState(table);
1131
+ if (!viewStatesEqual(vs, lastViewState)) {
1132
+ lastViewState = vs;
1133
+ _props.onViewStateChange?.(vs);
1134
+ }
1054
1135
  };
1055
1136
 
1056
1137
  // initial pull
@@ -1378,6 +1459,91 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1378
1459
  let redoStack: unknown[] = [];
1379
1460
  let restoringHistory: boolean = false;
1380
1461
 
1462
+ // ── Held view (`holdView`) ─────────────────────────────────────────────────────────────
1463
+ // ONE hold controller per instance (helpers/heldView.ts) and the two wrapped row-model
1464
+ // factories built ONCE from it, so createTable and every reFeed setOptions pass the SAME
1465
+ // function references (a fresh wrapper per re-feed would rebuild table-core's memoized
1466
+ // stage). Both wrapped factories are passed ALWAYS — not only while holding — because the
1467
+ // sort-mismatch count is only correct when wrapSorted observes the live sort. While the
1468
+ // controller is not holding, the wrappers return the base model unchanged (same identity),
1469
+ // so a table without `holdView` is behaviour-identical. Top-level `const`s: React lowers each
1470
+ // to a `useMemo(..., [])` (one instance per component). NOT `let`: React hoists a `let` to
1471
+ // `useRef(init)` and does not rewrite a sibling hoisted `let` read inside that initializer to
1472
+ // `.current`, so `let heldX = holdCtrl.wrapX(...)` would call `wrapX` on the ref object.
1473
+ const holdCtrl = createHoldController();
1474
+ const heldFilteredRowModel = holdCtrl.wrapFiltered(getFilteredRowModel());
1475
+ const heldSortedRowModel = holdCtrl.wrapSorted(getSortedRowModel());
1476
+ // Set when the view was re-derived (a sort/filter change, refreshView()) and by writeData on
1477
+ // every table write; consumed by the active-cell follow (Task 5). Plain `let`s, NOT $data —
1478
+ // written and read inside refreshRowModel's call graph (ROZ138 / React stale reads).
1479
+ let pendingViewChange = false;
1480
+ let pendingWrite = false;
1481
+ // Part 3 (active cell follows its record): the visible row ids at the end of the last
1482
+ // refreshRowModel, and the data array that refresh fed. pendingWrite is consumed only by the
1483
+ // refresh whose fed data CHANGED — in controlled `r-model:data` mode writeData's own
1484
+ // $data.dataDefault write re-feeds the OLD `$props.data` first, and the parent's echo arrives
1485
+ // a pass later; consuming the flag on that first, unchanged pass would turn the write's
1486
+ // vanished row into a clamp instead of a park.
1487
+ let prevRowIds: string[] | null = null;
1488
+ let lastFedData: unknown = null;
1489
+ // The last emitted view-state-change payload (refreshRowModel edge-triggers the emit).
1490
+ let lastViewState = CLEAN_VIEW_STATE;
1491
+ // reFeed bookkeeping: the sort/filter references fed last time (a change releases the hold)
1492
+ // and the one-shot fallback warning latch.
1493
+ let lastHoldSorting: unknown = null;
1494
+ let lastHoldColumnFilters: unknown = null;
1495
+ let lastHoldGlobalFilter: unknown = null;
1496
+ let holdFallbackWarned = false;
1497
+ // The grouping reference fed last time (a change returns to page 1 — see table.resetPageIndex).
1498
+ let lastPageGrouping: unknown = null;
1499
+ // The column-set signature fed last time (see columnSetSignature / reFeed).
1500
+ let lastColumnSig = '';
1501
+ // A signature of the column set as table-core's sort / filter stages see it: each column's id
1502
+ // and whether it can sort / filter (groups flattened, iteratively — no self-recursive const,
1503
+ // which React lowers to a useCallback). table-core memoizes those stages on the sorting /
1504
+ // columnFilters references and the upstream model only, NOT on the column defs; a stage that
1505
+ // first ran before a sorted/filtered column existed (a <Column> child registering after the
1506
+ // first pull, the norm on React/Solid/Lit) silently skipped that sort/filter and stays cached.
1507
+ function columnSetSignature(defs: any) {
1508
+ let sig = '';
1509
+ const stack = (defs || []).slice().reverse();
1510
+ while (stack.length) {
1511
+ const d = stack.pop();
1512
+ if (!d) continue;
1513
+ sig += String(d.id) + (d.enableSorting ? '+' : '') + (d.enableColumnFilter ? '*' : '') + '|';
1514
+ if (Array.isArray(d.columns)) for (let i = d.columns.length - 1; i >= 0; i--) stack.push(d.columns[i]);
1515
+ }
1516
+ return sig;
1517
+ }
1518
+ // The last `$props.data` reference maybeClearHistoryOnExternalSwap examined (seeded in
1519
+ // $onMount). Declared up here, before reFeed, so reFeed can call isExternalSwap() without a
1520
+ // forward reference (React useCallback ordering).
1521
+ let lastPropsData: unknown = null;
1522
+ // Read-only: has `$props.data` changed to a reference we did NOT write since the last
1523
+ // maybeClearHistoryOnExternalSwap? (reFeed uses it to release a held view before its refresh.)
1524
+ function isExternalSwap() {
1525
+ const pd = data();
1526
+ if (pd === lastPropsData) return false;
1527
+ return !(pd != null && (pd as any)[DATA_WRITE_TOKEN_KEY] != null);
1528
+ }
1529
+ // Whether the hold applies for state `st`: holdView on, and not one of the live-behaviour
1530
+ // fallbacks (grouping active, `manual`, tree data). Called from $onMount (before the first
1531
+ // pull) and from every reFeed; warns once when holdView is on but cannot hold.
1532
+ function syncHoldActive(st: any) {
1533
+ const wanted = local.holdView === true;
1534
+ const active = wanted && local.manual !== true && local.getSubRows == null && !(st.grouping || []).length;
1535
+ // A setActive that RELEASES a hold (holdView on→off, or a fallback flipping the controller
1536
+ // inactive while it was holding) re-derives the live view: for the active-cell follow that
1537
+ // is a view change (a record the live view drops parks on its header).
1538
+ const wasHolding = holdCtrl.isHolding();
1539
+ holdCtrl.setActive(active);
1540
+ if (wasHolding && !holdCtrl.isHolding()) pendingViewChange = true;
1541
+ if (wanted && !active && !holdFallbackWarned) {
1542
+ holdFallbackWarned = true;
1543
+ 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.');
1544
+ }
1545
+ }
1546
+
1381
1547
  // Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
1382
1548
  // has not bound `expanded` and has not yet toggled any group, group-header rows default to
1383
1549
  // EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
@@ -1671,6 +1837,8 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1671
1837
  // a custom fn defensively wrapped (T-50-04).
1672
1838
  groupable: c.groupable !== false,
1673
1839
  aggregationFn: wrapAggregationFn(c.aggregationFn),
1840
+ // Per-column filterFn/sortingFn (built-in name or custom fn); absent when unset.
1841
+ ...columnRuleKeys(c, id),
1674
1842
  pinned: c.pinned != null ? c.pinned : '',
1675
1843
  width: c.width != null ? c.width : '',
1676
1844
  // A-01 — `width` alone is inert; table-core sizes from `size`. Spread so an
@@ -1761,6 +1929,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1761
1929
  // Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
1762
1930
  groupable: spec.groupable !== false,
1763
1931
  aggregationFn: wrapAggregationFn(spec.aggregationFn),
1932
+ ...columnRuleKeys(spec, id),
1764
1933
  pinned: spec.pinned != null ? spec.pinned : '',
1765
1934
  width: spec.width != null ? spec.width : '',
1766
1935
  // A-01 — see buildConfigDef; same mapping for the <Column> registry branch.
@@ -2074,6 +2243,14 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
2074
2243
  recordSnapshot(currentData());
2075
2244
  emitHistoryChangeIfEdged(prevU, prevR);
2076
2245
  }
2246
+ // Held view (`holdView`): freeze the view as it is BEFORE this write (a no-op when the
2247
+ // controller is inactive or already holding), then note which source rows this write
2248
+ // replaced (the sort-mismatch count). Outside the `!restoringHistory` branch on purpose:
2249
+ // undo/redo replays are table writes too. pendingWrite is consumed by the active-cell
2250
+ // follow.
2251
+ if (table) holdCtrl.capture(table);
2252
+ holdCtrl.noteWrite(currentData() || [], Array.isArray(next) ? next : []);
2253
+ pendingWrite = true;
2077
2254
  const fresh = Array.isArray(next) ? next.slice() : next;
2078
2255
  try {
2079
2256
  Object.defineProperty(fresh, DATA_WRITE_TOKEN_KEY, {
@@ -3602,6 +3779,18 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
3602
3779
  if (local.selectionMode === 'none') return false;
3603
3780
  return lazyRowsActive() ? (row: any) => !rowIsLazyPlaceholder(row) : true;
3604
3781
  }
3782
+ // 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
3783
+ // synchronously INSIDE another slice's funnel (a header filter input on the fine-grained
3784
+ // targets) would have it silently dropped, so defer it to a microtask then.
3785
+ function writePaginationSoon(next: any) {
3786
+ if (!programmatic) {
3787
+ writePagination(next);
3788
+ return;
3789
+ }
3790
+ const run = () => writePagination(next);
3791
+ if (typeof queueMicrotask !== 'undefined') queueMicrotask(run);else Promise.resolve().then(run);
3792
+ }
3793
+
3605
3794
  // Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
3606
3795
  // re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
3607
3796
  function reFeed() {
@@ -3612,11 +3801,78 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
3612
3801
  // TRANSIENTLY-STALE `$props.data` and wrongly wipe a just-recorded edit's history. The reset is
3613
3802
  // keyed on the `$props.data` REFERENCE actually changing instead — see the $onUpdate backstop
3614
3803
  // below (`maybeClearHistoryOnExternalSwap`), which runs on all six targets.
3804
+ //
3805
+ // Held view: decide whether the hold applies (it falls back to live behaviour under
3806
+ // grouping, `manual`, and tree data), and re-derive on a sort/filter change. Fresh locals
3807
+ // only — the state object is read once here and passed to setOptions below.
3808
+ const st0 = currentState();
3809
+ syncHoldActive(st0);
3810
+ let st = st0;
3811
+ // Page reset (table-core's auto reset no longer turns the page — see table.resetPageIndex): a sort / filter / grouping change
3812
+ // returns to page 1. The first page is fed to table-core in THIS re-feed (a fresh state
3813
+ // object), so the re-derive — and the active-cell follow, which resolves against the current
3814
+ // page — already sees page 1; writePagination (after the refresh, below) carries it to the
3815
+ // consumer's pagination model. Skipped when already on page 1 (no spurious page-change) and
3816
+ // under `manual` (table-core never auto-reset there either).
3817
+ let pageReset: {
3818
+ pageIndex: number;
3819
+ pageSize: number;
3820
+ } | null = null;
3821
+ const groupingChanged = st0.grouping !== lastPageGrouping;
3822
+ lastPageGrouping = st0.grouping;
3823
+ const viewChanged = st0.sorting !== lastHoldSorting || st0.columnFilters !== lastHoldColumnFilters || st0.globalFilter !== lastHoldGlobalFilter;
3824
+ if (viewChanged) {
3825
+ lastHoldSorting = st0.sorting;
3826
+ lastHoldColumnFilters = st0.columnFilters;
3827
+ lastHoldGlobalFilter = st0.globalFilter;
3828
+ holdCtrl.release();
3829
+ pendingViewChange = true;
3830
+ }
3831
+ if ((viewChanged || groupingChanged) && local.manual !== true && st0.pagination && st0.pagination.pageIndex !== 0) {
3832
+ pageReset = {
3833
+ pageIndex: 0,
3834
+ pageSize: st0.pagination.pageSize
3835
+ };
3836
+ st = {
3837
+ ...st0,
3838
+ pagination: pageReset
3839
+ };
3840
+ }
3841
+ // The column set changed (a <Column> registered, a `columns` entry was added, a column became
3842
+ // sortable/filterable): table-core's sort and filter stages do not key on column defs, so a
3843
+ // sort or filter that referenced a column which did not exist yet stays skipped (an initial
3844
+ // `sorting` was ignored on React/Solid/Lit). Feed shallow copies of both arrays — the same
3845
+ // memo invalidation refreshView() uses — so they re-run against the new columns. Not a view
3846
+ // change: the hold (if any) is kept and the page is not reset.
3847
+ const cols = tableColumns();
3848
+ const colSig = columnSetSignature(cols);
3849
+ if (colSig !== lastColumnSig) {
3850
+ lastColumnSig = colSig;
3851
+ st = {
3852
+ ...st,
3853
+ sorting: [...(st.sorting || [])],
3854
+ columnFilters: [...(st.columnFilters || [])]
3855
+ };
3856
+ }
3857
+ // An external swap releases the hold BEFORE this re-feed's refresh, so the one
3858
+ // view-state-change it emits is already the re-derived state (never a stale held count
3859
+ // measured against the new dataset). Only the hold is touched here — the history clear and
3860
+ // the lastPropsData bookkeeping stay in maybeClearHistoryOnExternalSwap (keyed on the
3861
+ // `$props.data` reference; see its comment). isExternalSwap() is read-only and compares
3862
+ // against that same lastPropsData, so a transiently-stale `$props.data` (the reference
3863
+ // already recorded) never reads as a swap.
3864
+ if (holdCtrl.isHolding() && isExternalSwap()) holdCtrl.release();
3865
+ // An external swap is a reset, not one of our writes: drop a write flag the echo never
3866
+ // consumed (one-way `data`, no r-model), so the swap keeps the D-05 clamp fallback.
3867
+ if (pendingWrite && isExternalSwap()) pendingWrite = false;
3615
3868
  table.setOptions((prev: any) => ({
3616
3869
  ...prev,
3617
3870
  data: feedData(),
3618
- columns: tableColumns(),
3619
- state: currentState(),
3871
+ columns: cols,
3872
+ state: st,
3873
+ // Held view: the SAME wrapped factory references every re-feed (see holdCtrl).
3874
+ getFilteredRowModel: heldFilteredRowModel,
3875
+ getSortedRowModel: heldSortedRowModel,
3620
3876
  enableRowSelection: rowSelectionOption(),
3621
3877
  enableMultiRowSelection: local.selectionMode === 'multiple',
3622
3878
  // Re-pass the server-side page-count sources (#2) so a RUNTIME rowCount/pageCount change
@@ -3670,6 +3926,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
3670
3926
  onColumnSizingInfoChange: onColumnSizingInfoChangeCb
3671
3927
  }));
3672
3928
  if (refreshRowModel) refreshRowModel();
3929
+ if (pageReset) writePaginationSoon(pageReset);
3673
3930
  }
3674
3931
 
3675
3932
  // LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
@@ -3694,14 +3951,24 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
3694
3951
  // non-data tick (sort/filter/pagination) never touches `$props.data` → never clears. Called from
3695
3952
  // BOTH the coarse re-feed watch AND the $onUpdate backstop (Lit's @property `data` the effect-
3696
3953
  // tracked watch can't observe); both are ref-gated so the redundant call is an idempotent no-op.
3697
- let lastPropsData: unknown = null;
3698
3954
  function maybeClearHistoryOnExternalSwap() {
3699
3955
  const pd = data();
3700
3956
  if (pd === lastPropsData) return; // $props.data did not change → not an external swap
3701
3957
  lastPropsData = pd;
3702
- if (!local.undoable) return;
3703
3958
  if (pd != null && (pd as any)[DATA_WRITE_TOKEN_KEY] != null) return; // descends from our write → keep
3704
- clearHistory();
3959
+ // An external swap is a reset, not one of our writes: drop a write flag no echo consumed
3960
+ // (one-way `data`). Here AND in reFeed, so it holds whichever of the two runs first on a
3961
+ // target (Lit's $onUpdate and React's/Solid's effect order run this one before reFeed, after
3962
+ // which reFeed's isExternalSwap() already reads false).
3963
+ pendingWrite = false;
3964
+ // A genuine external swap (runs regardless of `undoable`): it re-derives a held view (a
3965
+ // backstop — reFeed normally released it already, before its own refresh) …
3966
+ if (holdCtrl.isHolding()) {
3967
+ holdCtrl.release();
3968
+ if (refreshRowModel) refreshRowModel();
3969
+ }
3970
+ // … and clears undo history (only meaningful when `undoable`).
3971
+ if (local.undoable) clearHistory();
3705
3972
  }
3706
3973
  let lastData: any = null;
3707
3974
  let lastDataLen = -1;
@@ -4450,6 +4717,32 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4450
4717
  // DataTable.rozie) — so DataTable.rozie's $expose references them BY NAME with zero
4451
4718
  // indirection. This file stays the seam for verbs that need a rename/adapter, not a mandatory
4452
4719
  // stop for every $expose entry.
4720
+ // Held view (`holdView`). refreshView() re-derives the view from the current filters and
4721
+ // sort and resets both mismatch counts. It works with holdView off too: it forces custom
4722
+ // filter/sort functions to re-run. table-core's filter and sort stages memoize on the
4723
+ // columnFilters/sorting array REFERENCES (not on the column defs or the functions), and
4724
+ // release() alone touches neither — so feed table-core SHALLOW COPIES of both arrays, which
4725
+ // invalidates those memos. The next reFeed restores the original references (one harmless
4726
+ // extra recompute). pendingViewChange is the component-scope `let` the active-cell follow
4727
+ // consumes.
4728
+ function refreshView() {
4729
+ holdCtrl.release();
4730
+ pendingViewChange = true;
4731
+ if (!table) return;
4732
+ table.setOptions((p: any) => ({
4733
+ ...p,
4734
+ state: {
4735
+ ...p.state,
4736
+ columnFilters: [...(p.state.columnFilters || [])],
4737
+ sorting: [...(p.state.sorting || [])]
4738
+ }
4739
+ }));
4740
+ if (refreshRowModel) refreshRowModel();
4741
+ }
4742
+ function getViewState() {
4743
+ return holdCtrl.viewState(table);
4744
+ }
4745
+
4453
4746
  // interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
4454
4747
  // identical to phase 48 (roles fall back to the literals, tabindex drops).
4455
4748
  function isGrid() {
@@ -5492,6 +5785,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5492
5785
  if (!tgt || !tgt.closest) return;
5493
5786
  const cellEl = tgt.closest('[data-grid-cell]');
5494
5787
  if (!cellEl) return;
5788
+ activeCellEngaged = true;
5495
5789
  const rowAttr = cellEl.getAttribute('data-row');
5496
5790
  const colAttr = cellEl.getAttribute('data-col-index');
5497
5791
  if (rowAttr == null || colAttr == null) return;
@@ -5788,11 +6082,100 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5788
6082
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
5789
6083
  }
5790
6084
 
5791
- // D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
5792
- // pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
5793
- // row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
5794
- // mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
5795
- function clampActiveCell(rowCount: any, colCount: any) {
6085
+ // followOpenEditors: when a re-derive changed the rows under an open single-cell editor or
6086
+ // full-row editor, re-address it to its record's new index (re-seating editor focus there),
6087
+ // or — when the record left the view — close it WITHOUT writing (its draft belonged to a record
6088
+ // that is no longer on screen; the active cell, on the same record, parks). Returns true when an
6089
+ // editor is still open on a moved record (it, not the cell, owns DOM focus).
6090
+ function followOpenEditors(prevIds: any, nextIds: any) {
6091
+ if (!prevIds || sameIdSequence(prevIds, nextIds)) return false;
6092
+ let owns = false;
6093
+ const er = editingRow();
6094
+ if (er >= 0) {
6095
+ const ef = resolveFollow(prevIds, er, nextIds, true);
6096
+ if (ef.kind === 'move') {
6097
+ setEditingRow(ef.index);
6098
+ focusEditorWhenReady(ef.index, editingCol(), false);
6099
+ owns = true;
6100
+ } else if (ef.kind !== 'stay') {
6101
+ // Latch the session (an unmount blur must not commit the stale draft) and tear down.
6102
+ committedThisSession = true;
6103
+ editTransition = true;
6104
+ endEdit();
6105
+ editTransition = false;
6106
+ }
6107
+ }
6108
+ const rr = editingRowIndex();
6109
+ if (rr != null && rr >= 0) {
6110
+ const rf = resolveFollow(prevIds, rr, nextIds, true);
6111
+ if (rf.kind === 'move') {
6112
+ setEditingRowIndex(rf.index);
6113
+ owns = true;
6114
+ } else if (rf.kind !== 'stay') {
6115
+ editTransition = true;
6116
+ endRowEdit();
6117
+ editTransition = false;
6118
+ }
6119
+ }
6120
+ return owns;
6121
+ }
6122
+
6123
+ // The row id at a visible index / the visible index of a row id, read off table-core's LIVE
6124
+ // model (windowSource — the same index space as $data.rows), never the React-stale $data.rows.
6125
+ function rowIdInModel(index: any) {
6126
+ const row = windowSource()[index];
6127
+ return row ? row.id : null;
6128
+ }
6129
+ function modelIndexOfRowId(id: any) {
6130
+ const rows = windowSource();
6131
+ for (let i = 0; i < rows.length; i++) if (rows[i].id === id) return i;
6132
+ return -1;
6133
+ }
6134
+
6135
+ // Part 3 (design 2026-11-06): the active cell follows its RECORD across every re-derive.
6136
+ // refreshRowModel hands over the previous and next visible row ids (fresh locals — never the
6137
+ // React-stale $data.rows) and whether the rows changed because of a view change (sort /
6138
+ // columnFilters / globalFilter / refreshView / a holdView release) or one of the table's own
6139
+ // writes. resolveFollow (helpers/activeFollow.ts) decides:
6140
+ // • stay / clamp → the D-05 clamp below, unchanged (clamp = the row is gone after navigation
6141
+ // or a reset: paging, page size, expand/collapse, an external data swap);
6142
+ // • move → re-seat the active row at the record's new index;
6143
+ // • park → the record left the view after a view change / write: park on its column header.
6144
+ // Skipped while a header is active (nothing to follow), on an empty model (B6 owns that), and
6145
+ // before the active cell was ever engaged (it is still just the D-04 entry cell, not a record).
6146
+ // The range is cleared when such a change altered row membership or order — a rectangle
6147
+ // cannot follow rows that are no longer adjacent. Cleared AFTER the clamp so clampRange's
6148
+ // (React-stale) corner write can never resurrect it. While the view is held the ids keep
6149
+ // their order, so the range stays.
6150
+ function followActiveCell(prevIds: any, nextIds: any, viewChangeOrWrite: any, rowCount: any, colCount: any, editFollowPending = false) {
6151
+ // An OPEN editor stays on the record it was opened for (its editing pair is index-addressed
6152
+ // over the visible rows). Done first so the active-cell move below knows the editor owns focus.
6153
+ // NOT grid-gated: the editCell / editRow verbs open editors in 'table' mode too (final review
6154
+ // #2); only the active-cell part below is grid-only.
6155
+ const editorOwnsFocus = followOpenEditors(prevIds, nextIds);
6156
+ if (!isGrid()) return;
6157
+ let follow: any = null;
6158
+ if (activeCellEngaged && rowCount > 0 && !activeIsHeader() && !gridEmptyFallback) {
6159
+ follow = resolveFollow(prevIds, activeRow(), nextIds, viewChangeOrWrite);
6160
+ }
6161
+ // Final review #3: a view change or a table write that EMPTIES the grid — B6 parks the
6162
+ // active cell on the header fallback; when DOM focus was on the vanished body cell, move it
6163
+ // there too (never to <body>).
6164
+ const focusEmptyPark = rowCount <= 0 && viewChangeOrWrite;
6165
+ clampActiveCell(rowCount, colCount, follow, editFollowPending || editorOwnsFocus, focusEmptyPark);
6166
+ if (viewChangeOrWrite && !sameIdSequence(prevIds, nextIds)) clearRange();
6167
+ }
6168
+
6169
+ // D-05 (now the FALLBACK of the Part 3 follow — see followActiveCell above): clamp the active
6170
+ // cell to bounds on an underlying-data change whose record could not be followed for a
6171
+ // navigation / reset reason (paging, page size, expand/collapse, an external data swap), and
6172
+ // on every change that left it in place. KEEP the same indices; clamp ONLY when the grid shrank
6173
+ // — NO bounce-to-top. Where the record CAN be found it is followed first (`follow.kind ===
6174
+ // 'move'`), and a record that left the view after a view change or a table write parks on its
6175
+ // column header (`'park'`) instead of falling through to whichever row now holds its index.
6176
+ // Gated by isGrid() so 'table' mode is entirely untouched. Invoked at the rowModelVer bump
6177
+ // path (refreshRowModel → followActiveCell).
6178
+ function clampActiveCell(rowCount: any, colCount: any, follow: any = null, editFollowPending = false, focusEmptyPark = false) {
5796
6179
  if (!isGrid()) return;
5797
6180
  // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
5798
6181
  // just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
@@ -5816,6 +6199,8 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5816
6199
  let recoverFocus = false;
5817
6200
  let doomedRow = -1;
5818
6201
  let doomedCol = 0;
6202
+ // Part 3: is DOM focus on ANY body cell of this grid (the follow re-focuses only then)?
6203
+ let focusOnBody = false;
5819
6204
  if (gridRoot) {
5820
6205
  const rootNode = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
5821
6206
  const focusedEl = rootNode ? rootNode.activeElement : null;
@@ -5824,6 +6209,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5824
6209
  const fRowAttr = focusedCell.getAttribute('data-row');
5825
6210
  const fColAttr = focusedCell.getAttribute('data-col-index');
5826
6211
  if (fRowAttr != null && fRowAttr !== '__header') {
6212
+ focusOnBody = true;
5827
6213
  const fr = parseInt(fRowAttr, 10);
5828
6214
  const fc = parseInt(fColAttr, 10);
5829
6215
  if (Number.isFinite(fr) && fr > rowN - 1) {
@@ -5857,6 +6243,11 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5857
6243
  // Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
5858
6244
  // (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
5859
6245
  // the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
6246
+ // The one exception (final review #3, `focusEmptyPark`): a view change or a table write
6247
+ // emptied the grid while DOM focus was ON a body cell (or a just-committed edit expects it
6248
+ // back) — that focus is about to drop to <body>, so move it to the header fallback cell
6249
+ // (the roving tab stop). Never steals focus that is elsewhere.
6250
+ if (focusEmptyPark && (focusOnBody || editFollowPending)) recoverGridFocus('__header', 0, headerLeafLevel());
5860
6251
  return;
5861
6252
  }
5862
6253
  // B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
@@ -5867,6 +6258,43 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5867
6258
  setActiveIsHeader(false);
5868
6259
  setActiveRow(0);
5869
6260
  }
6261
+ // Part 3 move: the record is still in the view at a NEW index — follow it. Emit the new
6262
+ // position (consumers tracking the active cell would otherwise hold a stale index) and, when
6263
+ // DOM focus was on a body cell of this grid, re-focus at the new position (which also scrolls
6264
+ // it into view). A pending B23 edit-follow owns focus instead (refreshRowModel resolves the
6265
+ // same row by identity). Fresh locals only — no $data re-read after the writes (ROZ138).
6266
+ if (follow && follow.kind === 'move') {
6267
+ const nextRow = follow.index;
6268
+ setActiveRow(nextRow);
6269
+ clampRange(rowN - 1, colN - 1);
6270
+ _props.onActivecellChange?.({
6271
+ rowIndex: toAbsRow(nextRow),
6272
+ colIndex: col,
6273
+ isHeader: false
6274
+ });
6275
+ if (focusOnBody && !editFollowPending) focusCellWhenReady(nextRow, col);
6276
+ return;
6277
+ }
6278
+ // Part 3 park: the record left the view after a view change or one of the table's own
6279
+ // writes. Park on the column's leaf header (keeping the column) rather than falling through
6280
+ // to whichever record now sits at the old index — a typed character then lands on a header
6281
+ // and edits nothing. NOT the B6 empty-grid fallback (no gridEmptyFallback): this park is
6282
+ // user-visible and sticky until the person navigates. When DOM focus was on the vanished
6283
+ // cell (or a just-committed edit expects focus back), move focus to that header so it never
6284
+ // drops to <body> (the B25 problem).
6285
+ if (follow && follow.kind === 'park') {
6286
+ const level = headerLeafLevel();
6287
+ setActiveIsHeader(true);
6288
+ setActiveHeaderLevel(level);
6289
+ clampRange(rowN - 1, colN - 1);
6290
+ _props.onActivecellChange?.({
6291
+ rowIndex: null,
6292
+ colIndex: col,
6293
+ isHeader: true
6294
+ });
6295
+ if (focusOnBody || editFollowPending) recoverGridFocus('__header', col, level);
6296
+ return;
6297
+ }
5870
6298
  if (!activeIsHeader()) {
5871
6299
  const lastRow = rowN - 1;
5872
6300
  const maxRow = lastRow < 0 ? 0 : lastRow;
@@ -6019,6 +6447,11 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
6019
6447
  // already read reactive $data synchronously). A top-level reassigned `let` referenced from the
6020
6448
  // refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
6021
6449
  let gridEmptyFallback = false;
6450
+ // Part 3: has the active cell ever been placed on a grid cell (a focusin on a cell — click, Tab-in,
6451
+ // keyboard nav, focusCell)? Until then the active cell is only the D-04 ENTRY cell, not a record
6452
+ // cursor, so a mount-time re-derive (columns registering, grouping engaging) must not drag it off
6453
+ // row 0 by following whichever record happened to sit there. Plain `let` (ROZ138-safe).
6454
+ let activeCellEngaged = false;
6022
6455
 
6023
6456
  // ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
6024
6457
  // A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
@@ -7355,6 +7788,12 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
7355
7788
  // A new edit session starts — reset the sync idempotency latch so THIS session's eventual
7356
7789
  // commit is not silently no-op'd by a PRIOR session's already-set latch.
7357
7790
  committedThisSession = false;
7791
+ // A pointerdown OUTSIDE the grid that happened BEFORE this editor opened (a toolbar button
7792
+ // that calls the editCell verb) is not a click-away from it. Left set, onEditorBlur would
7793
+ // consume it on the first null-relatedTarget blur of this editor — e.g. the blur Solid/Lit
7794
+ // fire when a keyed re-render MOVES the editor's row (a re-sort under the open editor) —
7795
+ // and commit-and-close the editor (final review #2, measured on solid + lit).
7796
+ outsidePointerDown = false;
7358
7797
  setInvalid('');
7359
7798
  // Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
7360
7799
  // editor clears any row-edit state so isEditing never resolves both modes for one cell.
@@ -8166,15 +8605,28 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
8166
8605
  const fromRow = editingRow();
8167
8606
  const fromCol = editingCol();
8168
8607
  const target = e.shiftKey ? prevEditableCell(fromRow, fromCol) : nextEditableCell(fromRow, fromCol);
8608
+ // Part 3 (2026-11-06 design): the advance targets a RECORD, not an index. The commit below
8609
+ // can re-sort / re-filter the rows (a write under an active sort or filter on the edited
8610
+ // column), so capture the target row's id now — from table-core's live model, which agrees
8611
+ // with the rendered rows before the write — and resolve its index again after the commit.
8612
+ const targetRowId = target ? rowIdInModel(target.row) : null;
8169
8613
  // skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
8170
8614
  // straight into the next editable cell's editor below. Use the RETURN value (not a
8171
8615
  // re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
8172
8616
  // failure returns false and keeps the editor open (the user must fix the value first).
8173
8617
  const committed = commitEdit(undefined, true);
8174
- if (committed && target) {
8175
- setActiveRow(target.row);
8618
+ // Where the target record is NOW: on targets whose re-derive runs synchronously inside the
8619
+ // write this is already the re-derived index; elsewhere the model has not re-derived yet,
8620
+ // the index is unchanged, and the refresh that follows moves the open editor and the active
8621
+ // cell with the record (followActiveCell). -1: the record already left the view — open
8622
+ // nothing (the refresh parked the active cell on its header).
8623
+ const advanceRow = targetRowId != null ? modelIndexOfRowId(targetRowId) : -1;
8624
+ if (committed && target && advanceRow >= 0) {
8625
+ setActiveRow(advanceRow);
8176
8626
  setActiveColIndex(target.col);
8177
- beginEdit(target.row, target.col, null);
8627
+ beginEdit(advanceRow, target.col, null);
8628
+ } else if (committed && target) {
8629
+ // The target record left the view: nothing to advance into.
8178
8630
  } else if (committed) {
8179
8631
  // B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
8180
8632
  // grid by returning it to the just-committed cell instead of letting it drop to <body>.