@rozie-ui/data-table-solid 0.2.6 → 0.3.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/src/DataTable.tsx CHANGED
@@ -3,6 +3,10 @@ import { Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, s
3
3
  import { Key } from '@solid-primitives/keyed';
4
4
  import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass, rozieContext, rozieDisplay } from '@rozie/runtime-solid';
5
5
  import Popover from '@rozie-ui/popover-solid';
6
+ import { isSafeKey, wrapAggregationFn } from './helpers/columnDefUtils';
7
+ import { applyUpdater, clamp, focusables } from './helpers/indexMath';
8
+ import { escapeTsvField, parseTsv, tileGridToBox, tileIndex } from './helpers/tsvGrid';
9
+ import { replaceRowValue, indexOfRowIn, replaceRowValues } from './helpers/rowValueUtils';
6
10
  import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
7
11
  // Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
8
12
  // (mirrors the expand/group models) — inert until a consumer READS a column facet via the
@@ -37,6 +41,13 @@ __rozieInjectStyle('DataTable-d5dcab4c', `.rozie-data-table[data-rozie-s-d5dcab4
37
41
  font: var(--rdt-font, 14px system-ui, sans-serif);
38
42
  color: var(--rdt-color, inherit);
39
43
  }
44
+ .rozie-data-table.rdt-col-windowed[data-rozie-s-d5dcab4c] {
45
+ table-layout: fixed;
46
+ }
47
+ .rozie-data-table.rdt-col-windowed[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c],
48
+ .rozie-data-table.rdt-col-windowed[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
49
+ box-sizing: border-box;
50
+ }
40
51
  .rdt-sr-live[data-rozie-s-d5dcab4c] {
41
52
  position: absolute;
42
53
  width: 1px;
@@ -482,13 +493,17 @@ interface DataTableProps {
482
493
  */
483
494
  undoLimit?: number;
484
495
  /**
485
- * Opt-in vertical **row windowing**. When `true`, only the visible slice of rows renders 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. Default `false` is byte-identical to a non-virtual table.
496
+ * 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`.
486
497
  */
487
- virtual?: boolean;
498
+ virtual?: boolean | string;
488
499
  /**
489
- * Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
500
+ * Estimated row height (px) — the first-paint seed for the windowing engine before any row has been measured. Only consulted when rows are windowed. When `autoMeasure` is `true`, later renders progressively refine the estimate from measured content; when `autoMeasure` is `false` this remains the explicit override for every render.
490
501
  */
491
502
  estimateRowHeight?: number;
503
+ /**
504
+ * Opt-in content-driven row-size estimation. When `true`, the windowing engine feeds `estimateSize()` a running mean of measured row heights instead of the fixed `estimateRowHeight` seed, so `getTotalSize()` converges to the true content total on a large table with variable-height rows. Falls back to `estimateRowHeight` before any row has been measured. Default `false` keeps the estimate fixed at `estimateRowHeight` for every render (today's behavior).
505
+ */
506
+ autoMeasure?: boolean;
492
507
  /**
493
508
  * A CSS length string bounding the `rdt-scroll` container when `virtual` is on (e.g. `'400px'`). Mirrored to the `--rozie-data-table-max-height` custom property; the prop wins, the token is the fallback.
494
509
  */
@@ -560,8 +575,8 @@ export interface DataTableHandle {
560
575
  }
561
576
 
562
577
  export default function DataTable(_props: DataTableProps): JSX.Element {
563
- const _merged = mergeProps({ columns: (() => [])() as any[], selectionMode: 'none', manual: false, rowCount: null, pageCount: null, expandable: false, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', singleClickEdit: false, undoable: false, undoLimit: 100, virtual: false, estimateRowHeight: 40, maxHeight: '' }, _props);
564
- const [local, attrs] = splitProps(_merged, ['data', 'columns', 'selectionMode', 'sorting', 'globalFilter', 'columnFilters', 'pagination', 'manual', 'rowCount', 'pageCount', 'expandable', 'expanded', 'getSubRows', 'groupable', 'grouping', 'rowSelection', 'columnVisibility', 'columnSizing', 'columnOrder', 'columnPinning', 'stickyHeader', 'interactionMode', 'singleClickEdit', 'undoable', 'undoLimit', 'virtual', 'estimateRowHeight', 'maxHeight', 'children', 'ref', 'onSortChange', 'onExpandChange', 'onGroupChange', 'onFilterChange', 'onPageChange', 'onSelectionChange', 'onVisibilityChange', 'onResizeChange', 'onReorderChange', 'onPinChange', 'onHistoryChange', 'onActivecellChange', 'onRangeChange', 'onCellEditCommit', 'onRowEditCommit']);
578
+ const _merged = mergeProps({ columns: (() => [])() as any[], selectionMode: 'none', manual: false, rowCount: null, pageCount: null, expandable: false, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', singleClickEdit: false, undoable: false, undoLimit: 100, virtual: false, estimateRowHeight: 40, autoMeasure: false, maxHeight: '' }, _props);
579
+ const [local, attrs] = splitProps(_merged, ['data', 'columns', 'selectionMode', 'sorting', 'globalFilter', 'columnFilters', 'pagination', 'manual', 'rowCount', 'pageCount', 'expandable', 'expanded', 'getSubRows', 'groupable', 'grouping', 'rowSelection', 'columnVisibility', 'columnSizing', 'columnOrder', 'columnPinning', 'stickyHeader', 'interactionMode', 'singleClickEdit', 'undoable', 'undoLimit', 'virtual', 'estimateRowHeight', 'autoMeasure', 'maxHeight', 'children', 'ref', 'onSortChange', 'onExpandChange', 'onGroupChange', 'onFilterChange', 'onPageChange', 'onSelectionChange', 'onVisibilityChange', 'onResizeChange', 'onReorderChange', 'onPinChange', 'onHistoryChange', 'onActivecellChange', 'onRangeChange', 'onCellEditCommit', 'onRowEditCommit']);
565
580
  const resolved = () => local.children;
566
581
  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, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut, undo, redo, canUndo, canRedo, clearHistory }); });
567
582
 
@@ -751,10 +766,15 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
751
766
  // into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
752
767
  // NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
753
768
  // setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
754
- if (local.virtual && virtualizer) {
769
+ if (rowsWindowed() && virtualizer) {
755
770
  virtualizer.setOptions(virtualizerOptions());
756
771
  virtualizer._willUpdate();
757
772
  }
773
+ // Phase 87 87-04: re-feed the column virtualizer's count/size on the SAME re-derive cycle
774
+ // (a column-visibility/order/pinning change is already in the $watch list below and re-pulls
775
+ // through here) — guarded internally on colsWindowed() && colVirtualizer, so this is a no-op
776
+ // on every other path.
777
+ remeasureColumnWindow();
758
778
  // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
759
779
  // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
760
780
  // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
@@ -823,49 +843,91 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
823
843
  // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
824
844
  // mount"); the consumer drives focus by Tabbing/clicking in, never the component.
825
845
 
826
- // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
827
- // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
828
- // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
829
- // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
830
- // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
831
- if (local.virtual) {
846
+ // ── Windowing: capture the scroll element + construct the row virtualizer (req-1/2 — the
847
+ // row virtual-core instance ONLY when rows are windowed; the scroll-element capture whenever
848
+ // EITHER axis is windowed, since both axes observe the SAME .rdt-scroll element — D-03). Built
849
+ // HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
850
+ // getPrePaginationRowModel reads the live table. ENTIRELY inside the isWindowed()/rowsWindowed()
851
+ // guards: when both axes are off, NO virtual-core runtime code executes (byte-identical-off).
852
+ // _didMount() registers the scroll-element ResizeObserver and returns the teardown stored for
853
+ // $onUnmount.
854
+ if (isWindowed()) {
832
855
  gridScrollEl = __rozieRootRef! ? __rozieRootRef!.querySelector('.rdt-scroll') : null;
856
+ }
857
+ if (rowsWindowed()) {
833
858
  virtualizer = new Virtualizer(virtualizerOptions());
834
859
  virtualizerCleanup = virtualizer._didMount();
835
- // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
836
- // evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
837
- // reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
838
- // non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
839
- // SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
840
- // only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
841
- // never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
842
- // re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
843
- // pulls getVirtualItems().
860
+ }
861
+ // Phase 87 87-04: construct the SECOND, horizontal Virtualizer instance when the COLUMN
862
+ // axis is windowed — entirely inside its own colsWindowed() guard, byte-identical-off for
863
+ // 'rows'/true/false.
864
+ if (colsWindowed()) {
865
+ colVirtualizer = new Virtualizer(columnVirtualizerOptions());
866
+ colVirtualizerCleanup = colVirtualizer._didMount();
867
+ }
868
+ // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte), either axis: the windowed <For>/{#each}
869
+ // accessor was first evaluated at initial render — while `virtualizer`/`colVirtualizer` were
870
+ // still null — and (because windowedRows()/windowedColIndices() read $data.windowVer up top)
871
+ // subscribed to windowVer then returned []/the full set. Both `let`s are non-reactive, so
872
+ // their assignment above does NOT notify the accessor; we must bump the SIGNAL it subscribed
873
+ // to. _didMount() computes the first window synchronously but its onChange only fires on
874
+ // SUBSEQUENT scroll/resize, so without this explicit bump the first window would never paint
875
+ // on the fine-grained targets. Idempotent + harmless on the coarse targets (they re-render
876
+ // wholesale anyway). ONE bump here (not one per axis, Rule 1 — see the ROZ138 note below)
877
+ // covers both: windowedRows()/windowedColIndices() each simply re-derive against their own
878
+ // now-non-null virtualizer instance off the SAME counter (87-02 checkpoint decision (b): no
879
+ // separate $data.colWindowVer). Gated on isWindowed() (either axis) rather than two separate
880
+ // per-axis bumps: a second `$data.windowVer = $data.windowVer + 1` write later in this SAME
881
+ // $onMount body would dominate-flag ROZ138 (React stale-read) on its own right-hand-side read
882
+ // of the counter it had just written a few lines above — a false positive (this increment
883
+ // idiom lowers to a functional React setState update and is safe regardless of ordering, the
884
+ // same reasoning virtualizerOptions()'s onChange documents), but collapsing to one bump avoids
885
+ // manufacturing the false-positive warning at all rather than accepting it.
886
+ if (isWindowed()) {
844
887
  setWindowVer(windowVer() + 1);
845
- // After the first window commits (next frame), refine heights + fire the dev-mode warns
846
- // ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
847
- // code and these warns can never fire there (req-1 byte-identical-off preserved).
888
+ }
889
+ // After the first window commits (next frame), refine heights + fire the dev-mode warns ONCE.
890
+ // Entirely inside the isWindowed() guard so the off (neither axis windowed) emitted path adds
891
+ // NO code and these warns can never fire there (req-1 byte-identical-off preserved). The
892
+ // row-specific measurement + warns stay further guarded by rowsWindowed() (byte-identical to
893
+ // before this restructure); the column-axis warn (Phase 87 D-07(a)) is guarded by
894
+ // colsWindowed() so it can fire ONLY on the 'columns'/'both' path, never on 'rows'/true/false.
895
+ if (isWindowed()) {
848
896
  const afterFirstFrame = () => {
849
- // D-10: measure the rendered rows.
850
- remeasureWindow();
851
- // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
852
- // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
853
- // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
854
- const h = gridScrollEl ? gridScrollEl.clientHeight : 0;
855
- if (!h) {
856
- console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
897
+ if (rowsWindowed()) {
898
+ // D-10: measure the rendered rows.
899
+ remeasureWindow();
900
+ // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
901
+ // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
902
+ // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
903
+ const h = gridScrollEl ? gridScrollEl.clientHeight : 0;
904
+ if (!h) {
905
+ console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
906
+ }
907
+ // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
908
+ // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
909
+ // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
910
+ // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
911
+ // detected as a pagination that DIFFERS from that default (a consumer who set a real page
912
+ // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
913
+ // virtual=false path are untouched (rowsWindowed()-guarded, as before this restructure).
914
+ const pg = pagination();
915
+ const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
916
+ if (local.manual !== true && pgConfigured) {
917
+ console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
918
+ }
857
919
  }
858
- // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
859
- // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
860
- // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
861
- // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
862
- // detected as a pagination that DIFFERS from that default (a consumer who set a real page
863
- // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
864
- // virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
865
- const pg = pagination();
866
- const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
867
- if (local.manual !== true && pgConfigured) {
868
- console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
920
+ // Phase 87 Task 1(a) (D-07/D-08 precedent, APPROVED): a dev-mode runtime warn when the
921
+ // column axis is windowed but the scroll container has no bounded width — the horizontal
922
+ // failure mode is worse than the vertical one (an unbounded width renders every column
923
+ // with no scrollbar, so the feature silently does nothing and there is no compile
924
+ // diagnostic possible, since the bound may come from consumer CSS). Guarded by
925
+ // colsWindowed() so it can NEVER fire on the 'rows'/true/false path.
926
+ if (colsWindowed()) {
927
+ const w = gridScrollEl ? gridScrollEl.clientWidth : 0;
928
+ if (!w) {
929
+ console.warn('[rozie-data-table] virtual is on for columns but the scroll container has no bounded width; set a CSS width on an ancestor so the column window can be measured');
930
+ }
869
931
  }
870
932
  };
871
933
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
@@ -880,6 +942,17 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
880
942
  });
881
943
  onCleanup(() => {
882
944
  if (virtualizerCleanup) virtualizerCleanup();
945
+ // Phase 87 87-04: tear down the column virtualizer's scroll-element ResizeObserver too.
946
+ // No-op when column windowing was off (cleanup stays null).
947
+ if (colVirtualizerCleanup) colVirtualizerCleanup();
948
+ // Gap-closure 87-09: disconnect the RTL dir-attribute MutationObserver (ensureColRtlWatch(),
949
+ // windowing.rzts). No-op if column windowing was never on (the observer was never attached).
950
+ teardownColRtlWatch();
951
+ // Gap-closure 87-13 (CR-01): cancel a deferred remeasure sweep still pending at unmount.
952
+ // Must run here — remeasureWindow()'s `!virtualizer || !gridRoot` guard never fires, since
953
+ // neither ref is nulled — or the pass reaches 87-07's refineRowEstimate() and writes
954
+ // $data.windowVer against a torn-down instance.
955
+ teardownRemeasure();
883
956
  // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
884
957
  teardownFillDrag();
885
958
  // §6 (260709-3qt): remove any live drag-select document listeners on a mid-drag unmount.
@@ -938,6 +1011,13 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
938
1011
  let virtualizer: any = null;
939
1012
  let virtualizerCleanup: any = null;
940
1013
  let gridScrollEl: any = null;
1014
+ // ── Horizontal (column-axis) windowing instance state (Phase 87 87-04) ─────────────────
1015
+ // The SECOND, independent virtual-core instance (AXIS MECHANISM, windowing.rzts): mutable
1016
+ // top-level `let`s (the `virtualizer` precedent above — React hoists to useRef). NULL until
1017
+ // $onMount, and ONLY constructed when colsWindowed(). Shares gridScrollEl (the SAME
1018
+ // .rdt-scroll element) with the row instance — D-03.
1019
+ let colVirtualizer: any = null;
1020
+ let colVirtualizerCleanup: any = null;
941
1021
  // CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
942
1022
  // rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
943
1023
  // pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
@@ -948,6 +1028,17 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
948
1028
  // onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
949
1029
  // once motion settles between scroll steps) is unaffected.
950
1030
  let remeasurePending = false;
1031
+ // Gap-closure 87-13: the deferred sweep's own rAF handle, plus a disposed latch. Until 87-07 a
1032
+ // post-unmount sweep was inert — it re-measured a detached tree and wrote nothing. 87-07's D-15
1033
+ // re-feed changed that: remeasureWindow() now reaches refineRowEstimate(), which calls
1034
+ // virtualizer.setOptions()/_willUpdate(), mutates gridScrollEl.scrollTop, and WRITES
1035
+ // $data.windowVer. remeasureWindow()'s existing `!virtualizer || !gridRoot` guard cannot catch
1036
+ // this — neither ref is ever nulled after $onMount assigns it — so a scroll/resize burst that
1037
+ // leaves a pass pending across unmount (a route transition mid-scroll) lands a state write on a
1038
+ // torn-down instance. Handle init is `null`, never 0: an Angular leaf types this as the DOM
1039
+ // handle and TS2322s on a numeric seed.
1040
+ let remeasureRaf: any = null;
1041
+ let remeasureDisposed = false;
951
1042
 
952
1043
  // ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
953
1044
  // Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
@@ -1109,35 +1200,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1109
1200
  function currentData(): any {
1110
1201
  return data() != null ? data() : dataDefault();
1111
1202
  }
1112
-
1113
- // Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
1114
- // applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
1115
- // byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
1116
- // cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
1117
- // render callbacks — cells render via the single #cell/#header scoped slot on this
1118
- // component, dispatched by columnId; <Column> carries metadata only.)
1119
- function isSafeKey(k: any) {
1120
- return k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
1121
- }
1122
- // wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
1123
- // aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
1124
- // anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
1125
- // 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
1126
- // built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
1127
- // is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
1128
- // throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
1129
- // Anything else → undefined (no aggregation; the cell renders as a placeholder).
1130
- function wrapAggregationFn(fn: any) {
1131
- if (typeof fn === 'string') return fn;
1132
- if (typeof fn !== 'function') return undefined;
1133
- return (columnId: any, leafRows: any, childRows: any) => {
1134
- try {
1135
- return fn(columnId, leafRows, childRows);
1136
- } catch (err: any) {
1137
- return undefined;
1138
- }
1139
- };
1140
- }
1141
1203
  // Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
1142
1204
  // ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
1143
1205
  // ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
@@ -1208,10 +1270,48 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1208
1270
  }
1209
1271
  };
1210
1272
  }
1273
+ // ══ D-23 (gap-closure, Phase 87 follow-up) — columnDefs() memoization ═══════════════════
1274
+ // ROOT CAUSE (confirmed via CPU profiling the D-13 [vue] fill-drag auto-scroll failure):
1275
+ // defFor(colId) (columnChrome.rzts) — the sole resolver behind editMetaOf/columnEditable/
1276
+ // editorTypeOf/isEditing, all called PER CELL, PER RENDER (isEditing's own header comment:
1277
+ // "Called per-cell in both <td> bodies") — called columnDefs() with ZERO caching. columnDefs()
1278
+ // rebuilds the FULL column-def array (buildConfigDef for every entry in $props.columns,
1279
+ // including recursive group traversal) from scratch on every single invocation. With N
1280
+ // columns and M rendered rows that is an O(N x M x N) = O(N^2 x M) cost PER RENDER COMMIT —
1281
+ // on this plan's 60-column fixture, up to ~1200 cells x 60 rebuilt defs = ~72,000
1282
+ // buildConfigDef calls per commit, repeated on every windowVer-driven re-render during a
1283
+ // sustained scroll gesture (the fill-drag edge-auto-scroll rAF loop bumps windowVer on
1284
+ // every frame). A CPU profile of the failing case showed buildConfigDef consuming ~39% of
1285
+ // ALL wall-clock time during the gesture, with a FURTHER ~27% in Vue's own reactive-Proxy
1286
+ // `get` trap — $props.columns/$data.colReg are reactive on Vue, so every one of the
1287
+ // ~15 property reads buildConfigDef performs per column pays Vue's track()/Reflect.get
1288
+ // overhead, an expense React's plain (non-Proxy) props do not pay. The SAME O(N^2 x M)
1289
+ // algorithmic cost exists on all six targets (this is shared-engine code, dissolved
1290
+ // identically everywhere) — Vue's per-access reactivity overhead is what tips an already
1291
+ // wasteful recompute over the fixed real-time budget used by this test's rAF-driven
1292
+ // auto-scroll hold, not a Vue-specific logic defect. Fixing the recompute (not the timing)
1293
+ // removes the accidental n^2 blowup for every target, and Vue-specifically restores enough
1294
+ // per-frame headroom to keep pace with the auto-scroll loop.
1295
+ //
1296
+ // FIX: cache the resolved array, keyed on the REFERENCE identity of $props.columns and
1297
+ // $data.colReg — the exact two dependencies the reFeed() $watch (DataTable.rozie) already
1298
+ // treats as "changed only when the reference changes" (columns is consumer-memoized like
1299
+ // $props.data/$props.sorting; colReg is reassigned via `{ ...colReg, [key]: spec }` on every
1300
+ // real mutation, NEVER written in place — see DataTable.rozie's own colReg-mutation comment).
1301
+ // A cache hit is an O(1) reference-equality check; a cache miss re-runs the exact same
1302
+ // computation as before (byte-identical output either way — this is a pure memoization, not
1303
+ // a behavior change).
1304
+ let columnDefsCache: any[] | null = null;
1305
+ let columnDefsCacheColumnsRef: any = undefined;
1306
+ let columnDefsCacheColRegRef: any = undefined;
1211
1307
  function columnDefs() {
1308
+ const cfg = local.columns || [];
1309
+ const reg = colReg() || {};
1310
+ if (columnDefsCache && local.columns === columnDefsCacheColumnsRef && colReg() === columnDefsCacheColRegRef) {
1311
+ return columnDefsCache;
1312
+ }
1212
1313
  const byId = Object.create(null);
1213
1314
  const order = [];
1214
- const cfg = local.columns || [];
1215
1315
  for (const c of cfg as any) {
1216
1316
  const def = buildConfigDef(c);
1217
1317
  if (!def) continue;
@@ -1219,7 +1319,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1219
1319
  if (!(id in byId)) order.push(id);
1220
1320
  byId[id] = def;
1221
1321
  }
1222
- const reg = colReg() || {};
1223
1322
  for (const id in reg) {
1224
1323
  if (!isSafeKey(id)) continue;
1225
1324
  const spec = reg[id];
@@ -1250,6 +1349,9 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1250
1349
  }
1251
1350
  const out = [];
1252
1351
  for (const id of order as any) if (byId[id]) out.push(byId[id]);
1352
+ columnDefsCache = out;
1353
+ columnDefsCacheColumnsRef = local.columns;
1354
+ columnDefsCacheColRegRef = colReg();
1253
1355
  return out;
1254
1356
  }
1255
1357
 
@@ -1334,9 +1436,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1334
1436
  _props.onSortChange?.(next);
1335
1437
  programmatic--;
1336
1438
  }
1337
- function applyUpdater(updater: any, current: any) {
1338
- return typeof updater === 'function' ? updater(current) : updater;
1339
- }
1340
1439
 
1341
1440
  // ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
1342
1441
  // table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
@@ -1732,13 +1831,159 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1732
1831
  // windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
1733
1832
  // scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.
1734
1833
 
1834
+ // ══ D-05 predicate layer (Phase 87 87-02) — resolves the windowing prop into an axis state,
1835
+ // replacing every bare truthiness read of $props.virtual (which is unsafe once the prop widens
1836
+ // to a string grammar in 87-03: 'columns' is truthy but does NOT mean "windows rows"). Modeled on
1837
+ // resolveAppendTo() in CommandPalette.rozie (the D-01 value-grammar precedent): branch on
1838
+ // typeof === 'string', switch on the known string values, fall back to the boolean cases. THIS
1839
+ // plan still declares `virtual: Boolean` (87-03 widens the declaration to [Boolean, String]), so
1840
+ // resolveVirtual() can currently only return 'off' or 'rows' and colsWindowed() is constantly
1841
+ // false — inert until 87-03/87-04 land the column axis.
1842
+ //
1843
+ // Declared BEFORE windowSource() (below): windowSource() calls rowsWindowed(), and the React
1844
+ // emitter lowers each of these to a useCallback whose dependency array is evaluated eagerly at
1845
+ // the declaration site — a forward reference here is a genuine TDZ (TS2448), not just a style
1846
+ // preference (found running the strict react typecheck gate this task, Rule 1).
1847
+ function resolveVirtual() {
1848
+ const v = local.virtual;
1849
+ if (typeof v === 'string') {
1850
+ if (v === 'rows') return 'rows';
1851
+ if (v === 'columns') return 'columns';
1852
+ if (v === 'both') return 'both';
1853
+ return 'off';
1854
+ }
1855
+ return v === true ? 'rows' : 'off';
1856
+ }
1857
+ // rowsWindowed(): the windowing.rzts host-contract symbol (D-05). Byte-behaviorally identical to
1858
+ // today's `$props.virtual` truthiness — this plan changes NO user-visible behavior.
1859
+ function rowsWindowed() {
1860
+ const s = resolveVirtual();
1861
+ return s === 'rows' || s === 'both';
1862
+ }
1863
+ function colsWindowed() {
1864
+ const s = resolveVirtual();
1865
+ return s === 'columns' || s === 'both';
1866
+ }
1867
+ function isWindowed() {
1868
+ return rowsWindowed() || colsWindowed();
1869
+ }
1870
+
1871
+ // autoMeasureOn() (Phase 87 87-07, D-18): the windowing.rzts host-contract gate for the
1872
+ // running-mean row-size estimator. Real body here: $props.autoMeasure === true. listbox/
1873
+ // combobox define this as a permanent `() => false` one-liner (D-20) so estimateRowSize()'s
1874
+ // accumulator branch is dead code for them.
1875
+ function autoMeasureOn() {
1876
+ return local.autoMeasure === true;
1877
+ }
1878
+
1879
+ // D-10 column-axis host-contract symbols. columnCount()/columnSize() got REAL bodies in
1880
+ // 87-04 (the tracer); forcedColumns() stays an empty-array stub until 87-05 gives it the
1881
+ // pinned + active-cell + editing-column union. Explicit return-type annotations (columnSize /
1882
+ // forcedColumns) copy the pinMeasurement() trick (windowing.rzts) so the strict bundled-leaf
1883
+ // tsc does not flow-narrow a no-op host's return to `never`.
1884
+ //
1885
+ // columnCount(): the leaf-column count the column virtualizer windows over — visibleColCount()
1886
+ // (gridFocusNav.rzts), the SAME index space colIndexOf()/data-col-index already address (D-08).
1887
+ function columnCount(): number {
1888
+ return visibleColCount();
1889
+ }
1890
+ // columnSize(i): the authoritative width of absolute leaf column i (D-06). Reads table-core's
1891
+ // OWN getVisibleLeafColumns() directly — NOT the host's allLeafColumns() wrapper
1892
+ // (columnChrome.rzts), which returns plain `{ id, label, visible }` POJOs with no getSize()
1893
+ // and also EXCLUDES the auto-injected chrome columns (select/expander), breaking the index
1894
+ // alignment with columnCount()/colIndexOf(). getVisibleLeafColumns() is the array
1895
+ // row.getVisibleCells() itself is built from, so its index space matches exactly. Falls back
1896
+ // to table-core's own default column width (150) when the index is out of range.
1897
+ function columnSize(i: number): number {
1898
+ if (!table || !table.getVisibleLeafColumns) return 150;
1899
+ const cols = table.getVisibleLeafColumns();
1900
+ const c = cols[i];
1901
+ return c && typeof c.getSize === 'function' ? c.getSize() : 150;
1902
+ }
1903
+ // forcedColumns() (Phase 87 87-05, D-10): the column-axis mirror of pinnedEditIndex()/
1904
+ // pinnedMeasurement() (the row axis's D-05 pin-extension hook) — unions pinned + active-cell +
1905
+ // editing column indices into the window slice so none of them ever recycle out from under the
1906
+ // user. Reads table.getVisibleLeafColumns() DIRECTLY (the SAME array columnCount()/columnSize()
1907
+ // index against, and the array row.getVisibleCells() is itself built from) — NOT
1908
+ // allLeafColumns() (columnChrome.rzts), which excludes the auto-injected chrome columns and
1909
+ // returns plain POJOs with no getSize(), breaking the index alignment (the exact columnSize()
1910
+ // Rule-1 fix from 87-04, mirrored here rather than repeated).
1911
+ //
1912
+ // The fill-handle corner is DELIBERATELY not forced (D-10 explicit) — no call to
1913
+ // isFillHandleCell here. The whole selected RANGE is not forced either: a range's moving
1914
+ // corner tracks into $data.activeColIndex (extendRange/setRangeFocus), so even a 200-column
1915
+ // range forces at most the ONE active-cell column via the branch below, never the full span —
1916
+ // forcing the whole range would defeat the feature precisely where it is most wanted (D-10).
1917
+ function forcedColumns(): number[] {
1918
+ if (!colsWindowed()) return [];
1919
+ const out = [];
1920
+ if (table && table.getVisibleLeafColumns) {
1921
+ const cols = table.getVisibleLeafColumns();
1922
+ for (let i = 0; i < cols.length; i++) {
1923
+ const c = cols[i];
1924
+ if (c && c.getIsPinned && c.getIsPinned()) out.push(i);
1925
+ }
1926
+ }
1927
+ // Active cell's column (D-10): required or focusActiveCell()'s [data-col-index="N"]
1928
+ // querySelector cannot resolve for an off-window active cell. Body cells only — a header-
1929
+ // active cell has no column-axis rendering dependency here (headers window separately).
1930
+ if (!activeIsHeader() && activeColIndex() >= 0 && out.indexOf(activeColIndex()) === -1) {
1931
+ out.push(activeColIndex());
1932
+ }
1933
+ // Editing column (D-10): required or an open editor unmounts mid-keystroke when a
1934
+ // horizontal scroll recycles it — the exact failure D-02 was built to fix on the row axis
1935
+ // (T-87-05-01, the phase's one HIGH-severity threat). Single-cell edit mode only
1936
+ // ($data.editingRow/editingCol, mirroring editingColumnId()'s own index pair) — full-row
1937
+ // edit mode (editingRowIndex) has no single "the editing column" to force.
1938
+ if (editingRow() >= 0 && editingCol() >= 0 && out.indexOf(editingCol()) === -1) {
1939
+ out.push(editingCol());
1940
+ }
1941
+ return out;
1942
+ }
1943
+
1944
+ // windowedCells(row) (D-09): the TEMPLATE-ONLY windowed slice of a row's visible cells — wraps
1945
+ // visibleCellsFor(row) (columnChrome.rzts) WITHOUT modifying it. Degrades to the full,
1946
+ // unsliced set when columns are not windowed (byte-identical to today). visibleCellsFor()
1947
+ // itself must never be sliced: colIndexOf/visibleColCount/columnIdAt/cellValueAt/beginEdit all
1948
+ // index into its FULL result (the A2 invariant, 87-02), and slicing it would silently break
1949
+ // all five with no compile error.
1950
+ function windowedCells(row: any) {
1951
+ // subscribe-first (the windowedRows() discipline) so the fine-grained targets' body <td>
1952
+ // r-for re-derives when the column window moves.
1953
+ void windowVer();
1954
+ const cells = visibleCellsFor(row);
1955
+ if (!colsWindowed()) return cells;
1956
+ const idx = windowedColIndices();
1957
+ const out = [];
1958
+ for (let i = 0; i < idx.length; i++) {
1959
+ // WR-01 shrink-window guard, one axis over: drop any index that outruns the row's
1960
+ // current cell list (a brief stale-count window analogous to windowedRows()'s WR-01).
1961
+ const cell = cells[idx[i]];
1962
+ if (cell) out.push(cell);
1963
+ }
1964
+ return out;
1965
+ }
1966
+
1967
+ // remeasureColumnWindow() (Phase 87 87-04) is declared in DataTable.rozie's OWN script, NOT
1968
+ // here — even though it is conceptually this file's D-10 host-shell twin of remeasureWindow()
1969
+ // above. Reason (Rule 1, found via the react typecheck gate): remeasureColumnWindow() must call
1970
+ // columnVirtualizerOptions() (headless-core windowing.rzts). inlineScriptPartials() groups a
1971
+ // RELATIVE partial import (this file) BEFORE a bare-package-specifier partial import
1972
+ // (@rozie-ui/headless-core/windowing.rzts) in the merged per-target output, regardless of which
1973
+ // import statement appears first in DataTable.rozie's own script (verified: columnVirtualizerOptions
1974
+ // emits as a `useCallback` LATER in the react output than every symbol this file exports). A
1975
+ // function declared HERE calling INTO windowing.rzts is therefore a forward reference — the
1976
+ // exact TS2448 TDZ class the 87-02 predicate-layer reordering already fixed once. DataTable.rozie's
1977
+ // OWN top-level script code lands AFTER both partial groups, so remeasureColumnWindow() is safe
1978
+ // declared there instead (see the comment beside windowedHeaderRow()/windowedColSpan()).
1979
+
1735
1980
  // windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
1736
1981
  // host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
1737
1982
  // (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
1738
1983
  // else the normal (paginated) row model — the non-virtual path is byte-unchanged.
1739
1984
  function windowSource() {
1740
1985
  if (!table) return [];
1741
- if (local.virtual) return table.getPrePaginationRowModel().rows;
1986
+ if (rowsWindowed()) return table.getPrePaginationRowModel().rows;
1742
1987
  return table.getRowModel().rows;
1743
1988
  }
1744
1989
 
@@ -1761,6 +2006,10 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1761
2006
  // Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
1762
2007
  // already-observed node, so running both is cheap and loop-free.
1763
2008
  function scheduleRemeasure() {
2009
+ // 87-13: never arm a new sweep after teardown — an onChange can still fire from a
2010
+ // virtualizer whose own ResizeObserver cleanup has run but whose queued tick was already
2011
+ // in flight.
2012
+ if (remeasureDisposed) return;
1764
2013
  if (remeasurePending) return;
1765
2014
  remeasurePending = true;
1766
2015
  let ranMicro = false;
@@ -1768,6 +2017,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1768
2017
  remeasureWindow();
1769
2018
  };
1770
2019
  const rafPass = () => {
2020
+ remeasureRaf = null;
1771
2021
  remeasurePending = false;
1772
2022
  remeasureWindow();
1773
2023
  };
@@ -1775,7 +2025,25 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1775
2025
  ranMicro = true;
1776
2026
  queueMicrotask(microPass);
1777
2027
  }
1778
- if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
2028
+ // 87-13: retain the handle so teardownRemeasure() can cancel a pass that is still pending
2029
+ // at unmount. Without this the callback survives teardown and writes $data.windowVer.
2030
+ if (typeof requestAnimationFrame === 'function') remeasureRaf = requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else remeasureRaf = setTimeout(rafPass, 0);
2031
+ }
2032
+
2033
+ // 87-13 (CR-01): idempotent teardown for the deferred sweep — the same discipline
2034
+ // teardownFillDrag()/teardownRangeDrag() already follow. Cancels a pending pass and latches
2035
+ // `remeasureDisposed` so the microtask twin (which carries no cancellable handle) and any
2036
+ // in-flight onChange both bail instead of writing to a torn-down instance.
2037
+ function teardownRemeasure() {
2038
+ remeasureDisposed = true;
2039
+ if (remeasureRaf != null) {
2040
+ // Discriminate on requestAnimationFrame — the SAME predicate scheduleRemeasure() branched
2041
+ // on when it produced this handle. Testing cancelAnimationFrame instead would hand a
2042
+ // setTimeout id to cancelAnimationFrame in any env where the two disagree.
2043
+ if (typeof requestAnimationFrame === 'function') cancelAnimationFrame(remeasureRaf);else clearTimeout(remeasureRaf);
2044
+ remeasureRaf = null;
2045
+ }
2046
+ remeasurePending = false;
1779
2047
  }
1780
2048
 
1781
2049
  // pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
@@ -1810,6 +2078,11 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1810
2078
  // idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
1811
2079
  // visible window each commit is cheap and loop-free.
1812
2080
  function remeasureWindow() {
2081
+ // 87-13 (CR-01): the disposed latch, checked BEFORE the ref guard below. That guard cannot
2082
+ // cover unmount — virtualizer/gridRoot are assigned in $onMount and never nulled — so this
2083
+ // is the only thing standing between a pass queued pre-unmount and refineRowEstimate()'s
2084
+ // setOptions/scrollTop/$data.windowVer writes against a torn-down instance.
2085
+ if (remeasureDisposed) return;
1813
2086
  if (!virtualizer || !gridRoot) return;
1814
2087
  // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
1815
2088
  // exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
@@ -1821,6 +2094,14 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1821
2094
  if (virtualizer.scrollState) return;
1822
2095
  const trs = gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
1823
2096
  for (const tr of trs as any) virtualizer.measureElement(tr);
2097
+ // D-15 (Phase 87 87-07): trigger the shared engine's post-measurement fold + hysteresis
2098
+ // re-feed via the afterRowRemeasure mutable-let hook (DataTable.rozie's own script,
2099
+ // assigned to windowing.rzts's refineRowEstimate()) — NEVER a direct call from this file
2100
+ // into windowing.rzts (see windowing.rzts's HOST CONTRACT comment for the forward-
2101
+ // reference TDZ this indirection avoids, the same class remeasureColumnWindow()'s comment
2102
+ // documents for columnVirtualizerOptions()). Inert (no-op) until the host assigns it; the
2103
+ // off/pre-mount path never reaches this line since virtualizer is null there.
2104
+ if (afterRowRemeasure) afterRowRemeasure();
1824
2105
  }
1825
2106
 
1826
2107
  // D-04: this shell exports ONLY the impure, data-table-specific host pieces. The pure windowing
@@ -1851,6 +2132,61 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1851
2132
  // defaulting to no-op): the DataTable host passes its edit-pinning hooks;
1852
2133
  // listbox passes nothing. Routing pinning through this host hook (NOT
1853
2134
  // inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
2135
+ // - rowsWindowed(): boolean — is the ROW axis windowed. REQUIRED, no default — replaces every bare
2136
+ // truthiness read of the host's windowing prop (D-05); `windowedRows()` /
2137
+ // `padTop()` / `padBottom()` / `rowIsOutsideWindow()` below call it by
2138
+ // convention exactly as they already call `pinnedEditIndex()`.
2139
+ // - colsWindowed(): boolean — is the COLUMN axis windowed. REQUIRED, no default. `false` for every
2140
+ // host until it defines the real column-axis mechanism (87-04+).
2141
+ // - columnCount(): number — the leaf-column count the column virtualizer windows over. REQUIRED,
2142
+ // no default.
2143
+ // - columnSize(i: number): number — the authoritative width of absolute leaf column `i`, sourced
2144
+ // from table-core's `getSize()` under D-06. REQUIRED, no default.
2145
+ // - forcedColumns(): number[] — the D-10 OPTIONAL column-axis mirror of `pinnedEditIndex()`: the
2146
+ // DataTable host unions pinned + active-cell + editing column indices into
2147
+ // the column-window slice; listbox/combobox pass an empty array (host-
2148
+ // provided, defaulting to `[]`).
2149
+ // - colVirtualizer — the host's SECOND virtual-core instance, windowing the COLUMN axis
2150
+ // (see the AXIS MECHANISM note below). Host-provided, defaulting to `null`.
2151
+ // - autoMeasureOn(): boolean — the D-18 REQUIRED content-driven-estimate gate (Phase 87 87-07):
2152
+ // data-table's real body reads `$props.autoMeasure === true`; listbox/
2153
+ // combobox/command-palette return `false` so the accumulator branch
2154
+ // estimateRowSize() gates on is dead code for them (D-20).
2155
+ // - afterRowRemeasure — OPTIONAL host-owned mutable `let` (defaults to a no-op / undefined),
2156
+ // assigned to refineRowEstimate() (below) by the host. The DataTable
2157
+ // host's remeasureWindow() (virtualization.rzts) calls it AFTER its
2158
+ // measureElement sweep so the fold + hysteresis re-feed run on every
2159
+ // window commit. Routed through a mutable `let` rather than a direct
2160
+ // call FROM virtualization.rzts INTO this file: a relative-partial CONST
2161
+ // calling a bare-specifier-partial CONST is the exact forward-reference
2162
+ // TDZ class remeasureColumnWindow()'s own DataTable.rozie comment
2163
+ // documents for columnVirtualizerOptions() (inlineScriptPartials()
2164
+ // groups the relative partial BEFORE the bare-specifier partial in the
2165
+ // merged per-target output, regardless of source import order). A
2166
+ // mutable `let` hoists to `useRef` on React and is excluded from a
2167
+ // useCallback's dependency array, sidestepping the hazard entirely — the
2168
+ // SAME mechanism `refreshRowModel` already relies on.
2169
+ //
2170
+ // AXIS MECHANISM (OQ1 / Assumption A1 — resolved from the installed source in 87-02;
2171
+ // LANDED in 87-04: `columnVirtualizerOptions()` below IS the second, horizontal instance this
2172
+ // note originally only documented). `horizontal` is a PER-INSTANCE field of `VirtualizerOptions`
2173
+ // (`node_modules/@tanstack/virtual-core/dist/esm/index.d.ts:67`, installed version 3.17.1 per
2174
+ // `package.json`), and every axis-sensitive internal read consults `instance.options.horizontal` —
2175
+ // `measureElement`'s inlineSize/blockSize + offsetWidth/offsetHeight branch
2176
+ // (`dist/esm/index.js:137,150`), `observeElementOffset`'s scrollLeft/scrollTop branch
2177
+ // (`dist/esm/index.js:118-121`), `getMaxScrollOffset`'s scrollWidth/scrollHeight branch
2178
+ // (`dist/esm/index.js:907-915`), and `scrollWithAdjustments`'s left/top branch
2179
+ // (`dist/esm/index.js:152-161`). So ONE `Virtualizer` instance windows exactly ONE axis: the column
2180
+ // axis needs its own SECOND, independent `Virtualizer` instance constructed with `horizontal: true`,
2181
+ // sharing the SAME `getScrollElement()` (the `rdt-scroll` wrapper) the row instance already uses.
2182
+ // Two options the row axis does not set that the column instance will need: `isRtl?: boolean`
2183
+ // (data-table ships an RTL grid path) and `overscan?: number` (D-07 gives the column axis its own
2184
+ // hardcoded constant, separate from the row axis's `overscan: 8` below).
2185
+ //
2186
+ // isRtl WIRING (gap-closure 87-09, LANDED — see `ensureColRtlWatch()`/`isColRtl()` below,
2187
+ // immediately ahead of `columnVirtualizerOptions()`): data-table has no construction-time RTL
2188
+ // signal (no `dir`/`rtl` prop), and `dir` can be set on `gridScrollEl` at ANY point relative to
2189
+ // mount. `isRtl` is therefore computed LIVE via `getComputedStyle`, not baked in once.
1854
2190
 
1855
2191
  // getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
1856
2192
  // React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
@@ -1860,17 +2196,316 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1860
2196
  return src && src[i] ? src[i].id : undefined;
1861
2197
  }
