@rozie-ui/data-table-solid 0.7.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +5 -1
- package/dist/index.cjs +501 -16
- package/dist/index.d.cts +23 -2
- package/dist/index.d.mts +23 -2
- package/dist/index.mjs +501 -16
- package/dist/source/index.jsx +500 -15
- package/package.json +2 -2
- package/src/Column.tsx +36 -3
- package/src/DataTable.tsx +484 -32
- package/src/helpers/activeFollow.ts +42 -0
- package/src/helpers/columnRuleUtils.ts +105 -0
- package/src/helpers/heldView.ts +185 -0
- package/src/index.ts +1 -1
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? }
|
|
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,
|
|
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:
|
|
904
|
+
columns: cols0,
|
|
885
905
|
state: currentState(),
|
|
886
906
|
getCoreRowModel: getCoreRowModel(),
|
|
887
|
-
|
|
888
|
-
|
|
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
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
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
|
-
|
|
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
|
-
// •
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
// overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
|
|
1028
|
-
// writing paginationDefault
|
|
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:
|
|
3619
|
-
state:
|
|
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
|
-
|
|
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
|
-
//
|
|
5792
|
-
//
|
|
5793
|
-
//
|
|
5794
|
-
//
|
|
5795
|
-
|
|
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
|
-
|
|
8175
|
-
|
|
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(
|
|
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>.
|