1862
2198
 
2199
+ // COL_OVERSCAN (D-07): the column axis's own hardcoded overscan constant, separate from the
2200
+ // row axis's `overscan: 8` below. Columns are far wider than rows are tall, so one number
2201
+ // cannot serve both axes; no prop is exposed because no consumer has asked to tune the row
2202
+ // overscan across the four phases it has shipped. Unused until 87-04 constructs the second,
2203
+ // horizontal Virtualizer instance (see the AXIS MECHANISM note above).
2204
+ const COL_OVERSCAN = 3;
2205
+
2206
+ // columnVirtualizerOptions() (Phase 87 87-04): the SECOND, horizontal Virtualizer instance's
2207
+ // full options set — the AXIS MECHANISM note's second instance, landed. Mirrors
2208
+ // virtualizerOptions() exactly except: count/estimateSize read the COLUMN host symbols,
2209
+ // horizontal:true selects the axis, overscan is the column axis's OWN constant (D-07), and
2210
+ // there is NO getItemKey — columns have no stable per-column id in the virtual-core sense, so
2211
+ // they key by index (virtual-core's default). onChange bumps the SAME $data.windowVer counter
2212
+ // the row axis uses (87-02 checkpoint decision (b): no separate $data.colWindowVer) but does
2213
+ // NOT call scheduleRemeasure(): under D-06 column widths come from table-core's getSize()
2214
+ // oracle and are NEVER measured from the DOM, so a horizontal scroll has nothing new to
2215
+ // observe (unlike the row axis's CR-01 recycled-row remeasure sweep).
2216
+ // ══ Gap-closure 87-09 (dir="rtl" column windowing) ═══════════════════════════════════════
2217
+ // virtual-core reads `instance.options.isRtl` FRESH on every native 'scroll' event — the
2218
+ // negation lives inside `observeElementOffset`'s internal per-event closure
2219
+ // (`dist/esm/index.js:119-120`: `el.scrollLeft * (isRtl && -1 || 1)`), called anew each time
2220
+ // the browser fires 'scroll'. But `instance.options` itself is only a plain VALUE snapshot
2221
+ // taken by the LAST `setOptions()` call (`dist/esm/index.js:277-280`:
2222
+ // `for (const key in opts2) merged[key] = opts2[key]` copies the boolean, not a live binding
2223
+ // or getter) — so a static `isRtl` computed ONCE at `colVirtualizer` construction would only
2224
+ // ever reflect direction AT THAT INSTANT. `dir` can be set on `gridScrollEl` at any point
2225
+ // relative to mount (data-table has no construction-time RTL prop), so a MutationObserver on
2226
+ // `gridScrollEl`'s own `dir` attribute re-feeds `colVirtualizer`'s options — a cheap
2227
+ // object-merge via `setOptions()`, deliberately NOT a forced `_willUpdate()` recompute, since
2228
+ // nothing needs to repaint until the next real scroll — the moment direction flips. By the
2229
+ // time the NEXT native 'scroll' event's own offset read runs, `instance.options.isRtl`
2230
+ // already reflects the correction. Ordering is spec-guaranteed, not a race: MutationObserver
2231
+ // callbacks run on the microtask queue, which the HTML event loop always drains before the
2232
+ // next task — and a native 'scroll' event dispatched after a programmatic `scrollLeft` write
2233
+ // is itself a subsequent task, not a microtask racing the same tick.
2234
+ //
2235
+ // F6 STALE-CLOSURE HAZARD (found + fixed this task, React only): calling
2236
+ // columnVirtualizerOptions() FRESH from inside the MutationObserver's callback would
2237
+ // re-derive `count`/`estimateSize` via columnCount() (virtualization.rzts's
2238
+ // visibleColCount(), which reads `$data.rows`). The MutationObserver is attached ONCE,
2239
+ // inside ensureColRtlWatch() at colVirtualizer CONSTRUCTION time ($onMount) — a closure
2240
+ // FROZEN to that render's scope on React, where `$data.rows` is a useState value.
2241
+ // $onMount's row-model seed (refreshRowModel()) resolves via an ASYNC setState, so AT THE
2242
+ // EXACT INSTANT colVirtualizer is constructed, `rows` may still be `[]` in THAT render's
2243
+ // closure — and unlike a `let`-hoisted ref (virtualizer, table, gridScrollEl — always read
2244
+ // via `.current`, always live no matter which render's closure holds the reference),
2245
+ // `$data.rows` is genuine React state: a plain local binding recreated fresh EVERY render,
2246
+ // so a function captured from an OLDER render reads that OLDER render's snapshot FOREVER,
2247
+ // no matter how many later renders populate it. Confirmed empirically: calling
2248
+ // columnVirtualizerOptions() from the frozen callback read `count` back as 0 (not 60),
2249
+ // collapsing getVirtualItems() to []. This is the exact class of bug `virtualItemKey`'s own
2250
+ // header comment already documents ("never a frozen mount-render $data.rows closure") — and
2251
+ // a MUTABLE-LET INDIRECTION (the `afterRowRemeasure` shape, 87-07) does NOT fix it here,
2252
+ // because the hazard lives INSIDE columnVirtualizerOptions()'s own dependency chain
2253
+ // ($data.rows), not in "which function identity gets called" — wrapping a stale call in
2254
+ // another layer of ref does not make an inner React-state read fresh.
2255
+ //
2256
+ // FIX: never call columnVirtualizerOptions() again from this callback at all. Read
2257
+ // `colVirtualizer.options` — virtual-core's OWN internal state, mutated in place on every
2258
+ // setOptions() call regardless of which render's closure triggered it (a REAL object
2259
+ // reference the SAME way `virtualizer`/`table` are, not React state) — and merge in ONLY
2260
+ // the corrected `isRtl`. `count`/`estimateSize`/etc. keep whatever value the LAST correct
2261
+ // setOptions() call gave them (from construction, or from remeasureColumnWindow()'s own
2262
+ // FRESH-per-render re-feed via the $watch-driven refreshRowModel cycle) — untouched,
2263
+ // because this dir-flip has nothing to do with row/column count. isColRtl() itself reads
2264
+ // only `gridScrollEl` (also a ref) and does a live DOM getComputedStyle read — no React
2265
+ // state involved, so it is never stale regardless of which render's closure calls it.
2266
+ let colRtlObserver: any = null;
2267
+ let colRtlObserverEl: any = null;
2268
+
2269
+ // isColRtl(): getComputedStyle (not a raw `.getAttribute('dir')` read) so an ANCESTOR's `dir`
2270
+ // or a CSS `direction: rtl` rule is honored the same way the browser's own bidi layout honors
2271
+ // it, not merely a literal attribute on gridScrollEl itself.
2272
+ function isColRtl(): boolean {
2273
+ if (!gridScrollEl || typeof getComputedStyle !== 'function') return false;
2274
+ return getComputedStyle(gridScrollEl).direction === 'rtl';
2275
+ }
2276
+
2277
+ // ensureColRtlWatch(): idempotent — a no-op once already attached to the CURRENT gridScrollEl
2278
+ // (a stable ref for the component's lifetime once mounted). Self-attaching from inside
2279
+ // columnVirtualizerOptions() means no new host-contract symbol is needed: listbox/combobox/
2280
+ // command-palette never call columnVirtualizerOptions() at all (colsWindowed() is permanently
2281
+ // false for them, D-20), so this code path — and the observer it creates — never exists there.
2282
+ function ensureColRtlWatch(): void {
2283
+ if (!gridScrollEl || colRtlObserverEl === gridScrollEl || typeof MutationObserver !== 'function') return;
2284
+ if (colRtlObserver) colRtlObserver.disconnect();
2285
+ colRtlObserver = new MutationObserver(() => {
2286
+ if (colVirtualizer) colVirtualizer.setOptions({
2287
+ ...colVirtualizer.options,
2288
+ isRtl: isColRtl()
2289
+ });
2290
+ });
2291
+ colRtlObserver.observe(gridScrollEl, {
2292
+ attributes: true,
2293
+ attributeFilter: ['dir']
2294
+ });
2295
+ colRtlObserverEl = gridScrollEl;
2296
+ }
2297
+
2298
+ // teardownColRtlWatch(): exported so the DataTable host disconnects the observer alongside
2299
+ // colVirtualizerCleanup() in $onUnmount — mirrors virtual-core's own _didMount()/teardown
2300
+ // discipline for the ResizeObserver it manages internally. No-op if never attached.
2301
+ function teardownColRtlWatch(): void {
2302
+ if (colRtlObserver) colRtlObserver.disconnect();
2303
+ colRtlObserver = null;
2304
+ colRtlObserverEl = null;
2305
+ }
2306
+ function columnVirtualizerOptions(): any {
2307
+ ensureColRtlWatch();
2308
+ return {
2309
+ count: columnCount(),
2310
+ getScrollElement: () => gridScrollEl,
2311
+ estimateSize: (i: any) => columnSize(i),
2312
+ horizontal: true,
2313
+ isRtl: isColRtl(),
2314
+ observeElementRect,
2315
+ observeElementOffset,
2316
+ scrollToFn: elementScroll,
2317
+ measureElement,
2318
+ overscan: COL_OVERSCAN,
2319
+ onChange: () => {
2320
+ setWindowVer(windowVer() + 1);
2321
+ }
2322
+ };
2323
+ }
2324
+
2325
+ // ══ Phase 87 87-07 (D-15/D-18) — content-driven auto-measure: the shared engine's FIRST
2326
+ // mutable top-level state. Hoisted to `useRef` PER-INSTANCE by the React emitter's
2327
+ // hoistModuleLet — the SAME mechanism already load-bearing for `table`, `virtualizer`,
2328
+ // `remeasurePending`, and `gridScrollEl` in the DataTable host (Task 1's confirmed
2329
+ // precedent), so two DataTable instances on one page never share an accumulator
2330
+ // (T-87-07-04). measuredRowTotal/measuredRowCount together give the running MEAN of every
2331
+ // row folded in so far; lastFedRowEstimate is the estimate value most recently pushed into
2332
+ // virtual-core (the hysteresis comparison baseline). ══
2333
+ let measuredRowTotal = 0;
2334
+ let measuredRowCount = 0;
2335
+ let lastFedRowEstimate = 0;
2336
+ // foldedRowHeights: dedupe map (full-model row index -> its last-folded height) so a row
2337
+ // measured TWICE (e.g. scrolled back into view) UPDATES the running mean rather than being
2338
+ // added a second time (T-87-07-03) — a skewed mean produces a lying scrollbar, the exact
2339
+ // defect this feature exists to remove.
2340
+ let foldedRowHeights: Record<number, number> = {};
2341
+
2342
+ // ══ Gap-closure 87-10 — windowVerBumpPending / bumpWindowVer(): coalesce EVERY $data.windowVer
2343
+ // write behind a SINGLE microtask-deferred increment, regardless of how many callers request
2344
+ // one within the same synchronous JS task. ══
2345
+ //
2346
+ // ROOT CAUSE (framework-agnostic; the Solid-specific symptom this closes only EXPOSES it) —
2347
+ // confirmed by instrumenting the installed @tanstack/virtual-core@3.17.1 source directly
2348
+ // (dist/esm/index.js), not by reasoning abstractly: virtual-core's resizeItem() calls
2349
+ // `this.notify(false)` — synchronously invoking `virtualizerOptions().onChange` below — EVERY
2350
+ // TIME a measured row's real size differs from its cached one (`delta !== 0`), independent of
2351
+ // framework (dist/esm/index.js:836-874). remeasureWindow()'s CR-01 sweep
2352
+ // (packages/ui/data-table/src/virtualization.rzts) measures EVERY currently-rendered `<tr>` in
2353
+ // ONE for-loop BEFORE calling afterRowRemeasure() (refineRowEstimate() below) — so a single
2354
+ // synchronous JS task (e.g. the very first measurement pass, which transitions N never-before-
2355
+ // measured rows from the flat seed to their real heights) can fire onChange, and therefore an
2356
+ // UNCOALESCED `$data.windowVer = $data.windowVer + 1`, MANY times in a row — well BEFORE
2357
+ // refineRowEstimate()'s own fold-then-re-feed (which runs only AFTER that loop finishes) has
2358
+ // folded those same measurements into the running mean or re-fed the converged estimate into
2359
+ // virtual-core via setOptions(). A live trace of this exact sequence (instrumented resizeItem/
2360
+ // getMeasurements calls, DataTableColumnVirtualDemo, autoMeasure on) showed Vue batching 3
2361
+ // resizeItem calls before its ONE downstream re-render reads getMeasurements() — already
2362
+ // reflecting the fully-folded, re-fed state — versus Solid re-running its padTop()/padBottom()
2363
+ // effects SYNCHRONOUSLY and IMMEDIATELY on EVERY individual windowVer write (11 interleaved
2364
+ // resize-then-immediate-recompute pairs, each recompute happening mid-sweep, before
2365
+ // refineRowEstimate() had run even once). React/Vue/Svelte/Angular/Lit all batch their own
2366
+ // reactivity to at least a microtask boundary, so their downstream reads land AFTER the whole
2367
+ // synchronous burst (measurement sweep + fold + re-feed) completes — accidentally correct, not
2368
+ // correct by construction. Solid does not auto-batch a signal write made from outside a
2369
+ // Solid-owned event/effect context, so it is the one target where the mid-burst TORN read is
2370
+ // externally observable. Because `setOptions()` + `_willUpdate()` alone do NOT invalidate
2371
+ // virtual-core's own `getMeasurements()` memo (keyed on itemSizeCacheVersion /
2372
+ // getMeasurementOptions() — never on the estimateSize FUNCTION reference itself; confirmed from
2373
+ // the same installed source, dist/esm/index.js:585-587,624), Solid's LAST such mid-sweep
2374
+ // recompute is also the LAST time getMeasurements() is ever invoked for that sweep once no
2375
+ // further row happens to differ from its cache — so the DOM stays frozen on that stale,
2376
+ // pre-fold/pre-re-feed snapshot indefinitely, even though the accumulator itself has already
2377
+ // converged correctly (T-87-07's own confirmed finding).
2378
+ //
2379
+ // FIX: coalesce every requester of a windowVer bump — virtual-core's own onChange AND
2380
+ // refineRowEstimate()'s explicit re-feed bump — behind ONE microtask-deferred write, the SAME
2381
+ // idiom scheduleRemeasure() already uses in virtualization.rzts. This makes the render happen
2382
+ // EXACTLY ONCE, strictly AFTER the entire synchronous burst (including refineRowEstimate()'s
2383
+ // fold + re-feed) on EVERY target, by construction rather than by incidental host-framework
2384
+ // batching. Scoped to the ROW axis only: colVirtualizer never calls resizeItem() at all (D-06 —
2385
+ // column widths come from table-core's getSize() oracle, never measured from the DOM), so
2386
+ // columnVirtualizerOptions()'s onChange cannot hit this burst class and is left untouched.
2387
+ let windowVerBumpPending = false;
2388
+ function bumpWindowVer(): void {
2389
+ if (windowVerBumpPending) return;
2390
+ windowVerBumpPending = true;
2391
+ const flush = () => {
2392
+ windowVerBumpPending = false;
2393
+ setWindowVer(windowVer() + 1);
2394
+ };
2395
+ // Mirrors scheduleRemeasure()'s own defensive queueMicrotask-with-setTimeout-fallback
2396
+ // (virtualization.rzts) for environments where queueMicrotask is unavailable.
2397
+ if (typeof queueMicrotask !== 'undefined') queueMicrotask(flush);else setTimeout(flush, 0);
2398
+ }
2399
+
2400
+ // ESTIMATE_REFEED_DELTA_PX (D-15): the hysteresis threshold gating a re-feed into
2401
+ // virtual-core. Without it, a mean nudging by a fraction of a pixel on every fold would
2402
+ // re-feed on every window commit — the T-87-07-01 DoS control, paired with virtual-core's
2403
+ // own measureElement/resizeItem idempotence (see refineRowEstimate() below).
2404
+ const ESTIMATE_REFEED_DELTA_PX = 4;
2405
+
2406
+ // estimateRowSize(i) (D-15/D-17): the estimateSize() resolver. MUST check !autoMeasureOn()
2407
+ // FIRST so the off path touches zero accumulator state and returns $props.estimateRowHeight
2408
+ // verbatim (D-17's byte-behavioral no-op). The zero-measurements case (first paint,
2409
+ // regardless of autoMeasure) still returns the seed — the very first render has nothing
2410
+ // measured yet either way (D-15).
2411
+ function estimateRowSize(i: number): number {
2412
+ if (!autoMeasureOn()) return local.estimateRowHeight;
2413
+ if (measuredRowCount === 0) return local.estimateRowHeight;
2414
+ return Math.round(measuredRowTotal / measuredRowCount);
2415
+ }
2416
+
2417
+ // foldMeasuredRow(index, height): fold ONE measured row's height into the running-mean
2418
+ // accumulator, UPDATING (not double-adding) an already-folded index (T-87-07-03).
2419
+ function foldMeasuredRow(index: number, height: number): void {
2420
+ const prev = foldedRowHeights[index];
2421
+ if (prev === height) return;
2422
+ if (prev == null) {
2423
+ measuredRowTotal = measuredRowTotal + height;
2424
+ measuredRowCount = measuredRowCount + 1;
2425
+ } else {
2426
+ measuredRowTotal = measuredRowTotal - prev + height;
2427
+ }
2428
+ foldedRowHeights[index] = height;
2429
+ }
2430
+
2431
+ // refineRowEstimate() (D-15/D-16, Phase 87 87-07): the shared engine's post-measurement
2432
+ // orchestration, called via the host's afterRowRemeasure hook (see the HOST CONTRACT note
2433
+ // above for why this is never called directly FROM virtualization.rzts). Folds every
2434
+ // CURRENTLY RENDERED row's REAL measurement (from virtual-core's own getMeasurements() —
2435
+ // never a DOM read), then hysteresis-gates a re-feed into virtual-core, reusing the EXACT
2436
+ // `virtualizer.scrollState` bail remeasureWindow() already applies (T-87-07-02) rather than
2437
+ // inventing a second gating mechanism, and finally (Task 3, D-16) anchor-corrects scrollTop
2438
+ // around the re-feed so the row under the user's eye does not visibly lurch. Entirely a
2439
+ // no-op when !autoMeasureOn() — the off path never folds a measurement, touches
2440
+ // lastFedRowEstimate, or adjusts scrollTop (D-17).
2441
+ function refineRowEstimate(): void {
2442
+ if (!autoMeasureOn() || !virtualizer) return;
2443
+ const items = virtualizer.getVirtualItems();
2444
+ const measurements = virtualizer.getMeasurements();
2445
+ for (let i = 0; i < items.length; i++) {
2446
+ const idx = items[i].index;
2447
+ const m = measurements && measurements[idx];
2448
+ if (m) foldMeasuredRow(idx, m.size);
2449
+ }
2450
+ // T-87-07-02: never re-feed while a PROGRAMMATIC scroll is in flight — the exact bail
2451
+ // remeasureWindow() already applies (virtualizer.scrollState is non-null only during
2452
+ // scrollToIndex/scrollToOffset).
2453
+ if (virtualizer.scrollState) return;
2454
+ const est = estimateRowSize(0);
2455
+ if (Math.abs(est - lastFedRowEstimate) < ESTIMATE_REFEED_DELTA_PX) return;
2456
+ // D-16 anchor capture: the topmost rendered row's full-model index + its CURRENT offset,
2457
+ // taken from virtual-core's own measurements (never a getBoundingClientRect() layout read
2458
+ // — slower, and inconsistent with the measurements the spacer math already uses) — taken
2459
+ // BEFORE the re-feed so the correction below can compute exactly the delta the re-feed
2460
+ // itself introduces. Not restricted to scroll offset 0 (D-16 explicit): a table restored
2461
+ // mid-scroll, or whose content changes height after scrolling, must refine too.
2462
+ const anchorIndex = items.length ? items[0].index : -1;
2463
+ const anchorStart = items.length ? items[0].start : 0;
2464
+ virtualizer.setOptions(virtualizerOptions());
2465
+ virtualizer._willUpdate();
2466
+ lastFedRowEstimate = est;
2467
+ // D-16 anchor correction: read the SAME index's fresh measurement post-re-feed and adjust
2468
+ // scrollTop by the delta so the row under the user's eye stays visually put. Guarded by
2469
+ // the SAME path the re-feed itself is gated on (autoMeasureOn() && scrollState === null,
2470
+ // both already checked above), so it can never fight a programmatic scroll (T-87-07-02).
2471
+ if (anchorIndex >= 0 && gridScrollEl) {
2472
+ const freshMeasurements = virtualizer.getMeasurements();
2473
+ const fresh = freshMeasurements && freshMeasurements[anchorIndex];
2474
+ if (fresh) {
2475
+ const delta = fresh.start - anchorStart;
2476
+ if (delta !== 0) gridScrollEl.scrollTop = gridScrollEl.scrollTop + delta;
2477
+ }
2478
+ }
2479
+ // Explicit windowVer bump (87-07, Rule 1): virtual-core's own setOptions()+_willUpdate()
2480
+ // re-feed above does NOT itself invoke notify()/onChange (confirmed from the installed
2481
+ // source: _willUpdate() is a no-op once `scrollElement` is already set, dist/esm/
2482
+ // index.js:389-393), so a re-feed needs its own explicit trigger to make the spacer
2483
+ // re-derivation happen at all. Routed through bumpWindowVer() (87-10, see its own comment
2484
+ // above) rather than a raw `$data.windowVer = $data.windowVer + 1` — coalescing this bump
2485
+ // with any onChange-triggered ones already pending from the SAME synchronous sweep is what
2486
+ // makes the eventual render land strictly AFTER this fold + re-feed has fully landed, on
2487
+ // every target (the 87-10 fix for the confirmed Solid-specific rendering gap).
2488
+ bumpWindowVer();
2489
+ }
2490
+
1863
2491
  // The FULL virtualizer options. virtual-core's setOptions REPLACES options with
1864
2492
  // `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
1865
2493
  // source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
1866
2494
  // Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
1867
- // on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
1868
- // increment the React emitter lowers to functional setState — correct even from a mount closure.
2495
+ // on virtual-core's generic option inference. onChange's windowVer write is routed through
2496
+ // bumpWindowVer() (87-10) rather than a raw `$data.x = $data.x + 1` — resizeItem() can call
2497
+ // this onChange MANY times in a single synchronous sweep (once per row whose real measured
2498
+ // size differs from its cache, e.g. every never-before-measured row in the FIRST window),
2499
+ // and coalescing those into one microtask-deferred write is what keeps every target's render
2500
+ // landing strictly AFTER the whole sweep (see bumpWindowVer()'s own comment for the confirmed
2501
+ // Solid-specific rendering gap this closes). The React emitter still lowers the underlying
2502
+ // `$data.windowVer = $data.windowVer + 1` to functional setState — correct even deferred to a
2503
+ // microtask, exactly as it was correct from a mount closure before.
1869
2504
  function virtualizerOptions(): any {
1870
2505
  return {
1871
2506
  count: windowSource().length,
1872
2507
  getScrollElement: () => gridScrollEl,
1873
- estimateSize: () => local.estimateRowHeight,
2508
+ estimateSize: (i: any) => estimateRowSize(i),
1874
2509
  observeElementRect,
1875
2510
  observeElementOffset,
1876
2511
  scrollToFn: elementScroll,
@@ -1878,7 +2513,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1878
2513
  overscan: 8,
1879
2514
  getItemKey: virtualItemKey,
1880
2515
  onChange: () => {
1881
- setWindowVer(windowVer() + 1);
2516
+ bumpWindowVer();
1882
2517
  // CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
1883
2518
  // virtual-core only observe()s a node you explicitly hand to measureElement (it does
1884
2519
  // NOT auto-discover rendered rows — measureElement is the SOLE caller of
@@ -1933,14 +2568,23 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1933
2568
  void windowVer();
1934
2569
  void editVer();
1935
2570
  if (!virtualizer) {
1936
- // Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
1937
- // but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
1938
- // the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
1939
- // rows appear on the first onChange after _didMount.
1940
- if (!local.virtual) {
2571
+ // Rows OFF (Phase 87 D-04: this now includes the colsWindowed()-only path, since the
2572
+ // wrapper template is entered whenever isWindowed(), not just rowsWindowed() — the row
2573
+ // virtualizer is never constructed when only the column axis is windowed, D-04) → the FULL
2574
+ // set, with a SYNTHETIC `vi.index` set to each row's array position (matching rowIndexOf's
2575
+ // own `$data.rows.indexOf(row)` semantics exactly, since $data.rows IS windowSource()'s
2576
+ // output here). Every windowed body binding reads wr.vi.index (data-row, aria-rowindex,
2577
+ // colIndexOf, isEditing, the fill handle) — a bare `null` there is a hard crash the moment
2578
+ // this branch is reached with the wrapper mounted, which colsWindowed()-only now does.
2579
+ // Row-virtual ON but the virtualizer is not yet constructed (pre-$onMount first paint) →
2580
+ // render NOTHING so the template never dereferences a not-yet-real `vi`; the rows appear on
2581
+ // the first onChange after _didMount.
2582
+ if (!rowsWindowed()) {
1941
2583
  const rowList = rows() || [];
1942
- return rowList.map((r: any) => ({
1943
- vi: null,
2584
+ return rowList.map((r: any, i: any) => ({
2585
+ vi: {
2586
+ index: i
2587
+ },
1944
2588
  row: r
1945
2589
  }));
1946
2590
  }
@@ -1997,7 +2641,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
1997
2641
  // and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
1998
2642
  void windowVer();
1999
2643
  void editVer();
2000
- if (!local.virtual || !virtualizer) return 0;
2644
+ if (!rowsWindowed() || !virtualizer) return 0;
2001
2645
  const items = virtualizer.getVirtualItems();
2002
2646
  let pad = items.length ? items[0].start : 0;
2003
2647
  // D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
@@ -2017,7 +2661,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
2017
2661
  // on pin/unpin.
2018
2662
  void windowVer();
2019
2663
  void editVer();
2020
- if (!local.virtual || !virtualizer) return 0;
2664
+ if (!rowsWindowed() || !virtualizer) return 0;
2021
2665
  const items = virtualizer.getVirtualItems();
2022
2666
  if (!items.length) return 0;
2023
2667
  let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
@@ -2049,12 +2693,140 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
2049
2693
  // rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
2050
2694
  // window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
2051
2695
  function rowIsOutsideWindow(r: any) {
2052
- if (!local.virtual || !virtualizer) return false;
2696
+ if (!rowsWindowed() || !virtualizer) return false;
2053
2697
  const items = virtualizer.getVirtualItems();
2054
2698
  for (const it of items as any) if (it.index === r) return false;
2055
2699
  return true;
2056
2700
  }
2057
2701
 
2702
+ // ══ Phase 87 87-04 — the column-axis analogs of windowedRows()/padTop()/padBottom()/
2703
+ // rowIsOutsideWindow() above. The column axis has no "row-shaped" identity to carry alongside
2704
+ // a VirtualItem (a column is not a full-model object the way a row is), so windowedColIndices()
2705
+ // returns bare ABSOLUTE leaf-column indices; the template resolves each index back to a header/
2706
+ // cell through the host's own header-group / visibleCellsFor lookups (D-08/D-09). ══
2707
+
2708
+ // windowedColIndices(): the ordered array of ABSOLUTE leaf-column indices to render.
2709
+ function windowedColIndices() {
2710
+ // SUBSCRIBE FIRST (the windowedRows() discipline): colVirtualizer is a non-reactive `let`,
2711
+ // null at first render, so a $data.windowVer read placed BELOW the guards below would mean
2712
+ // Solid's <For> / Svelte's {#each} accessors never subscribe — the column window would stay
2713
+ // blank forever on exactly those two targets. Also subscribe to editVer (mirrors the row
2714
+ // axis's pin/unpin re-derive — the D-10 forced-column union lands on top of this in 87-05).
2715
+ void windowVer();
2716
+ void editVer();
2717
+ if (!colsWindowed()) {
2718
+ // Columns OFF (or rows-only windowed) → every absolute index, the degrade-to-full-set
2719
+ // path that keeps the caller (windowedCells / the header loop) axis-agnostic.
2720
+ const n = columnCount();
2721
+ const out = [];
2722
+ for (let i = 0; i < n; i++) out.push(i);
2723
+ return out;
2724
+ }
2725
+ // Columns windowed but the column virtualizer is not yet constructed (pre-$onMount first
2726
+ // paint) → render NOTHING, matching windowedRows()'s pre-mount behavior.
2727
+ if (!colVirtualizer) return [];
2728
+ const items = colVirtualizer.getVirtualItems();
2729
+ const idx = items.map((it: any) => it.index);
2730
+ // D-10 forced-column union (host hook — empty array until 87-05 gives it a real body):
2731
+ // dedupe-then-append any forced index not already in the virtualized window.
2732
+ const forced = forcedColumns();
2733
+ for (let i = 0; i < forced.length; i++) {
2734
+ if (idx.indexOf(forced[i]) === -1) idx.push(forced[i]);
2735
+ }
2736
+ // Sorted ascending: the body <td> sequence and the header <th> sequence both follow visual
2737
+ // left-to-right order, and the virtual items + forced union are not guaranteed sorted.
2738
+ idx.sort((a: any, b: any) => a - b);
2739
+ return idx;
2740
+ }
2741
+
2742
+ // colPadLeft() / colPadRight() (D-06): the horizontal spacer <td>/<th> widths — the leading
2743
+ // spacer occupies items[0].start, the trailing spacer the gap between the last rendered
2744
+ // item's end and getTotalSize(). Mirrors padTop()/padBottom() exactly, INCLUDING the D-02-style
2745
+ // pin subtraction (87-05): forcedColumns() can return MULTIPLE forced indices (pinned + active +
2746
+ // editing, not just one pin), so each forced index outside the virtual window is checked
2747
+ // individually. A forced column rendered outside the window contributes its OWN real width to
2748
+ // the row (via its <th>/<td>'s own inline width under table-layout:fixed), so that width must
2749
+ // come OUT of the spacer or the row's total overshoots getTotalSize() and the horizontal
2750
+ // scrollbar lengthens on every such scroll. WR-01: decide before/after the window by comparing
2751
+ // the forced INDEX against the window's first/last item INDEX, never by start-OFFSET —
2752
+ // measurement drift makes the offset comparison wrong for the variable-width case (mirrors
2753
+ // padBottom()'s existing WR-01 discipline). A forced column's width is columnSize(i) (the
2754
+ // table-core size oracle, D-06's authoritative source), never a DOM measurement.
2755
+ function colPadLeft() {
2756
+ void windowVer();
2757
+ void editVer();
2758
+ if (!colsWindowed() || !colVirtualizer) return 0;
2759
+ const items = colVirtualizer.getVirtualItems();
2760
+ let pad = items.length ? items[0].start : 0;
2761
+ if (items.length) {
2762
+ const firstIdx = items[0].index;
2763
+ const forced = forcedColumns();
2764
+ for (let i = 0; i < forced.length; i++) {
2765
+ if (forced[i] < firstIdx) pad = pad - columnSize(forced[i]);
2766
+ }
2767
+ }
2768
+ return pad < 0 ? 0 : pad;
2769
+ }
2770
+ function colPadRight() {
2771
+ void windowVer();
2772
+ void editVer();
2773
+ if (!colsWindowed() || !colVirtualizer) return 0;
2774
+ const items = colVirtualizer.getVirtualItems();
2775
+ if (!items.length) return 0;
2776
+ let pad = colVirtualizer.getTotalSize() - items[items.length - 1].end;
2777
+ const lastIdx = items[items.length - 1].index;
2778
+ const forced = forcedColumns();
2779
+ for (let i = 0; i < forced.length; i++) {
2780
+ if (forced[i] > lastIdx) pad = pad - columnSize(forced[i]);
2781
+ }
2782
+ return pad < 0 ? 0 : pad;
2783
+ }
2784
+ // colIsOutsideWindow(c): is the absolute leaf-column index c absent from the currently
2785
+ // rendered column window? The column-axis analog of rowIsOutsideWindow(r) (D-12's
2786
+ // scroll-then-focus seam, wired starting 87-06). Gated on colsWindowed() (never a bare prop
2787
+ // truthiness read — the 87-02 prohibition gate).
2788
+ function colIsOutsideWindow(c: any) {
2789
+ if (!colsWindowed() || !colVirtualizer) return false;
2790
+ const items = colVirtualizer.getVirtualItems();
2791
+ for (const it of items as any) if (it.index === c) return false;
2792
+ return true;
2793
+ }
2794
+
2795
+ // afterRowRemeasure (Phase 87 87-07, D-15): a mutable-`let` indirection so
2796
+ // virtualization.rzts's remeasureWindow() (a relative-partial CONST, textually EARLIER
2797
+ // than windowing.rzts in the merged per-target output) can trigger the shared engine's
2798
+ // post-measurement fold + hysteresis re-feed (refineRowEstimate(), windowing.rzts) without
2799
+ // calling a bare-specifier-partial CONST directly — the exact forward-reference TDZ class
2800
+ // remeasureColumnWindow()'s own comment below documents for columnVirtualizerOptions(). A
2801
+ // mutable `let` hoists to `useRef` on React and never enters a useCallback's dependency
2802
+ // array, sidestepping the hazard there.
2803
+ //
2804
+ // DECLARED HERE — a plain top-level statement placed AFTER both import lines above, NOT
2805
+ // grouped with the other windowing `let`s near the top of this file, and NOT deferred into
2806
+ // $onMount. Two Rule-1 bugs were found and fixed in this exact spot this task, both via the
2807
+ // VR suite:
2808
+ // (a) A top-level `let X = refineRowEstimate` placed BEFORE the import statements (where
2809
+ // the other windowing `let`s live) reads a windowing.rzts symbol at its OWN
2810
+ // declaration's evaluation time. React's own emitter re-groups DataTable.rozie's
2811
+ // entire top-level script AFTER both inlined partials regardless of source position,
2812
+ // so it tolerated that placement — but Vue/Svelte/Solid/Angular/Lit splice each
2813
+ // partial's content at its IMPORT STATEMENT's source position, so a `let` positioned
2814
+ // BEFORE the import genuinely executes before `refineRowEstimate` exists:
2815
+ // `ReferenceError: Cannot access 'refineRowEstimate' before initialization`. Moving the
2816
+ // declaration to AFTER the windowing.rzts import fixes every non-React target — the
2817
+ // splice has already run by this point in the source.
2818
+ // (b) Deferring the assignment into $onMount instead (`afterRowRemeasure = refineRowEstimate`
2819
+ // inside the mount body, mirroring `refreshRowModel`) fixed (a) but broke REACT: it
2820
+ // triggered the react emitter's mount-referenced-helper stable-ref synthesis for
2821
+ // `refineRowEstimate` (a NEW code path for a plain script helper, not the `$computed`
2822
+ // case that mechanism was built for), producing a genuine
2823
+ // `ReferenceError: Cannot access '...' before initialization` in the component's
2824
+ // render body. Reverted; the emitter path itself is out of this plan's scope.
2825
+ // This placement (top-level, immediately after the windowing.rzts import) is therefore the
2826
+ // ONE spot that is simultaneously safe for React (unaffected by source position) and every
2827
+ // other target (past the import-position splice).
2828
+ let afterRowRemeasure = refineRowEstimate;
2829
+
2058
2830
  // ── Sort/filter live-announcement (#14) ─────────────────────────────────────────────
2059
2831
  // A polite aria-live announcement whenever the consumer changes sorting or filtering, so a
2060
2832
  // screen-reader user hears that the rows were reordered / narrowed (which is otherwise silent).
@@ -2822,7 +3594,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
2822
3594
  // or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
2823
3595
  function getRowIndexRelativeToPage(absRow: any) {
2824
3596
  const abs = absRow == null ? toAbsRow(activeRow()) : Math.trunc(Number(absRow)) || 0;
2825
- if (local.virtual) return abs;
3597
+ if (rowsWindowed()) return abs;
2826
3598
  return abs - pageRowOffset();
2827
3599
  }
2828
3600
 
@@ -2843,12 +3615,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
2843
3615
  // DataTable.rozie) — so DataTable.rozie's $expose references them BY NAME with zero
2844
3616
  // indirection. This file stays the seam for verbs that need a rename/adapter, not a mandatory
2845
3617
  // stop for every $expose entry.
2846
- // ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
2847
- // This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
2848
- // the active-cell index-pair state, the data-* cell markers, and the SINGLE
2849
- // focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
2850
- // (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.
2851
-
2852
3618
  // interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
2853
3619
  // identical to phase 48 (roles fall back to the literals, tabindex drops).
2854
3620
  function isGrid() {
@@ -2893,7 +3659,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
2893
3659
  // isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
2894
3660
  // state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
2895
3661
  function pageRowOffset() {
2896
- if (!isGrid() || local.virtual) return 0;
3662
+ if (!isGrid() || rowsWindowed()) return 0;
2897
3663
  return pageIndex() * pageSize();
2898
3664
  }
2899
3665
  // page-relative active row → absolute (display-order) index.
@@ -2908,7 +3674,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
2908
3674
  // In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
2909
3675
  // non-virtual paginated body $data.rows is only the page slice, so read the live model.
2910
3676
  function prePaginationRowCount() {
2911
- if (!table || local.virtual) return bodyRowCount();
3677
+ if (!table || rowsWindowed()) return bodyRowCount();
2912
3678
  const pm = table.getPrePaginationRowModel();
2913
3679
  return pm && pm.rows ? pm.rows.length : bodyRowCount();
2914
3680
  }
@@ -3014,24 +3780,37 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
3014
3780
  // here returns the PRE-write value, resolving focus to the BODY cell instead of the
3015
3781
  // header. Callers pass the fresh isHeader local; falls back to $data when omitted.
3016
3782
  const header = nextIsHeader == null ? activeIsHeader() : nextIsHeader;
3017
- // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
3018
- // rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
3019
- // double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
3020
- // in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
3021
- // The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
3022
- // while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
3023
- if (local.virtual && virtualizer && !header && rowIsOutsideWindow(r)) {
3024
- virtualizer.scrollToIndex(r, {
3783
+ // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row and/or column
3784
+ // is OUTSIDE the rendered window, scroll it in first, then defer focus to AFTER the new
3785
+ // window commits (the double-rAF — a single rAF can fire before React's async commit, Pitfall
3786
+ // 4). Header cells and fully in-window cells keep the synchronous path below (table-mode /
3787
+ // non-windowed stay byte-stable). The guard reads the resolved `header` (NOT the raw
3788
+ // `nextIsHeader`) so an omitted-arg call while a header cell is active falls back to
3789
+ // $data.activeIsHeader and skips the scroll path.
3790
+ // Phase 87 87-06 (D-12): widened to the SECOND axis — colIsOutsideWindow(c)/colsWindowed()
3791
+ // mirror rowIsOutsideWindow(r)/rowsWindowed() exactly (both landed in windowing.rzts in
3792
+ // 87-04). Either axis being out triggers the scroll+poll; both scrollToIndex calls fire when
3793
+ // both are out, and the ONE existing poll below is reused unchanged — it already resolves the
3794
+ // target cell by BOTH (row, col) via resolveCellEl(String(r), c), so a cell that becomes
3795
+ // reachable via the column axis needs no new poll logic (per the plan's own key_links note).
3796
+ const rowOut = rowsWindowed() && virtualizer && rowIsOutsideWindow(r);
3797
+ const colOut = colsWindowed() && colVirtualizer && colIsOutsideWindow(c);
3798
+ if (!header && (rowOut || colOut)) {
3799
+ if (rowOut) virtualizer.scrollToIndex(r, {
3800
+ align: 'center'
3801
+ });
3802
+ if (colOut) colVirtualizer.scrollToIndex(c, {
3025
3803
  align: 'center'
3026
3804
  });
3027
3805
  // Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
3028
- // bump → the framework commits the scrolled-in row. On React that commit is async (setState →
3029
- // reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
3030
- // fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
3031
- // React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
3032
- // fast-committing targets resolve on the first attempt (behavior unchanged), React retries
3033
- // across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
3034
- // cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
3806
+ // bump → the framework commits the scrolled-in row/column. On React that commit is async
3807
+ // (setState → reconcile) and for a far scroll (e.g. row 4000, or col 55 on a 60-col table)
3808
+ // spans several frames — a one-shot double-rAF fires BEFORE resolveCellEl can find the cell,
3809
+ // so focus is silently lost (the deterministic React off-window-focus failure). Poll
3810
+ // resolveCellEl for up to ~30 frames: the five fast-committing targets resolve on the first
3811
+ // attempt (behavior unchanged), React retries across the few frames its async commit needs.
3812
+ // The poll ONLY focuses (never measures), so it cannot re-introduce the remeasure-vs-scroll
3813
+ // fight. Inside the rowOut||colOut guard only.
3035
3814
  let focusAttempts = 0;
3036
3815
  // #9: capture the epoch AFTER this call's own bump (above) so the poll never aborts itself
3037
3816
  // (its captured value equals the current epoch). A LATER focusActiveCell / focusCell /
@@ -3129,9 +3908,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
3129
3908
  function bodyRowCount() {
3130
3909
  return (rows() || []).length;
3131
3910
  }
3132
- function clamp(v: any, lo: any, hi: any) {
3133
- return v < lo ? lo : v > hi ? hi : v;
3134
- }
3135
3911
 
3136
3912
  // ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
3137
3913
  // $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
@@ -3371,13 +4147,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
3371
4147
  return resolveCellEl(rowKey, activeColIndex(), activeIsHeader() ? activeHeaderLevel() : null);
3372
4148
  }
3373
4149
 
3374
- // The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
3375
- function focusables(cellEl: any) {
3376
- if (!cellEl || !cellEl.querySelectorAll) return [];
3377
- const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
3378
- return list.filter((n: any) => !n.disabled);
3379
- }
3380
-
3381
4150
  // Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
3382
4151
  // (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
3383
4152
  // No-op (stay in navigation mode) if the cell has no focusable control.
@@ -4087,6 +4856,75 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4087
4856
  }
4088
4857
  }
4089
4858
 
4859
+ // ══ Phase 87 87-04/87-05 — column-axis template windowing (D-06/D-11; declared HERE (after
4860
+ // the columnChrome/gridFocusNav imports above) so a forward cross-partial reference to
4861
+ // colsWindowed()/windowedColIndices() in the merged per-target output never TDZs on React —
4862
+ // the 87-02 useCallback-deps-array ordering lesson, the SAME reason remeasureColumnWindow()
4863
+ // below stays out of virtualization.rzts). ══
4864
+ // windowedHeadersFor(hg, hgLevel) (87-05): generalizes 87-04's windowedHeaderRow() (leaf-level
4865
+ // only) to EVERY header level with a CLAMPED colspan (D-11). A header spans the CONTIGUOUS run
4866
+ // of leaf-column indices [cursor, cursor + header.colSpan) — table-core lays out every header
4867
+ // level's `headers` array left-to-right over the SAME full leaf-column order, so walking a
4868
+ // running cursor by each header's OWN colSpan reconstructs its absolute leaf range with no
4869
+ // extra table-core calls. Intersect that range with windowedColIndices() (which already unions
4870
+ // the D-10 forced set): a header whose intersection is EMPTY is dropped entirely (a group
4871
+ // wholly outside the window renders no <th> at all — D-11); otherwise it is emitted as
4872
+ // { header, span } with span = the intersection SIZE (the clamp). Degrades to hg.headers
4873
+ // UNCHANGED (span = the header's own colSpan, or 1) when columns are not windowed — the
4874
+ // off-path the template's `:colspan="wh.span > 1 ? wh.span : null"` binding preserves
4875
+ // byte-identically (a bare colspan="1" would change the emitted markup on the unwindowed path).
4876
+ function windowedHeadersFor(hg: any, hgLevel: any) {
4877
+ const headers = hg && hg.headers || [];
4878
+ if (!colsWindowed()) {
4879
+ return headers.map((h: any) => ({
4880
+ header: h,
4881
+ span: h && h.colSpan > 1 ? h.colSpan : 1
4882
+ }));
4883
+ }
4884
+ const idx = windowedColIndices();
4885
+ const idxSet = {};
4886
+ for (let i = 0; i < idx.length; i++) idxSet[idx[i]] = true;
4887
+ let cursor = 0;
4888
+ const out = [];
4889
+ for (let i = 0; i < headers.length; i++) {
4890
+ const h = headers[i];
4891
+ const leafCount = h && h.colSpan ? h.colSpan : 1;
4892
+ const start = cursor;
4893
+ const end = cursor + leafCount;
4894
+ cursor = end;
4895
+ let span = 0;
4896
+ for (let c = start; c < end; c++) if (idxSet[c]) span = span + 1;
4897
+ if (span > 0) out.push({
4898
+ header: h,
4899
+ span
4900
+ });
4901
+ }
4902
+ return out;
4903
+ }
4904
+ // windowedColSpan(): the body row's rendered <td> COUNT under column windowing — the windowed
4905
+ // cell slice length plus the two rdt-col-spacer cells, so the leading/trailing full-row spacer
4906
+ // <tr>s and the #detail <tr> span the SAME column count the body row actually renders (D-06/
4907
+ // D-11's alignment obligation under table-layout:fixed). Off → visibleColCount() unchanged
4908
+ // (byte-identical).
4909
+ function windowedColSpan() {
4910
+ return colsWindowed() ? windowedColIndices().length + 2 : visibleColCount();
4911
+ }
4912
+ // remeasureColumnWindow() (Phase 87 87-04): push a fresh column count/size into the SECOND
4913
+ // (horizontal) virtualizer on every row-model refresh — mirrors the row axis's
4914
+ // virtualizer.setOptions(virtualizerOptions()) + _willUpdate() re-feed in refreshRowModel, so a
4915
+ // runtime column-visibility/order/pinning change (all watched in the $watch list below) keeps
4916
+ // the column virtualizer's count in sync. Never a render helper (Pitfall 1); rides the EXISTING
4917
+ // refreshRowModel imperative re-feed cycle rather than adding a third scheduling path. Declared
4918
+ // HERE (DataTable.rozie's own script), not in virtualization.rzts — see that file's comment for
4919
+ // why: it must call columnVirtualizerOptions() (windowing.rzts), and a function inside
4920
+ // virtualization.rzts calling into windowing.rzts is a forward-reference TDZ on React (the
4921
+ // 87-02 predicate-layer lesson, one file-grouping boundary over).
4922
+ function remeasureColumnWindow() {
4923
+ if (!colsWindowed() || !colVirtualizer) return;
4924
+ colVirtualizer.setOptions(columnVirtualizerOptions());
4925
+ colVirtualizer._willUpdate();
4926
+ }
4927
+
4090
4928
  // B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
4091
4929
  // flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
4092
4930
  // plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
@@ -4353,15 +5191,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4353
5191
  }
4354
5192
  }
4355
5193
 
4356
- // ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
4357
- // The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
4358
- // TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
4359
- // N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
4360
- // ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
4361
- // cell is written as plain string DATA through the per-column validator and rendered via the
4362
- // SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
4363
- // cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).
4364
-
4365
5194
  // announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
4366
5195
  // pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
4367
5196
  function announce(msg: any) {
@@ -4411,17 +5240,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4411
5240
  };
4412
5241
  }
4413
5242
 
4414
- // B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
4415
- // or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
4416
- // field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
4417
- // newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
4418
- function escapeTsvField(s: any) {
4419
- if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
4420
- return '"' + s.replace(/"/g, '""') + '"';
4421
- }
4422
- return s;
4423
- }
4424
-
4425
5243
  // rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
4426
5244
  // reading each cell's value off the visible model by index (cellValueAt). A single active
4427
5245
  // cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
@@ -4443,84 +5261,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4443
5261
  return lines.join('\n');
4444
5262
  }
4445
5263
 
4446
- // parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
4447
- // newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
4448
- // the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
4449
- function parseTsv(text: any) {
4450
- const str = text != null ? String(text) : '';
4451
- // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
4452
- // large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
4453
- // single-pass scan allocates a cell-per-character grid.
4454
- if (str === '' || str.length > 2000000) return [];
4455
- // B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
4456
- // cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
4457
- // newlines, and doubled quotes ("") inside it are literal content until the closing quote;
4458
- // an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
4459
- const rows = [];
4460
- let row = [];
4461
- let field = '';
4462
- let inQuotes = false;
4463
- let i = 0;
4464
- const n = str.length;
4465
- while (i < n) {
4466
- const ch = str[i];
4467
- if (inQuotes) {
4468
- if (ch === '"') {
4469
- if (i + 1 < n && str[i + 1] === '"') {
4470
- field = field + '"';
4471
- i = i + 2;
4472
- continue;
4473
- }
4474
- inQuotes = false;
4475
- i = i + 1;
4476
- continue;
4477
- }
4478
- field = field + ch;
4479
- i = i + 1;
4480
- continue;
4481
- }
4482
- if (ch === '"' && field === '') {
4483
- inQuotes = true;
4484
- i = i + 1;
4485
- continue;
4486
- }
4487
- if (ch === '\t') {
4488
- row.push(field);
4489
- field = '';
4490
- i = i + 1;
4491
- continue;
4492
- }
4493
- if (ch === '\r') {
4494
- if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
4495
- row.push(field);
4496
- field = '';
4497
- rows.push(row);
4498
- row = [];
4499
- i = i + 1;
4500
- continue;
4501
- }
4502
- if (ch === '\n') {
4503
- row.push(field);
4504
- field = '';
4505
- rows.push(row);
4506
- row = [];
4507
- i = i + 1;
4508
- continue;
4509
- }
4510
- field = field + ch;
4511
- i = i + 1;
4512
- }
4513
- // Flush the trailing field + row.
4514
- row.push(field);
4515
- rows.push(row);
4516
- // Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
4517
- if (rows.length > 1) {
4518
- const last = rows[rows.length - 1];
4519
- if (last.length === 1 && last[0] === '') rows.pop();
4520
- }
4521
- return rows;
4522
- }
4523
-
4524
5264
  // copyRange(): write the current range as TSV to the clipboard (async). No-op when the
4525
5265
  // async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
4526
5266
  function copyRange() {
@@ -4613,43 +5353,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4613
5353
  return row ? row.id : null;
4614
5354
  }
4615
5355
 
4616
- // C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
4617
- // paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
4618
- // dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
4619
- // the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
4620
- // clipboard LARGER than the selection pastes its full block from the top-left (preserving the
4621
- // no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
4622
- // yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
4623
- // nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
4624
- function tileGridToBox(grid: any, box: any) {
4625
- const srcRows = grid.length;
4626
- // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
4627
- // (a later row WIDER than the first, e.g. TSV "a\tb\nc\td\te") would otherwise never read
4628
- // the extra column and silently drop those cells. A row SHORTER than srcCols tiles its
4629
- // missing cells as '' (the `v != null ? v : ''` coercion below), never undefined.
4630
- let srcCols = 0;
4631
- for (let i = 0; i < srcRows; i++) {
4632
- const w = grid[i] && grid[i].length ? grid[i].length : 0;
4633
- if (w > srcCols) srcCols = w;
4634
- }
4635
- if (srcRows <= 0 || srcCols <= 0) return grid;
4636
- const boxRows = box.r1 - box.r0 + 1;
4637
- const boxCols = box.c1 - box.c0 + 1;
4638
- const rows = boxRows > srcRows ? boxRows : srcRows;
4639
- const cols = boxCols > srcCols ? boxCols : srcCols;
4640
- const out = [];
4641
- for (let r = 0; r < rows; r++) {
4642
- const srcLine = grid[r % srcRows] || [];
4643
- const line = [];
4644
- for (let c = 0; c < cols; c++) {
4645
- const v = srcLine[c % srcCols];
4646
- line.push(v != null ? v : '');
4647
- }
4648
- out.push(line);
4649
- }
4650
- return out;
4651
- }
4652
-
4653
5356
  // pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
4654
5357
  // (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
4655
5358
  // clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
@@ -4750,18 +5453,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4750
5453
  applyGridToRange(grid, r0, c0);
4751
5454
  }
4752
5455
 
4753
- // tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
4754
- // the source block), handling indices below lo (negative offset) correctly. A 1-wide source
4755
- // (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
4756
- // cell it copies — so each column copies its OWN source value down its OWN column.
4757
- function tileIndex(i: any, lo: any, hi: any) {
4758
- const span = hi - lo + 1;
4759
- if (span <= 1) return lo;
4760
- let k = (i - lo) % span;
4761
- if (k < 0) k = k + span;
4762
- return lo + k;
4763
- }
4764
-
4765
5456
  // fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
4766
5457
  // SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
4767
5458
  // the range); each target cell copies the source cell in its OWN column (and row, when the
@@ -4827,6 +5518,49 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4827
5518
  // but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
4828
5519
  let fillDragMove: any = null;
4829
5520
  let fillDragUp: any = null;
5521
+ // Phase 87 87-06 (D-13, T-87-06-02): the single rAF handle driving the fill-drag per-axis
5522
+ // edge auto-scroll — a module-`let` beside fillDragMove/fillDragUp (the `let table` precedent;
5523
+ // React hoists to useRef), cleared IDEMPOTENTLY in teardownFillDrag() so an interrupted gesture
5524
+ // never leaves an orphaned scroll loop running forever.
5525
+ let fillEdgeScrollRaf: any = null;
5526
+ // Phase 87 87-06 (D-13): per-axis edge-triggered auto-scroll for the fill drag.
5527
+ // FILL_EDGE_SCROLL_PX — the pixel distance from the scroll container's edge, on either axis,
5528
+ // that engages auto-scroll. 24px: comfortably wider than the fill handle's own hit target but
5529
+ // well inside a typical row/column size, so the gesture recognizably nears an edge before
5530
+ // scrolling engages (not so wide that an ordinary drag near the middle of the viewport
5531
+ // spuriously triggers it).
5532
+ const FILL_EDGE_SCROLL_PX = 24;
5533
+ // FILL_EDGE_SCROLL_STEP — pixels scrolled per animation frame while the pointer sits inside an
5534
+ // edge zone. A fixed step (not distance-proportional) keeps the scroll speed predictable and
5535
+ // the WR-01-style spacer/window math simple to reason about frame-to-frame.
5536
+ const FILL_EDGE_SCROLL_STEP = 16;
5537
+
5538
+ // edgeDelta(clientX, clientY): the per-frame (dx, dy) scroll delta for a pointer at the given
5539
+ // screen position, relative to the scroll container `gridScrollEl` already observed by the row
5540
+ // AND column virtualizers (D-03 — one shared `.rdt-scroll` wrapper for whichever axis/axes are
5541
+ // windowed). Each axis is independently gated on colsWindowed()/rowsWindowed() (never a bare
5542
+ // prop truthiness read — the 87-02 prohibition gate) so a drag near the left/right edge of a
5543
+ // row-only-windowed table (or vice versa) never scrolls an axis that has nothing to reveal.
5544
+ // Pure — reads no gesture state, only the live container rect + the windowing predicates.
5545
+ function edgeDelta(clientX: any, clientY: any) {
5546
+ if (!gridScrollEl) return {
5547
+ dx: 0,
5548
+ dy: 0
5549
+ };
5550
+ const rect = gridScrollEl.getBoundingClientRect();
5551
+ let dx = 0;
5552
+ let dy = 0;
5553
+ if (colsWindowed()) {
5554
+ if (clientX - rect.left < FILL_EDGE_SCROLL_PX) dx = -FILL_EDGE_SCROLL_STEP;else if (rect.right - clientX < FILL_EDGE_SCROLL_PX) dx = FILL_EDGE_SCROLL_STEP;
5555
+ }
5556
+ if (rowsWindowed()) {
5557
+ if (clientY - rect.top < FILL_EDGE_SCROLL_PX) dy = -FILL_EDGE_SCROLL_STEP;else if (rect.bottom - clientY < FILL_EDGE_SCROLL_PX) dy = FILL_EDGE_SCROLL_STEP;
5558
+ }
5559
+ return {
5560
+ dx,
5561
+ dy
5562
+ };
5563
+ }
4830
5564
  function teardownFillDrag() {
4831
5565
  if (typeof document !== 'undefined') {
4832
5566
  if (fillDragMove) document.removeEventListener('pointermove', fillDragMove);
@@ -4835,6 +5569,15 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4835
5569
  fillDragMove = null;
4836
5570
  fillDragUp = null;
4837
5571
  fillDragging = false;
5572
+ // D-13 (T-87-06-02): idempotent cleanup of the edge-auto-scroll rAF loop — the SAME discipline
5573
+ // fillDragMove/fillDragUp already follow. Called both on a normal pointerup release AND at the
5574
+ // TOP of the next onFillHandlePointerDown (the #leak guard below), so an interrupted gesture
5575
+ // (pointer released off-window, context menu, alt-tab) can never leave a stray rAF scrolling
5576
+ // the table forever.
5577
+ if (fillEdgeScrollRaf != null) {
5578
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(fillEdgeScrollRaf);
5579
+ fillEdgeScrollRaf = null;
5580
+ }
4838
5581
  }
4839
5582
  function cellIndexFromPoint(clientX: any, clientY: any) {
4840
5583
  if (typeof document === 'undefined' || !document.elementFromPoint) return null;
@@ -4870,7 +5613,8 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4870
5613
  // pointerup was missed (pointer released off-window, context menu, alt-tab), the prior fillDrag's
4871
5614
  // document pointermove/pointerup stay attached; overwriting fillDragMove/fillDragUp below would
4872
5615
  // strand them (removeEventListener could never reach the old refs) → a permanent global
4873
- // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached).
5616
+ // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached) — it ALSO
5617
+ // cancels any orphaned edge-auto-scroll rAF from the same prior gesture.
4874
5618
  teardownFillDrag();
4875
5619
  fillDragging = true;
4876
5620
  // B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
@@ -4884,21 +5628,74 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4884
5628
  r: sourceBox.r1,
4885
5629
  c: sourceBox.c1
4886
5630
  } : null;
5631
+ // D-13: the pointer's LAST known screen position, updated on every real pointermove AND read
5632
+ // by the rAF loop below on every frame. The scroll loop must re-resolve the cell under this
5633
+ // FIXED screen point each frame — the pointer itself may not move at all while the user simply
5634
+ // holds it at the edge, but the content sliding beneath it means a DIFFERENT cell sits there
5635
+ // each frame, which is what must extend the range (this is the whole point of "edge-triggered
5636
+ // auto-scroll": a real pointermove would never fire on a stationary pointer).
5637
+ let lastClientX = 0;
5638
+ let lastClientY = 0;
5639
+ // applyPointAt: resolve the cell under (clientX, clientY) and extend the range to it if it is
5640
+ // a NEW cell (B20 dedup — unchanged from the pre-87-06 shape). Shared by both a real
5641
+ // pointermove and every frame of the edge-auto-scroll loop.
5642
+ const applyPointAt = (clientX: any, clientY: any) => {
5643
+ const cell = cellIndexFromPoint(clientX, clientY);
5644
+ if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
5645
+ lastCell = cell;
5646
+ setRangeFocus$local(cell.r, cell.c);
5647
+ }
5648
+ };
5649
+ // scrollStep: the single rAF loop (T-87-06-02) — reads the LAST known pointer position each
5650
+ // frame, scrolls the windowed axis/axes still within the edge threshold, then re-resolves the
5651
+ // cell now under that same screen point. Self-terminates (never reschedules) the moment the
5652
+ // gesture ends OR the pointer no longer sits in an edge zone — so leaving the edge, or a
5653
+ // pointerup, stops the scroll immediately with no lingering frame.
5654
+ const scrollStep = () => {
5655
+ if (!fillDragging) {
5656
+ fillEdgeScrollRaf = null;
5657
+ return;
5658
+ }
5659
+ const d = edgeDelta(lastClientX, lastClientY);
5660
+ if (d.dx === 0 && d.dy === 0) {
5661
+ fillEdgeScrollRaf = null;
5662
+ return;
5663
+ }
5664
+ if (gridScrollEl) {
5665
+ // T-87-06-03: direct scrollLeft/scrollTop writes — NEVER virtualizer.scrollToOffset /
5666
+ // colVirtualizer.scrollToOffset. A programmatic virtual-core scroll sets
5667
+ // virtualizer.scrollState, which remeasureWindow() bails on for as long as it is non-null;
5668
+ // a SUSTAINED auto-scroll driven through that API would suppress row remeasurement for the
5669
+ // whole gesture. scrollLeft/scrollTop are ordinary user-scroll-shaped writes (scrollState
5670
+ // stays null), so remeasureWindow() keeps observing recycled rows normally throughout.
5671
+ if (d.dx !== 0) gridScrollEl.scrollLeft = gridScrollEl.scrollLeft + d.dx;
5672
+ if (d.dy !== 0) gridScrollEl.scrollTop = gridScrollEl.scrollTop + d.dy;
5673
+ }
5674
+ applyPointAt(lastClientX, lastClientY);
5675
+ fillEdgeScrollRaf = requestAnimationFrame(scrollStep);
5676
+ };
4887
5677
  const move = (ev: any) => {
4888
5678
  if (!fillDragging) return;
4889
- const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
5679
+ lastClientX = ev.clientX;
5680
+ lastClientY = ev.clientY;
4890
5681
  // B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
4891
5682
  // pointermove (the pointer fires many per cell) spams the event with identical payloads.
4892
5683
  // Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
4893
5684
  // seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
4894
5685
  // or re-enters the same cell is suppressed (the range is unchanged).
4895
- if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
4896
- lastCell = cell;
4897
- setRangeFocus$local(cell.r, cell.c);
5686
+ applyPointAt(ev.clientX, ev.clientY);
5687
+ // D-13: start the edge-auto-scroll loop the FIRST time the pointer enters an edge zone
5688
+ // (idempotent — fillEdgeScrollRaf is only null when no loop is currently scheduled). The
5689
+ // loop itself (scrollStep) re-evaluates edgeDelta every frame and self-terminates once the
5690
+ // pointer leaves the zone, so no separate "still in zone" check is needed here.
5691
+ if (fillEdgeScrollRaf == null && typeof requestAnimationFrame === 'function') {
5692
+ const d = edgeDelta(ev.clientX, ev.clientY);
5693
+ if (d.dx !== 0 || d.dy !== 0) fillEdgeScrollRaf = requestAnimationFrame(scrollStep);
4898
5694
  }
4899
5695
  };
4900
5696
  const up = () => {
4901
- // teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
5697
+ // teardownFillDrag clears fillDragging + removes both listeners + cancels any in-flight
5698
+ // edge-auto-scroll rAF (CR-04 / T-87-06-02 shared path).
4902
5699
  teardownFillDrag();
4903
5700
  // A plain click on the fill handle (pointerdown+up with NO intervening drag) leaves lastCell
4904
5701
  // at the source box's own origin corner (r1,c1), so fillRange(sourceBox, corner) would
@@ -4994,13 +5791,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
4994
5791
  }
4995
5792
  }
4996
5793
 
4997
- // ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
4998
- // Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
4999
- // the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
5000
- // onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
5001
- // D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
5002
- // editable columns lowers byte-identical (the editor branch r-if is always false).
5003
-
5004
5794
  // The column id at the active cell (the active row's visible cell list @ activeColIndex).
5005
5795
  // Null when out of range (no body rows, or active cell is a header / select column).
5006
5796
  function activeCellColumnId() {
@@ -5075,29 +5865,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5075
5865
  setInvalidMsg(msg != null ? msg : '');
5076
5866
  }
5077
5867
 
5078
- // replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
5079
- // set to the new value); the rest share by reference (the family immutable whole-array
5080
- // replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
5081
- // is over currentData() (== the visible model order for the non-virtual, unsorted/
5082
- // unfiltered single-cell case; the row id is carried for the commit payload).
5083
- function replaceRowValue(rows: any, rowIndex: any, field: any, value: any) {
5084
- const src = rows || [];
5085
- const out = [];
5086
- for (let i = 0; i < src.length; i++) {
5087
- if (i === rowIndex) {
5088
- // WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
5089
- // and would copy inherited enumerable props of typed/class-instance row objects.
5090
- out.push({
5091
- ...(src[i] || {}),
5092
- [field]: value
5093
- });
5094
- } else {
5095
- out.push(src[i]);
5096
- }
5097
- }
5098
- return out;
5099
- }
5100
-
5101
5868
  // Map a visible-model body-row index ($data.rows index) to its underlying currentData()
5102
5869
  // index via the row's original object identity (sorting/filtering/pagination may reorder
5103
5870
  // the visible model away from the source array order). Falls back to the same index.
@@ -5277,23 +6044,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5277
6044
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
5278
6045
  }
5279
6046
 
5280
- // B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
5281
- // row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
5282
- // across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
5283
- // via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
5284
- // only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
5285
- // the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
5286
- function indexOfRowIn(rows: any, rowOriginal: any, rowId: any) {
5287
- const list = rows || [];
5288
- for (let i = 0; i < list.length; i++) {
5289
- const r = list[i];
5290
- if (!r) continue;
5291
- if (rowId != null && r.id === rowId) return i;
5292
- if (rowOriginal != null && r.original === rowOriginal) return i;
5293
- }
5294
- return -1;
5295
- }
5296
-
5297
6047
  // endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
5298
6048
  // draft + invalid state and returns to navigation mode. Does NOT move focus (callers
5299
6049
  // decide where focus lands — commit/cancel return it to the owning cell).
@@ -5512,13 +6262,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5512
6262
  focusCellWhenReady(focusRow, focusCol);
5513
6263
  }
5514
6264
 
5515
- // ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
5516
- // Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
5517
- // edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
5518
- // replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
5519
- // validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
5520
- // editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.
5521
-
5522
6265
  // The editable [columnId, field] pairs for a body row at the given visible-model index,
5523
6266
  // in visible-cell order. field is the column's accessorKey (the row-object key to write).
5524
6267
  function editableColumnsForRow(rowIndex: any) {
@@ -5741,27 +6484,6 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
5741
6484
  focusCellWhenReady(focusRow, focusCol);
5742
6485
  }
5743
6486
 
5744
- // replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
5745
- // in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
5746
- function replaceRowValues(rows: any, rowIndex: any, fieldValues: any) {
5747
- const src = rows || [];
5748
- const fv = fieldValues || {};
5749
- const out = [];
5750
- for (let i = 0; i < src.length; i++) {
5751
- if (i === rowIndex) {
5752
- // WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
5753
- // prototype-walking copy. Spread copies own enumerable props only.
5754
- out.push({
5755
- ...(src[i] || {}),
5756
- ...fv
5757
- });
5758
- } else {
5759
- out.push(src[i]);
5760
- }
5761
- }
5762
- return out;
5763
- }
5764
-
5765
6487
  // Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
5766
6488
  // rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
5767
6489
  // cell at the row's end; stop (return null) at grid end. Pure index math over the visible
@@ -6210,9 +6932,17 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
6210
6932
  // B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
6211
6933
  // real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
6212
6934
  // active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
6935
+ // Phase 87 87-06 (D-08, deferred-items.md "87-03: focusCell's activecell-change emit guard
6936
+ // ignores column-only moves"): also snapshot the PRE-write column so a column-ONLY move (e.g.
6937
+ // focusCell(sameRow, differentCol) — the exact D-08 case) is recognized as a real move too. The
6938
+ // pre-fix guard compared only the row, so DOM focus moved correctly but the event never fired —
6939
+ // logged as a genuine, pre-existing bug in 87-03 and explicitly revisited here because this
6940
+ // plan's absolute-column-index work (and its own acceptance criteria) makes fixing it correct
6941
+ // and cheap now, rather than leaving it deferred.
6213
6942
  const prevAbs = toAbsRow(activeRow());
6943
+ const prevCol = activeColIndex();
6214
6944
  const prevIsHeader = activeIsHeader();
6215
- if (local.virtual) {
6945
+ if (rowsWindowed()) {
6216
6946
  // Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
6217
6947
  // the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
6218
6948
  // focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
@@ -6246,7 +6976,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
6246
6976
  focusActiveCell(localRow, c, false);
6247
6977
  }
6248
6978
  }
6249
- if (absRow !== prevAbs || prevIsHeader) {
6979
+ if (absRow !== prevAbs || c !== prevCol || prevIsHeader) {
6250
6980
  _props.onActivecellChange?.({
6251
6981
  rowIndex: absRow,
6252
6982
  colIndex: c
@@ -6420,7 +7150,7 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
6420
7150
 
6421
7151
  {<Show when={local.groupable}><div class={"rdt-group-bar-host"} data-rozie-s-d5dcab4c="">
6422
7152
  {(_props.groupBarSlot ?? _props.slots?.['groupBar'])?.({ grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping, clearGrouping }) ?? <Key each={groupingKeys() as readonly any[]} by={(gk) => gk}>{(gk) => <span class={"rdt-group-token"} data-group-token="" data-rozie-s-d5dcab4c="">{rozieDisplay(gk())}</span>}</Key>}
6423
- </div></Show>}{<Show when={local.virtual} fallback={<table aria-rowcount={rozieAttr(gridAriaRowCount())} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader })} role={rozieAttr(tableRole())} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridKeyDown($event); }} onFocusIn={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { syncActiveFromEvent($event); }} onFocusOut={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridFocusOut($event); }} onMouseDown={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridMouseDown($event); }} onDblClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridDblClick($event); }} onClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridClick($event); }} data-rozie-s-d5dcab4c="">
7153
+ </div></Show>}{<Show when={isWindowed()} fallback={<table aria-rowcount={rozieAttr(gridAriaRowCount())} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader })} role={rozieAttr(tableRole())} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridKeyDown($event); }} onFocusIn={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { syncActiveFromEvent($event); }} onFocusOut={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridFocusOut($event); }} onMouseDown={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridMouseDown($event); }} onDblClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridDblClick($event); }} onClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridClick($event); }} data-rozie-s-d5dcab4c="">
6424
7154
  <thead class={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
6425
7155
  <Key each={headerGroups() as readonly any[]} by={(hg) => hg.id}>{(hg, hgLevel) => <tr class={"rdt-tr"} role="row" aria-rowindex={rozieAttr(hgLevel() + 1)} data-rozie-s-d5dcab4c="">
6426
7156
  <Key each={hg().headers as readonly any[]} by={(header) => header.id}>{(header) => <th class={"rdt-th" + " " + rozieClass({ 'rdt-select-th': isSelectColumn(header().column.id), 'rdt-expander-th': isExpanderColumn(header().column.id), 'rdt-th-resizing': columnIsResizing(header().column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg(), header()), hgLevel()) })} role="columnheader" data-col={rozieAttr(header().column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel())} colSpan={rozieAttr(header().colSpan > 1 ? header().colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg(), header()))} tabIndex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg(), header()), hgLevel()))} aria-sort={rozieAttr(ariaSortFor(header().column.id))} style={parseInlineStyle(thStyle(header().column.id))} data-rozie-s-d5dcab4c="">
@@ -6493,53 +7223,56 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
6493
7223
  </td>
6494
7224
  </tr></Show>}</>}</Key>
6495
7225
  </tbody>
6496
- </table>}><div class={"rdt-scroll"} style={parseInlineStyle(local.maxHeight ? 'max-height:' + local.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + local.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c="">
6497
- <table aria-rowcount={rozieAttr(gridAriaRowCount())} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader })} role={rozieAttr(tableRole())} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridKeyDown($event); }} onFocusIn={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { syncActiveFromEvent($event); }} onFocusOut={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridFocusOut($event); }} onMouseDown={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridMouseDown($event); }} onDblClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridDblClick($event); }} onClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridClick($event); }} data-rozie-s-d5dcab4c="">
7226
+ </table>}><div class={"rdt-scroll"} style={parseInlineStyle(rowsWindowed() && local.maxHeight ? 'max-height:' + local.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + local.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c="">
7227
+ <table aria-rowcount={rozieAttr(gridAriaRowCount())} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader, 'rdt-col-windowed': colsWindowed() })} role={rozieAttr(tableRole())} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridKeyDown($event); }} onFocusIn={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { syncActiveFromEvent($event); }} onFocusOut={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridFocusOut($event); }} onMouseDown={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridMouseDown($event); }} onDblClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridDblClick($event); }} onClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridClick($event); }} data-rozie-s-d5dcab4c="">
6498
7228
  <thead class={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
6499
7229
  <Key each={headerGroups() as readonly any[]} by={(hg) => hg.id}>{(hg, hgLevel) => <tr class={"rdt-tr"} role="row" aria-rowindex={rozieAttr(hgLevel() + 1)} data-rozie-s-d5dcab4c="">
6500
- <Key each={hg().headers as readonly any[]} by={(header) => header.id}>{(header) => <th class={"rdt-th" + " " + rozieClass({ 'rdt-select-th': isSelectColumn(header().column.id), 'rdt-expander-th': isExpanderColumn(header().column.id), 'rdt-th-resizing': columnIsResizing(header().column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg(), header()), hgLevel()) })} role="columnheader" data-col={rozieAttr(header().column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel())} colSpan={rozieAttr(header().colSpan > 1 ? header().colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg(), header()))} tabIndex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg(), header()), hgLevel()))} aria-sort={rozieAttr(ariaSortFor(header().column.id))} style={parseInlineStyle(thStyle(header().column.id))} data-rozie-s-d5dcab4c="">
6501
- {<Show when={isSelectColumn(header().column.id)} fallback={<Show when={isExpanderColumn(header().column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
6502
- {<Show when={header().column.getCanSort && header().column.getCanSort()} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
7230
+
7231
+ {<Show when={colsWindowed()}><th class={"rdt-col-spacer"} aria-hidden="true" style={parseInlineStyle('width:' + colPadLeft() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" /></Show>}<Key each={windowedHeadersFor(hg(), hgLevel()) as readonly any[]} by={(wh) => wh.header.id}>{(wh) => <th class={"rdt-th" + " " + rozieClass({ 'rdt-select-th': isSelectColumn(wh().header.column.id), 'rdt-expander-th': isExpanderColumn(wh().header.column.id), 'rdt-th-resizing': columnIsResizing(wh().header.column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg(), wh().header), hgLevel()) })} role="columnheader" data-col={rozieAttr(wh().header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel())} colSpan={rozieAttr(wh().span > 1 ? wh().span : null)} data-col-index={rozieAttr(headerColIndexOf(hg(), wh().header))} tabIndex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg(), wh().header), hgLevel()))} aria-sort={rozieAttr(ariaSortFor(wh().header.column.id))} style={parseInlineStyle(thStyle(wh().header.column.id))} data-rozie-s-d5dcab4c="">
7232
+ {<Show when={isSelectColumn(wh().header.column.id)} fallback={<Show when={isExpanderColumn(wh().header.column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
7233
+ {<Show when={wh().header.column.getCanSort && wh().header.column.getCanSort()} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
6503
7234
  <span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
6504
- {(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header().column.id, column: header().column, label: headerLabel(header().column.id) }) ?? rozieDisplay(headerLabel(header().column.id))}
7235
+ {(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: wh().header.column.id, column: wh().header.column, label: headerLabel(wh().header.column.id) }) ?? rozieDisplay(headerLabel(wh().header.column.id))}
6505
7236
  </span>
6506
- </span>}><button type="button" class={"rdt-sort-btn"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onHeaderSort(header().column.id, $event); }} data-rozie-s-d5dcab4c="">
7237
+ </span>}><button type="button" class={"rdt-sort-btn"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onHeaderSort(wh().header.column.id, $event); }} data-rozie-s-d5dcab4c="">
6507
7238
  <span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
6508
- {(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header().column.id, column: header().column, label: headerLabel(header().column.id) }) ?? rozieDisplay(headerLabel(header().column.id))}
7239
+ {(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: wh().header.column.id, column: wh().header.column, label: headerLabel(wh().header.column.id) }) ?? rozieDisplay(headerLabel(wh().header.column.id))}
6509
7240
  </span>
6510
- <span class={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(header().column.id))}</span>
7241
+ <span class={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(wh().header.column.id))}</span>
6511
7242
  </button></Show>}<Popover trigger="click" placement="bottom-end" strategy="fixed" offset={4} data-rozie-s-d5dcab4c="" anchorSlot={() => (<>
6512
- <button type="button" class={"rdt-col-menu-trigger"} aria-label={rozieAttr('Column options for ' + headerLabel(header().column.id))} data-rozie-s-d5dcab4c="">⋯</button>
7243
+ <button type="button" class={"rdt-col-menu-trigger"} aria-label={rozieAttr('Column options for ' + headerLabel(wh().header.column.id))} data-rozie-s-d5dcab4c="">⋯</button>
6513
7244
  </>)}><div class={"rdt-col-menu"} role="menu" data-rozie-s-d5dcab4c="">
6514
- <button type="button" role="menuitem" aria-pressed={columnPinSide(header().column.id) === 'left'} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(header().column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">Pin left</button>
6515
- <button type="button" role="menuitem" aria-pressed={columnPinSide(header().column.id) === 'right'} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(header().column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">Pin right</button>
6516
- <button type="button" role="menuitem" aria-pressed={!columnPinSide(header().column.id)} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(header().column.id, false, $event); }} data-rozie-s-d5dcab4c="">Unpin</button>
7245
+ <button type="button" role="menuitem" aria-pressed={columnPinSide(wh().header.column.id) === 'left'} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(wh().header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">Pin left</button>
7246
+ <button type="button" role="menuitem" aria-pressed={columnPinSide(wh().header.column.id) === 'right'} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(wh().header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">Pin right</button>
7247
+ <button type="button" role="menuitem" aria-pressed={!columnPinSide(wh().header.column.id)} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(wh().header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">Unpin</button>
6517
7248
  <hr class={"rdt-col-menu-sep"} data-rozie-s-d5dcab4c="" />
6518
- <button type="button" role="menuitem" class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onHideColumn(header().column.id, $event); }} data-rozie-s-d5dcab4c="">Hide column</button>
7249
+ <button type="button" role="menuitem" class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onHideColumn(wh().header.column.id, $event); }} data-rozie-s-d5dcab4c="">Hide column</button>
6519
7250
  </div></Popover>
6520
- <button type="button" aria-label={rozieAttr('Resize ' + headerLabel(header().column.id))} class={"rdt-resize-handle"} onPointerDown={($event: PointerEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onResizeStart(header().column.id, $event); }} onTouchStart={($event: TouchEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onResizeStart(header().column.id, $event); }} data-rozie-s-d5dcab4c=""><span class={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
7251
+ <button type="button" aria-label={rozieAttr('Resize ' + headerLabel(wh().header.column.id))} class={"rdt-resize-handle"} onPointerDown={($event: PointerEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onResizeStart(wh().header.column.id, $event); }} onTouchStart={($event: TouchEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onResizeStart(wh().header.column.id, $event); }} data-rozie-s-d5dcab4c=""><span class={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
6521
7252
  </span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
6522
7253
  {(_props.selectAllSlot ?? _props.slots?.['selectAll'])?.({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }) ?? <Show when={local.selectionMode === 'multiple'}><input type="checkbox" aria-label="Select all rows" class={"rdt-select-all"} checked={isAllRowsSelected()} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" /></Show>}
6523
7254
  </span></Show>}</th>}</Key>
6524
- </tr>}</Key>
7255
+
7256
+ {<Show when={colsWindowed()}><th class={"rdt-col-spacer"} aria-hidden="true" style={parseInlineStyle('width:' + colPadRight() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" /></Show>}</tr>}</Key>
6525
7257
 
6526
7258
  {<Show when={hasAnyFilterableColumn()}><tr class={"rdt-filter-row"} data-rozie-s-d5dcab4c="">
6527
- <Key each={headerGroups()[headerGroups().length - 1].headers as readonly any[]} by={(header) => header.id}>{(header) => <th class={"rdt-filter-cell"} role="presentation" style={parseInlineStyle(pinStyle(header().column.id))} data-rozie-s-d5dcab4c="">
6528
- {<Show when={isSelectColumn(header().column.id)} fallback={<Show when={isExpanderColumn(header().column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
6529
- {<Show when={columnIsFilterable(header().column.id) && !hasFilterSlot()}><input type="text" aria-label={rozieAttr('Filter ' + headerLabel(header().column.id))} class={"rdt-col-filter"} value={columnFilterValue(header().column.id)} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onColumnFilterInput(header().column.id, $event); }} onClick={($event: MouseEvent & { currentTarget: HTMLInputElement; target: Element }) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" /></Show>}{<Show when={columnIsFilterable(header().column.id)}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
6530
- {(_props.filterSlot ?? _props.slots?.['filter'])?.({ columnId: header().column.id, value: columnFilterValue(header().column.id), uniqueValues: getFacetedUniqueValues(header().column.id), minMax: getFacetedMinMaxValues(header().column.id), setFilter: setColumnFilter })}
7259
+ {<Show when={colsWindowed()}><th class={"rdt-col-spacer"} aria-hidden="true" style={parseInlineStyle('width:' + colPadLeft() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" /></Show>}<Key each={windowedHeadersFor(headerGroups()[headerGroups().length - 1], headerGroups().length - 1) as readonly any[]} by={(wh) => wh.header.id}>{(wh) => <th class={"rdt-filter-cell"} role="presentation" data-col={rozieAttr(wh().header.column.id)} style={parseInlineStyle(pinStyle(wh().header.column.id))} data-rozie-s-d5dcab4c="">
7260
+ {<Show when={isSelectColumn(wh().header.column.id)} fallback={<Show when={isExpanderColumn(wh().header.column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
7261
+ {<Show when={columnIsFilterable(wh().header.column.id) && !hasFilterSlot()}><input type="text" aria-label={rozieAttr('Filter ' + headerLabel(wh().header.column.id))} class={"rdt-col-filter"} value={columnFilterValue(wh().header.column.id)} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onColumnFilterInput(wh().header.column.id, $event); }} onClick={($event: MouseEvent & { currentTarget: HTMLInputElement; target: Element }) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" /></Show>}{<Show when={columnIsFilterable(wh().header.column.id)}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
7262
+ {(_props.filterSlot ?? _props.slots?.['filter'])?.({ columnId: wh().header.column.id, value: columnFilterValue(wh().header.column.id), uniqueValues: getFacetedUniqueValues(wh().header.column.id), minMax: getFacetedMinMaxValues(wh().header.column.id), setFilter: setColumnFilter })}
6531
7263
  </span></Show>}</span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}</th>}</Key>
6532
- </tr></Show>}</thead>
7264
+ {<Show when={colsWindowed()}><th class={"rdt-col-spacer"} aria-hidden="true" style={parseInlineStyle('width:' + colPadRight() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" /></Show>}</tr></Show>}</thead>
6533
7265
 
6534
7266
  <tbody class={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
6535
7267
 
6536
7268
  <tr class={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
6537
- <td colSpan={rozieAttr(visibleColCount())} style={parseInlineStyle('height:' + padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
7269
+ <td colSpan={rozieAttr(windowedColSpan())} style={parseInlineStyle('height:' + padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
6538
7270
  </tr>
6539
7271
 
6540
7272
  <Key each={windowedRows() as readonly any[]} by={(wr) => wr.row.id}>{(wr) => <>
6541
7273
  <tr class={"rdt-tr" + " " + rozieClass({ 'rdt-group-header': rowIsGrouped(wr().row), 'rdt-row-pinned': wr().pinned })} role="row" data-row={rozieAttr(wr().vi.index)} aria-rowindex={rozieAttr(headerRowCount() + wr().vi.index + 1)} data-index={rozieAttr(wr().vi.index)} data-pinned={rozieAttr(wr().pinned ? 'true' : null)} data-depth={rozieAttr(wr().row.depth)} data-group-header={rozieAttr(rowIsGrouped(wr().row) ? wr().row.id : null)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(wr().row) ? wr().row.id : null)} aria-expanded={(rowIsGrouped(wr().row) ? !!rowIsExpanded(wr().row) : null) ?? undefined} aria-selected={(local.selectionMode !== 'none' ? !!rowIsSelected(wr().row) : null) ?? undefined} aria-level={rozieAttr(groupingActive() ? wr().row.depth + 1 : null)} data-rozie-s-d5dcab4c="">
6542
- <Key each={visibleCellsFor(wr().row) as readonly any[]} by={(cell) => cell.id}>{(cell) => <td class={"rdt-td" + " " + rozieClass({ 'rdt-select-td': isSelectColumn(cell().column.id), 'rdt-expander-td': isExpanderColumn(cell().column.id), 'rdt-in-range': inRange(wr().vi.index, colIndexOf(wr().row, cell())), 'rdt-cell-active': isActiveCell(String(wr().vi.index), colIndexOf(wr().row, cell())) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cell().column.id)} data-grid-cell="" data-row={rozieAttr(wr().vi.index)} data-col-index={rozieAttr(colIndexOf(wr().row, cell()))} tabIndex={rozieAttr(cellTabindex(String(wr().vi.index), colIndexOf(wr().row, cell())))} style={parseInlineStyle(bodyCellStyle(wr().row, cell().column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr().vi.index, colIndexOf(wr().row, cell())))} data-in-range={rozieAttr(inRange(wr().vi.index, colIndexOf(wr().row, cell())) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cell()) ? cell().column.id : null)} data-rozie-s-d5dcab4c="">
7274
+
7275
+ {<Show when={colsWindowed()}><td class={"rdt-col-spacer"} aria-hidden="true" style={parseInlineStyle('width:' + colPadLeft() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" /></Show>}<Key each={windowedCells(wr().row) as readonly any[]} by={(cell) => cell.id}>{(cell) => <td class={"rdt-td" + " " + rozieClass({ 'rdt-select-td': isSelectColumn(cell().column.id), 'rdt-expander-td': isExpanderColumn(cell().column.id), 'rdt-in-range': inRange(wr().vi.index, colIndexOf(wr().row, cell())), 'rdt-cell-active': isActiveCell(String(wr().vi.index), colIndexOf(wr().row, cell())) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cell().column.id)} data-grid-cell="" data-row={rozieAttr(wr().vi.index)} data-col-index={rozieAttr(colIndexOf(wr().row, cell()))} tabIndex={rozieAttr(cellTabindex(String(wr().vi.index), colIndexOf(wr().row, cell())))} style={parseInlineStyle(bodyCellStyle(wr().row, cell().column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr().vi.index, colIndexOf(wr().row, cell())))} data-in-range={rozieAttr(inRange(wr().vi.index, colIndexOf(wr().row, cell())) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cell()) ? cell().column.id : null)} data-rozie-s-d5dcab4c="">
6543
7276
 
6544
7277
  {<Show when={isExpanderColumn(cell().column.id)} fallback={<Show when={isSelectColumn(cell().column.id)} fallback={<Show when={cellIsGrouped(cell())} fallback={<Show when={isEditing(wr().vi.index, colIndexOf(wr().row, cell()))} fallback={<Show when={cellIsPlaceholder(cell())} fallback={<span class={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
6545
7278
  {(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cell().column.id, column: cell().column, row: wr().row.original, value: cell().getValue() }) ?? rozieDisplay(cell().getValue())}
@@ -6558,20 +7291,21 @@ export default function DataTable(_props: DataTableProps): JSX.Element {
6558
7291
  {(_props.selectCellSlot ?? _props.slots?.['selectCell'])?.({ row: wr().row.original, checked: rowIsSelected(wr().row), toggle: e => onToggleRow(wr().row, e) }) ?? <input type="checkbox" aria-label="Select row" class={"rdt-select-row"} checked={rowIsSelected(wr().row)} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { onToggleRow(wr().row, $event); }} data-rozie-s-d5dcab4c="" />}
6559
7292
  </span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
6560
7293
  {<Show when={rowCanExpand(wr().row)}><button type="button" data-expander="" aria-expanded={!!rowIsExpanded(wr().row)} aria-label={rozieAttr(rowIsExpanded(wr().row) ? 'Collapse row' : 'Expand row')} class={"rdt-expander"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onToggleExpand(wr().row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(wr().row) ? '▾' : '▸')}</button></Show>}</span></Show>}{<Show when={isFillHandleCell(wr().vi.index, colIndexOf(wr().row, cell()))}><span data-fill-handle="" data-testid="fill-handle" aria-hidden="true" class={"rdt-fill-handle"} onPointerDown={($event: PointerEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" /></Show>}</td>}</Key>
6561
- </tr>
7294
+
7295
+ {<Show when={colsWindowed()}><td class={"rdt-col-spacer"} aria-hidden="true" style={parseInlineStyle('width:' + colPadRight() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" /></Show>}</tr>
6562
7296
 
6563
7297
  {<Show when={rowShowsDetail(wr().row)}><tr class={"rdt-detail-row"} role="row" data-detail-row={rozieAttr(wr().row.id)} data-rozie-s-d5dcab4c="">
6564
- <td class={"rdt-detail-cell"} colSpan={rozieAttr(visibleColCount())} data-rozie-s-d5dcab4c="">
7298
+ <td class={"rdt-detail-cell"} colSpan={rozieAttr(windowedColSpan())} data-rozie-s-d5dcab4c="">
6565
7299
  {(_props.detailSlot ?? _props.slots?.['detail'])?.({ row: wr().row.original })}
6566
7300
  </td>
6567
7301
  </tr></Show>}</>}</Key>
6568
7302
 
6569
7303
  <tr class={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
6570
- <td colSpan={rozieAttr(visibleColCount())} style={parseInlineStyle('height:' + padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
7304
+ <td colSpan={rozieAttr(windowedColSpan())} style={parseInlineStyle('height:' + padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
6571
7305
  </tr>
6572
7306
  </tbody>
6573
7307
  </table>
6574
- </div></Show>}{<Show when={!local.virtual}><div class={"rdt-pagination"} role="group" aria-label="Pagination" data-rozie-s-d5dcab4c="">
7308
+ </div></Show>}{<Show when={!rowsWindowed()}><div class={"rdt-pagination"} role="group" aria-label="Pagination" data-rozie-s-d5dcab4c="">
6575
7309
  <button type="button" class={"rdt-page-btn rdt-page-prev"} disabled={!canPrevPage()} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPrevPage(); }} data-rozie-s-d5dcab4c="">Prev</button>
6576
7310
  <span class={"rdt-page-status"} aria-live="polite" data-rozie-s-d5dcab4c="">
6577
7311
  {rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + displayPageCount())}