@rozie-ui/data-table-lit 0.2.7 → 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.ts CHANGED
@@ -5,6 +5,10 @@ import { RozieSlotDistributor, createLitControllableProperty, rozieAttr, rozieDi
5
5
  import { ContextProvider, createContext } from '@lit/context';
6
6
  import { repeat } from 'lit/directives/repeat.js';
7
7
  import '@rozie-ui/popover-lit';
8
+ import { isSafeKey, wrapAggregationFn } from './helpers/columnDefUtils';
9
+ import { applyUpdater, clamp, focusables } from './helpers/indexMath';
10
+ import { escapeTsvField, parseTsv, tileGridToBox, tileIndex } from './helpers/tsvGrid';
11
+ import { replaceRowValue, indexOfRowIn, replaceRowValues } from './helpers/rowValueUtils';
8
12
  import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
9
13
  // Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
10
14
  // (mirrors the expand/group models) — inert until a consumer READS a column facet via the
@@ -101,6 +105,13 @@ export default class DataTable extends SignalWatcher(LitElement) {
101
105
  font: var(--rdt-font, 14px system-ui, sans-serif);
102
106
  color: var(--rdt-color, inherit);
103
107
  }
108
+ .rozie-data-table.rdt-col-windowed[data-rozie-s-d5dcab4c] {
109
+ table-layout: fixed;
110
+ }
111
+ .rozie-data-table.rdt-col-windowed[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c],
112
+ .rozie-data-table.rdt-col-windowed[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
113
+ box-sizing: border-box;
114
+ }
104
115
  .rdt-sr-live[data-rozie-s-d5dcab4c] {
105
116
  position: absolute;
106
117
  width: 1px;
@@ -551,13 +562,17 @@ export default class DataTable extends SignalWatcher(LitElement) {
551
562
  */
552
563
  @property({ type: Number, reflect: true }) undoLimit: number = 100;
553
564
  /**
554
- * 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.
565
+ * 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`.
555
566
  */
556
- @property({ type: Boolean, reflect: true }) virtual: boolean = false;
567
+ @property({ converter: { fromAttribute: (v: string | null) => (v === null ? false : v === 'true' ? true : v === 'false' ? false : v === '' ? true : v) } }) virtual: boolean | string = false;
557
568
  /**
558
- * Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
569
+ * 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.
559
570
  */
560
571
  @property({ type: Number, reflect: true }) estimateRowHeight: number = 40;
572
+ /**
573
+ * 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).
574
+ */
575
+ @property({ type: Boolean, reflect: true }) autoMeasure: boolean = false;
561
576
  /**
562
577
  * 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.
563
578
  */
@@ -957,10 +972,15 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
957
972
  // into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
958
973
  // NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
959
974
  // setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
960
- if (this.virtual && this.virtualizer) {
975
+ if (this.rowsWindowed() && this.virtualizer) {
961
976
  this.virtualizer.setOptions(this.virtualizerOptions());
962
977
  this.virtualizer._willUpdate();
963
978
  }
979
+ // Phase 87 87-04: re-feed the column virtualizer's count/size on the SAME re-derive cycle
980
+ // (a column-visibility/order/pinning change is already in the $watch list below and re-pulls
981
+ // through here) — guarded internally on colsWindowed() && colVirtualizer, so this is a no-op
982
+ // on every other path.
983
+ this.remeasureColumnWindow();
964
984
  // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
965
985
  // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
966
986
  // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
@@ -1005,7 +1025,7 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1005
1025
  if (this.pendingEditFollow && this.isGrid()) {
1006
1026
  const follow = this.pendingEditFollow;
1007
1027
  this.pendingEditFollow = null;
1008
- const followIdx = this.indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
1028
+ const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
1009
1029
  if (followIdx >= 0) this.focusCellWhenReady(followIdx, follow.col);
1010
1030
  }
1011
1031
  // keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
@@ -1034,60 +1054,132 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1034
1054
  // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
1035
1055
  // mount"); the consumer drives focus by Tabbing/clicking in, never the component.
1036
1056
 
1037
- // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
1038
- // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
1039
- // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
1040
- // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
1041
- // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
1057
+ // ── Windowing: capture the scroll element + construct the row virtualizer (req-1/2 — the
1058
+ // row virtual-core instance ONLY when rows are windowed; the scroll-element capture whenever
1059
+ // EITHER axis is windowed, since both axes observe the SAME .rdt-scroll element — D-03). Built
1060
+ // HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
1061
+ // getPrePaginationRowModel reads the live table. ENTIRELY inside the isWindowed()/rowsWindowed()
1062
+ // guards: when both axes are off, NO virtual-core runtime code executes (byte-identical-off).
1063
+ // _didMount() registers the scroll-element ResizeObserver and returns the teardown stored for
1064
+ // $onUnmount.
1042
1065
  // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
1043
1066
  // page load AND was non-deterministic on React/Solid (the entry cell may not be
1044
1067
  // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
1045
1068
  // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
1046
1069
  // mount"); the consumer drives focus by Tabbing/clicking in, never the component.
1047
1070
 
1048
- // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
1049
- // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
1050
- // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
1051
- // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
1052
- // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
1053
- if (this.virtual) {
1071
+ // ── Windowing: capture the scroll element + construct the row virtualizer (req-1/2 — the
1072
+ // row virtual-core instance ONLY when rows are windowed; the scroll-element capture whenever
1073
+ // EITHER axis is windowed, since both axes observe the SAME .rdt-scroll element — D-03). Built
1074
+ // HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
1075
+ // getPrePaginationRowModel reads the live table. ENTIRELY inside the isWindowed()/rowsWindowed()
1076
+ // guards: when both axes are off, NO virtual-core runtime code executes (byte-identical-off).
1077
+ // _didMount() registers the scroll-element ResizeObserver and returns the teardown stored for
1078
+ // $onUnmount.
1079
+ if (this.isWindowed()) {
1054
1080
  this.gridScrollEl = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rdt-scroll') : null;
1081
+ }
1082
+ if (this.rowsWindowed()) {
1055
1083
  this.virtualizer = new Virtualizer(this.virtualizerOptions());
1056
1084
  this.virtualizerCleanup = this.virtualizer._didMount();
1057
- // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
1058
- // evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
1059
- // reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
1060
- // non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
1061
- // SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
1062
- // only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
1063
- // never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
1064
- // re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
1065
- // pulls getVirtualItems().
1085
+ }
1086
+ // Phase 87 87-04: construct the SECOND, horizontal Virtualizer instance when the COLUMN
1087
+ // axis is windowed — entirely inside its own colsWindowed() guard, byte-identical-off for
1088
+ // 'rows'/true/false.
1089
+ // Phase 87 87-04: construct the SECOND, horizontal Virtualizer instance when the COLUMN
1090
+ // axis is windowed — entirely inside its own colsWindowed() guard, byte-identical-off for
1091
+ // 'rows'/true/false.
1092
+ if (this.colsWindowed()) {
1093
+ this.colVirtualizer = new Virtualizer(this.columnVirtualizerOptions());
1094
+ this.colVirtualizerCleanup = this.colVirtualizer._didMount();
1095
+ }
1096
+ // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte), either axis: the windowed <For>/{#each}
1097
+ // accessor was first evaluated at initial render — while `virtualizer`/`colVirtualizer` were
1098
+ // still null — and (because windowedRows()/windowedColIndices() read $data.windowVer up top)
1099
+ // subscribed to windowVer then returned []/the full set. Both `let`s are non-reactive, so
1100
+ // their assignment above does NOT notify the accessor; we must bump the SIGNAL it subscribed
1101
+ // to. _didMount() computes the first window synchronously but its onChange only fires on
1102
+ // SUBSEQUENT scroll/resize, so without this explicit bump the first window would never paint
1103
+ // on the fine-grained targets. Idempotent + harmless on the coarse targets (they re-render
1104
+ // wholesale anyway). ONE bump here (not one per axis, Rule 1 — see the ROZ138 note below)
1105
+ // covers both: windowedRows()/windowedColIndices() each simply re-derive against their own
1106
+ // now-non-null virtualizer instance off the SAME counter (87-02 checkpoint decision (b): no
1107
+ // separate $data.colWindowVer). Gated on isWindowed() (either axis) rather than two separate
1108
+ // per-axis bumps: a second `$data.windowVer = $data.windowVer + 1` write later in this SAME
1109
+ // $onMount body would dominate-flag ROZ138 (React stale-read) on its own right-hand-side read
1110
+ // of the counter it had just written a few lines above — a false positive (this increment
1111
+ // idiom lowers to a functional React setState update and is safe regardless of ordering, the
1112
+ // same reasoning virtualizerOptions()'s onChange documents), but collapsing to one bump avoids
1113
+ // manufacturing the false-positive warning at all rather than accepting it.
1114
+ // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte), either axis: the windowed <For>/{#each}
1115
+ // accessor was first evaluated at initial render — while `virtualizer`/`colVirtualizer` were
1116
+ // still null — and (because windowedRows()/windowedColIndices() read $data.windowVer up top)
1117
+ // subscribed to windowVer then returned []/the full set. Both `let`s are non-reactive, so
1118
+ // their assignment above does NOT notify the accessor; we must bump the SIGNAL it subscribed
1119
+ // to. _didMount() computes the first window synchronously but its onChange only fires on
1120
+ // SUBSEQUENT scroll/resize, so without this explicit bump the first window would never paint
1121
+ // on the fine-grained targets. Idempotent + harmless on the coarse targets (they re-render
1122
+ // wholesale anyway). ONE bump here (not one per axis, Rule 1 — see the ROZ138 note below)
1123
+ // covers both: windowedRows()/windowedColIndices() each simply re-derive against their own
1124
+ // now-non-null virtualizer instance off the SAME counter (87-02 checkpoint decision (b): no
1125
+ // separate $data.colWindowVer). Gated on isWindowed() (either axis) rather than two separate
1126
+ // per-axis bumps: a second `$data.windowVer = $data.windowVer + 1` write later in this SAME
1127
+ // $onMount body would dominate-flag ROZ138 (React stale-read) on its own right-hand-side read
1128
+ // of the counter it had just written a few lines above — a false positive (this increment
1129
+ // idiom lowers to a functional React setState update and is safe regardless of ordering, the
1130
+ // same reasoning virtualizerOptions()'s onChange documents), but collapsing to one bump avoids
1131
+ // manufacturing the false-positive warning at all rather than accepting it.
1132
+ if (this.isWindowed()) {
1066
1133
  this._windowVer.value = this._windowVer.value + 1;
1067
- // After the first window commits (next frame), refine heights + fire the dev-mode warns
1068
- // ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
1069
- // code and these warns can never fire there (req-1 byte-identical-off preserved).
1134
+ }
1135
+ // After the first window commits (next frame), refine heights + fire the dev-mode warns ONCE.
1136
+ // Entirely inside the isWindowed() guard so the off (neither axis windowed) emitted path adds
1137
+ // NO code and these warns can never fire there (req-1 byte-identical-off preserved). The
1138
+ // row-specific measurement + warns stay further guarded by rowsWindowed() (byte-identical to
1139
+ // before this restructure); the column-axis warn (Phase 87 D-07(a)) is guarded by
1140
+ // colsWindowed() so it can fire ONLY on the 'columns'/'both' path, never on 'rows'/true/false.
1141
+ // After the first window commits (next frame), refine heights + fire the dev-mode warns ONCE.
1142
+ // Entirely inside the isWindowed() guard so the off (neither axis windowed) emitted path adds
1143
+ // NO code and these warns can never fire there (req-1 byte-identical-off preserved). The
1144
+ // row-specific measurement + warns stay further guarded by rowsWindowed() (byte-identical to
1145
+ // before this restructure); the column-axis warn (Phase 87 D-07(a)) is guarded by
1146
+ // colsWindowed() so it can fire ONLY on the 'columns'/'both' path, never on 'rows'/true/false.
1147
+ if (this.isWindowed()) {
1070
1148
  const afterFirstFrame = () => {
1071
- // D-10: measure the rendered rows.
1072
- this.remeasureWindow();
1073
- // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
1074
- // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
1075
- // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
1076
- const h = this.gridScrollEl ? this.gridScrollEl.clientHeight : 0;
1077
- if (!h) {
1078
- console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
1149
+ if (this.rowsWindowed()) {
1150
+ // D-10: measure the rendered rows.
1151
+ this.remeasureWindow();
1152
+ // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
1153
+ // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
1154
+ // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
1155
+ const h = this.gridScrollEl ? this.gridScrollEl.clientHeight : 0;
1156
+ if (!h) {
1157
+ console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
1158
+ }
1159
+ // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
1160
+ // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
1161
+ // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
1162
+ // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
1163
+ // detected as a pagination that DIFFERS from that default (a consumer who set a real page
1164
+ // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
1165
+ // virtual=false path are untouched (rowsWindowed()-guarded, as before this restructure).
1166
+ const pg = this.pagination;
1167
+ const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
1168
+ if (this.manual !== true && pgConfigured) {
1169
+ 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.');
1170
+ }
1079
1171
  }
1080
- // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
1081
- // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
1082
- // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
1083
- // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
1084
- // detected as a pagination that DIFFERS from that default (a consumer who set a real page
1085
- // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
1086
- // virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
1087
- const pg = this.pagination;
1088
- const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
1089
- if (this.manual !== true && pgConfigured) {
1090
- 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.');
1172
+ // Phase 87 Task 1(a) (D-07/D-08 precedent, APPROVED): a dev-mode runtime warn when the
1173
+ // column axis is windowed but the scroll container has no bounded width — the horizontal
1174
+ // failure mode is worse than the vertical one (an unbounded width renders every column
1175
+ // with no scrollbar, so the feature silently does nothing and there is no compile
1176
+ // diagnostic possible, since the bound may come from consumer CSS). Guarded by
1177
+ // colsWindowed() so it can NEVER fire on the 'rows'/true/false path.
1178
+ if (this.colsWindowed()) {
1179
+ const w = this.gridScrollEl ? this.gridScrollEl.clientWidth : 0;
1180
+ if (!w) {
1181
+ 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');
1182
+ }
1091
1183
  }
1092
1184
  };
1093
1185
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
@@ -1129,6 +1221,17 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1129
1221
  this._rozieTornDown = true;
1130
1222
  () => {
1131
1223
  if (this.virtualizerCleanup) this.virtualizerCleanup();
1224
+ // Phase 87 87-04: tear down the column virtualizer's scroll-element ResizeObserver too.
1225
+ // No-op when column windowing was off (cleanup stays null).
1226
+ if (this.colVirtualizerCleanup) this.colVirtualizerCleanup();
1227
+ // Gap-closure 87-09: disconnect the RTL dir-attribute MutationObserver (ensureColRtlWatch(),
1228
+ // windowing.rzts). No-op if column windowing was never on (the observer was never attached).
1229
+ this.teardownColRtlWatch();
1230
+ // Gap-closure 87-13 (CR-01): cancel a deferred remeasure sweep still pending at unmount.
1231
+ // Must run here — remeasureWindow()'s `!virtualizer || !gridRoot` guard never fires, since
1232
+ // neither ref is nulled — or the pass reaches 87-07's refineRowEstimate() and writes
1233
+ // $data.windowVer against a torn-down instance.
1234
+ this.teardownRemeasure();
1132
1235
  // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
1133
1236
  this.teardownFillDrag();
1134
1237
  // §6 (260709-3qt): remove any live drag-select document listeners on a mid-drag unmount.
@@ -1180,52 +1283,55 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1180
1283
  ${this.groupBar !== undefined ? this.groupBar({grouping: this.groupingKeys(), groupableColumns: this.groupableColumns(), applyGrouping: this.applyGrouping, clearGrouping: this.clearGrouping}) : html`<slot name="groupBar" data-rozie-params=${(() => { try { return JSON.stringify({grouping: this.groupingKeys(), groupableColumns: this.groupableColumns()}); } catch { return '{}'; } })()} @rozie-group-bar-apply-grouping=${($event: CustomEvent) => ((this.applyGrouping) as (...args: any[]) => any)($event.detail)} @rozie-group-bar-clear-grouping=${($event: CustomEvent) => ((this.clearGrouping) as (...args: any[]) => any)($event.detail)}>
1181
1284
  ${repeat<any>(this.groupingKeys(), (gk, _idx) => gk, (gk, _idx) => html`<span class="rdt-group-token" data-group-token="" data-rozie-s-d5dcab4c>${rozieDisplay(gk)}</span>`)}
1182
1285
  </slot>`}
1183
- </div>` : nothing}${this.virtual ? html`<div class="rdt-scroll" style=${rozieStyle(this.maxHeight ? 'max-height:' + this.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + this.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c>
1184
- <table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${rozieAttr(this.gridAriaRowCount())} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridKeyDown($event); }} @focusin=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridFocusOut($event); }} @mousedown=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridMouseDown($event); }} @dblclick=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridDblClick($event); }} @click=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridClick($event); }} data-rozie-s-d5dcab4c>
1286
+ </div>` : nothing}${this.isWindowed() ? html`<div class="rdt-scroll" style=${rozieStyle(this.rowsWindowed() && this.maxHeight ? 'max-height:' + this.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + this.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c>
1287
+ <table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader, 'rdt-col-windowed': this.colsWindowed() }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${rozieAttr(this.gridAriaRowCount())} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridKeyDown($event); }} @focusin=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridFocusOut($event); }} @mousedown=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridMouseDown($event); }} @dblclick=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridDblClick($event); }} @click=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridClick($event); }} data-rozie-s-d5dcab4c>
1185
1288
  <thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>
1186
1289
  ${repeat<any>(this._headerGroups.value, (hg, hgLevel) => hg.id, (hg, hgLevel) => html`<tr class="rdt-tr" role="row" aria-rowindex=${rozieAttr(hgLevel + 1)} data-rozie-s-d5dcab4c>
1187
- ${repeat<any>(hg.headers, (header, _idx) => header.id, (header, _idx) => html`<th class="${Object.entries({ "rdt-th": true, 'rdt-select-th': this.isSelectColumn(header.column.id), 'rdt-expander-th': this.isExpanderColumn(header.column.id), 'rdt-th-resizing': this.columnIsResizing(header.column.id), 'rdt-cell-active': this.isActiveCell('__header', this.headerColIndexOf(hg, header), hgLevel) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" 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(this.headerColIndexOf(hg, header))} tabindex=${rozieAttr(this.cellTabindex('__header', this.headerColIndexOf(hg, header), hgLevel))} aria-sort=${rozieAttr(this.ariaSortFor(header.column.id))} style=${rozieStyle(this.thStyle(header.column.id))} data-rozie-s-d5dcab4c>
1188
- ${this.isSelectColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
1290
+
1291
+ ${this.colsWindowed() ? html`<th class="rdt-col-spacer" aria-hidden="true" style=${rozieStyle('width:' + this.colPadLeft() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></th>` : nothing}${repeat<any>(this.windowedHeadersFor(hg, hgLevel), (wh, _idx) => wh.header.id, (wh, _idx) => html`<th class="${Object.entries({ "rdt-th": true, 'rdt-select-th': this.isSelectColumn(wh.header.column.id), 'rdt-expander-th': this.isExpanderColumn(wh.header.column.id), 'rdt-th-resizing': this.columnIsResizing(wh.header.column.id), 'rdt-cell-active': this.isActiveCell('__header', this.headerColIndexOf(hg, wh.header), hgLevel) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" 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(this.headerColIndexOf(hg, wh.header))} tabindex=${rozieAttr(this.cellTabindex('__header', this.headerColIndexOf(hg, wh.header), hgLevel))} aria-sort=${rozieAttr(this.ariaSortFor(wh.header.column.id))} style=${rozieStyle(this.thStyle(wh.header.column.id))} data-rozie-s-d5dcab4c>
1292
+ ${this.isSelectColumn(wh.header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
1189
1293
  ${this.selectAll !== undefined ? this.selectAll({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected(), toggle: this.onToggleAllRows}) : html`<slot name="selectAll" data-rozie-params=${(() => { try { return JSON.stringify({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected()}); } catch { return '{}'; } })()} @rozie-select-all-toggle=${($event: CustomEvent) => ((this.onToggleAllRows) as (...args: any[]) => any)($event.detail)}>
1190
1294
  ${this.selectionMode === 'multiple' ? html`<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" ?checked=${this.isAllRowsSelected()} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onToggleAllRows($event); }} data-rozie-s-d5dcab4c />` : nothing}</slot>`}
1191
- </span>` : this.isExpanderColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
1192
- ${header.column.getCanSort && header.column.getCanSort() ? html`<button class="rdt-sort-btn" type="button" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c>
1295
+ </span>` : this.isExpanderColumn(wh.header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
1296
+ ${wh.header.column.getCanSort && wh.header.column.getCanSort() ? html`<button class="rdt-sort-btn" type="button" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onHeaderSort(wh.header.column.id, $event); }} data-rozie-s-d5dcab4c>
1193
1297
  <span class="rdt-header-label" data-rozie-s-d5dcab4c>
1194
- ${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
1298
+ ${this.colHeader !== undefined ? this.colHeader({columnId: wh.header.column.id, column: wh.header.column, label: this.headerLabel(wh.header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: wh.header.column.id, column: wh.header.column, label: this.headerLabel(wh.header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(wh.header.column.id))}</slot>`}
1195
1299
  </span>
1196
- <span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>${rozieDisplay(this.sortIndicator(header.column.id))}</span>
1300
+ <span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>${rozieDisplay(this.sortIndicator(wh.header.column.id))}</span>
1197
1301
  </button>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
1198
1302
  <span class="rdt-header-label" data-rozie-s-d5dcab4c>
1199
- ${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
1303
+ ${this.colHeader !== undefined ? this.colHeader({columnId: wh.header.column.id, column: wh.header.column, label: this.headerLabel(wh.header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: wh.header.column.id, column: wh.header.column, label: this.headerLabel(wh.header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(wh.header.column.id))}</slot>`}
1200
1304
  </span>
1201
- </span>`}<rozie-popover trigger="click" placement="bottom-end" strategy="fixed" .offset=${4} data-rozie-s-d5dcab4c><button class="rdt-col-menu-trigger" type="button" aria-label=${rozieAttr('Column options for ' + this.headerLabel(header.column.id))} data-rozie-s-d5dcab4c slot="anchor">⋯</button><div class="rdt-col-menu" role="menu" data-rozie-s-d5dcab4c>
1202
- <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${this.columnPinSide(header.column.id) === 'left'} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>Pin left</button>
1203
- <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${this.columnPinSide(header.column.id) === 'right'} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>Pin right</button>
1204
- <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${!this.columnPinSide(header.column.id)} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>Unpin</button>
1305
+ </span>`}<rozie-popover trigger="click" placement="bottom-end" strategy="fixed" .offset=${4} data-rozie-s-d5dcab4c><button class="rdt-col-menu-trigger" type="button" aria-label=${rozieAttr('Column options for ' + this.headerLabel(wh.header.column.id))} data-rozie-s-d5dcab4c slot="anchor">⋯</button><div class="rdt-col-menu" role="menu" data-rozie-s-d5dcab4c>
1306
+ <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${this.columnPinSide(wh.header.column.id) === 'left'} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(wh.header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>Pin left</button>
1307
+ <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${this.columnPinSide(wh.header.column.id) === 'right'} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(wh.header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>Pin right</button>
1308
+ <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${!this.columnPinSide(wh.header.column.id)} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(wh.header.column.id, false, $event); }} data-rozie-s-d5dcab4c>Unpin</button>
1205
1309
  <hr class="rdt-col-menu-sep" data-rozie-s-d5dcab4c />
1206
- <button class="rdt-col-menu-item" type="button" role="menuitem" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onHideColumn(header.column.id, $event); }} data-rozie-s-d5dcab4c>Hide column</button>
1310
+ <button class="rdt-col-menu-item" type="button" role="menuitem" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onHideColumn(wh.header.column.id, $event); }} data-rozie-s-d5dcab4c>Hide column</button>
1207
1311
  </div></rozie-popover>
1208
- <button class="rdt-resize-handle" type="button" aria-label=${rozieAttr('Resize ' + this.headerLabel(header.column.id))} @pointerdown=${($event: PointerEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onResizeStart(header.column.id, $event); }} @touchstart=${($event: TouchEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button>
1312
+ <button class="rdt-resize-handle" type="button" aria-label=${rozieAttr('Resize ' + this.headerLabel(wh.header.column.id))} @pointerdown=${($event: PointerEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onResizeStart(wh.header.column.id, $event); }} @touchstart=${($event: TouchEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onResizeStart(wh.header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button>
1209
1313
  </span>`}</th>`)}
1210
- </tr>`)}
1314
+
1315
+ ${this.colsWindowed() ? html`<th class="rdt-col-spacer" aria-hidden="true" style=${rozieStyle('width:' + this.colPadRight() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></th>` : nothing}</tr>`)}
1211
1316
 
1212
1317
  ${this.hasAnyFilterableColumn() ? html`<tr class="rdt-filter-row" data-rozie-s-d5dcab4c>
1213
- ${repeat<any>(this._headerGroups.value[this._headerGroups.value.length - 1].headers, (header, _idx) => header.id, (header, _idx) => html`<th class="rdt-filter-cell" role="presentation" style=${rozieStyle(this.pinStyle(header.column.id))} data-rozie-s-d5dcab4c>
1214
- ${this.isSelectColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : this.isExpanderColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
1215
- ${this.columnIsFilterable(header.column.id) && !this.hasFilterSlot() ? html`<input class="rdt-col-filter" type="text" aria-label=${rozieAttr('Filter ' + this.headerLabel(header.column.id))} .value=${this.columnFilterValue(header.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onColumnFilterInput(header.column.id, $event); }} @click=${($event: MouseEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.stopEvent($event); }} data-rozie-s-d5dcab4c />` : nothing}${this.columnIsFilterable(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
1216
- ${this.filter !== undefined ? this.filter({columnId: header.column.id, value: this.columnFilterValue(header.column.id), uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id), setFilter: this.setColumnFilter}) : html`<slot name="filter" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, value: this.columnFilterValue(header.column.id), uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id)}); } catch { return '{}'; } })()} @rozie-filter-set-filter=${($event: CustomEvent) => ((this.setColumnFilter) as (...args: any[]) => any)($event.detail)}></slot>`}
1318
+ ${this.colsWindowed() ? html`<th class="rdt-col-spacer" aria-hidden="true" style=${rozieStyle('width:' + this.colPadLeft() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></th>` : nothing}${repeat<any>(this.windowedHeadersFor(this._headerGroups.value[this._headerGroups.value.length - 1], this._headerGroups.value.length - 1), (wh, _idx) => wh.header.id, (wh, _idx) => html`<th class="rdt-filter-cell" role="presentation" data-col=${rozieAttr(wh.header.column.id)} style=${rozieStyle(this.pinStyle(wh.header.column.id))} data-rozie-s-d5dcab4c>
1319
+ ${this.isSelectColumn(wh.header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : this.isExpanderColumn(wh.header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
1320
+ ${this.columnIsFilterable(wh.header.column.id) && !this.hasFilterSlot() ? html`<input class="rdt-col-filter" type="text" aria-label=${rozieAttr('Filter ' + this.headerLabel(wh.header.column.id))} .value=${this.columnFilterValue(wh.header.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onColumnFilterInput(wh.header.column.id, $event); }} @click=${($event: MouseEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.stopEvent($event); }} data-rozie-s-d5dcab4c />` : nothing}${this.columnIsFilterable(wh.header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
1321
+ ${this.filter !== undefined ? this.filter({columnId: wh.header.column.id, value: this.columnFilterValue(wh.header.column.id), uniqueValues: this.getFacetedUniqueValues(wh.header.column.id), minMax: this.getFacetedMinMaxValues(wh.header.column.id), setFilter: this.setColumnFilter}) : html`<slot name="filter" data-rozie-params=${(() => { try { return JSON.stringify({columnId: wh.header.column.id, value: this.columnFilterValue(wh.header.column.id), uniqueValues: this.getFacetedUniqueValues(wh.header.column.id), minMax: this.getFacetedMinMaxValues(wh.header.column.id)}); } catch { return '{}'; } })()} @rozie-filter-set-filter=${($event: CustomEvent) => ((this.setColumnFilter) as (...args: any[]) => any)($event.detail)}></slot>`}
1217
1322
  </span>` : nothing}</span>`}</th>`)}
1218
- </tr>` : nothing}</thead>
1323
+ ${this.colsWindowed() ? html`<th class="rdt-col-spacer" aria-hidden="true" style=${rozieStyle('width:' + this.colPadRight() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></th>` : nothing}</tr>` : nothing}</thead>
1219
1324
 
1220
1325
  <tbody class="rdt-tbody" role="rowgroup" data-rozie-s-d5dcab4c>
1221
1326
 
1222
1327
  <tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c>
1223
- <td colspan=${rozieAttr(this.visibleColCount())} style=${rozieStyle('height:' + this.padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>
1328
+ <td colspan=${rozieAttr(this.windowedColSpan())} style=${rozieStyle('height:' + this.padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>
1224
1329
  </tr>
1225
1330
 
1226
1331
  ${repeat<any>(this.windowedRows(), (wr, _idx) => wr.row.id, (wr, _idx) => html`
1227
1332
  <tr class="${Object.entries({ "rdt-tr": true, 'rdt-group-header': this.rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="row" data-row=${rozieAttr(wr.vi.index)} aria-rowindex=${rozieAttr(this.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(this.rowIsGrouped(wr.row) ? wr.row.id : null)} data-group-leaf=${rozieAttr(this.groupingActive() && !this.rowIsGrouped(wr.row) ? wr.row.id : null)} aria-expanded=${rozieAttr(this.rowIsGrouped(wr.row) ? !!this.rowIsExpanded(wr.row) : null)} aria-selected=${rozieAttr(this.selectionMode !== 'none' ? !!this.rowIsSelected(wr.row) : null)} aria-level=${rozieAttr(this.groupingActive() ? wr.row.depth + 1 : null)} data-rozie-s-d5dcab4c>
1228
- ${repeat<any>(this.visibleCellsFor(wr.row), (cell, _idx) => cell.id, (cell, _idx) => html`<td class="${Object.entries({ "rdt-td": true, 'rdt-select-td': this.isSelectColumn(cell.column.id), 'rdt-expander-td': this.isExpanderColumn(cell.column.id), 'rdt-in-range': this.inRange(wr.vi.index, this.colIndexOf(wr.row, cell)), 'rdt-cell-active': this.isActiveCell(String(wr.vi.index), this.colIndexOf(wr.row, cell)) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.cellRole())} data-col=${rozieAttr(cell.column.id)} data-grid-cell="" data-row=${rozieAttr(wr.vi.index)} data-col-index=${rozieAttr(this.colIndexOf(wr.row, cell))} tabindex=${rozieAttr(this.cellTabindex(String(wr.vi.index), this.colIndexOf(wr.row, cell)))} style=${rozieStyle(this.bodyCellStyle(wr.row, cell.column.id))} aria-invalid=${rozieAttr(this.cellAriaInvalid(wr.vi.index, this.colIndexOf(wr.row, cell)))} data-in-range=${rozieAttr(this.inRange(wr.vi.index, this.colIndexOf(wr.row, cell)) ? 'true' : null)} data-agg-cell=${rozieAttr(this.cellIsAggregated(cell) ? cell.column.id : null)} data-rozie-s-d5dcab4c>
1333
+
1334
+ ${this.colsWindowed() ? html`<td class="rdt-col-spacer" aria-hidden="true" style=${rozieStyle('width:' + this.colPadLeft() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>` : nothing}${repeat<any>(this.windowedCells(wr.row), (cell, _idx) => cell.id, (cell, _idx) => html`<td class="${Object.entries({ "rdt-td": true, 'rdt-select-td': this.isSelectColumn(cell.column.id), 'rdt-expander-td': this.isExpanderColumn(cell.column.id), 'rdt-in-range': this.inRange(wr.vi.index, this.colIndexOf(wr.row, cell)), 'rdt-cell-active': this.isActiveCell(String(wr.vi.index), this.colIndexOf(wr.row, cell)) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.cellRole())} data-col=${rozieAttr(cell.column.id)} data-grid-cell="" data-row=${rozieAttr(wr.vi.index)} data-col-index=${rozieAttr(this.colIndexOf(wr.row, cell))} tabindex=${rozieAttr(this.cellTabindex(String(wr.vi.index), this.colIndexOf(wr.row, cell)))} style=${rozieStyle(this.bodyCellStyle(wr.row, cell.column.id))} aria-invalid=${rozieAttr(this.cellAriaInvalid(wr.vi.index, this.colIndexOf(wr.row, cell)))} data-in-range=${rozieAttr(this.inRange(wr.vi.index, this.colIndexOf(wr.row, cell)) ? 'true' : null)} data-agg-cell=${rozieAttr(this.cellIsAggregated(cell) ? cell.column.id : null)} data-rozie-s-d5dcab4c>
1229
1335
 
1230
1336
  ${this.isExpanderColumn(cell.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
1231
1337
  ${this.rowCanExpand(wr.row) ? html`<button class="rdt-expander" type="button" data-expander="" aria-expanded=${!!this.rowIsExpanded(wr.row)} aria-label=${rozieAttr(this.rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(wr.row) ? '▾' : '▸')}</button>` : nothing}</span>` : this.isSelectColumn(cell.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
@@ -1246,16 +1352,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1246
1352
  </select>` : this.editorTypeOf(cell.column.id) === 'checkbox' ? html`<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" ?checked=${this.editorCheckedFor(cell.column.id)} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorCheckbox(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : html`<input class="rdt-cell-editor" type="text" data-editing-cell="" .value=${this.editorValueFor(cell.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />`}</span>` : this.cellIsPlaceholder(cell) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span class="rdt-cell-value" data-rozie-s-d5dcab4c>
1247
1353
  ${this.cell !== undefined ? this.cell({columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cell.getValue())}</slot>`}
1248
1354
  </span>`}${this.isFillHandleCell(wr.vi.index, this.colIndexOf(wr.row, cell)) ? html`<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown=${($event: PointerEvent & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c></span>` : nothing}</td>`)}
1249
- </tr>
1355
+
1356
+ ${this.colsWindowed() ? html`<td class="rdt-col-spacer" aria-hidden="true" style=${rozieStyle('width:' + this.colPadRight() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>` : nothing}</tr>
1250
1357
 
1251
1358
  ${this.rowShowsDetail(wr.row) ? html`<tr class="rdt-detail-row" role="row" data-detail-row=${rozieAttr(wr.row.id)} data-rozie-s-d5dcab4c>
1252
- <td class="rdt-detail-cell" colspan=${rozieAttr(this.visibleColCount())} data-rozie-s-d5dcab4c>
1359
+ <td class="rdt-detail-cell" colspan=${rozieAttr(this.windowedColSpan())} data-rozie-s-d5dcab4c>
1253
1360
  ${this.detail !== undefined ? this.detail({row: wr.row.original}) : html`<slot name="detail" data-rozie-params=${(() => { try { return JSON.stringify({row: wr.row.original}); } catch { return '{}'; } })()}></slot>`}
1254
1361
  </td>
1255
1362
  </tr>` : nothing}`)}
1256
1363
 
1257
1364
  <tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c>
1258
- <td colspan=${rozieAttr(this.visibleColCount())} style=${rozieStyle('height:' + this.padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>
1365
+ <td colspan=${rozieAttr(this.windowedColSpan())} style=${rozieStyle('height:' + this.padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>
1259
1366
  </tr>
1260
1367
  </tbody>
1261
1368
  </table>
@@ -1334,7 +1441,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1334
1441
  </td>
1335
1442
  </tr>` : nothing}`)}
1336
1443
  </tbody>
1337
- </table>`}${!this.virtual ? html`<div class="rdt-pagination" role="group" aria-label="Pagination" data-rozie-s-d5dcab4c>
1444
+ </table>`}${!this.rowsWindowed() ? html`<div class="rdt-pagination" role="group" aria-label="Pagination" data-rozie-s-d5dcab4c>
1338
1445
  <button class="rdt-page-btn rdt-page-prev" type="button" ?disabled=${!this.canPrevPage()} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPrevPage(); }} data-rozie-s-d5dcab4c>Prev</button>
1339
1446
  <span class="rdt-page-status" aria-live="polite" data-rozie-s-d5dcab4c>
1340
1447
  ${rozieDisplay('Page ' + (this.pageIndex() + 1) + ' of ' + this.displayPageCount())}
@@ -1363,6 +1470,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1363
1470
 
1364
1471
  gridScrollEl: any = null;
1365
1472
 
1473
+ // ── Horizontal (column-axis) windowing instance state (Phase 87 87-04) ─────────────────
1474
+ // The SECOND, independent virtual-core instance (AXIS MECHANISM, windowing.rzts): mutable
1475
+ // top-level `let`s (the `virtualizer` precedent above — React hoists to useRef). NULL until
1476
+ // $onMount, and ONLY constructed when colsWindowed(). Shares gridScrollEl (the SAME
1477
+ // .rdt-scroll element) with the row instance — D-03.
1478
+ colVirtualizer: any = null;
1479
+
1480
+ colVirtualizerCleanup: any = null;
1481
+
1366
1482
  // CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
1367
1483
  // rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
1368
1484
  // pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
@@ -1374,6 +1490,19 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1374
1490
  // once motion settles between scroll steps) is unaffected.
1375
1491
  remeasurePending = false;
1376
1492
 
1493
+ // Gap-closure 87-13: the deferred sweep's own rAF handle, plus a disposed latch. Until 87-07 a
1494
+ // post-unmount sweep was inert — it re-measured a detached tree and wrote nothing. 87-07's D-15
1495
+ // re-feed changed that: remeasureWindow() now reaches refineRowEstimate(), which calls
1496
+ // virtualizer.setOptions()/_willUpdate(), mutates gridScrollEl.scrollTop, and WRITES
1497
+ // $data.windowVer. remeasureWindow()'s existing `!virtualizer || !gridRoot` guard cannot catch
1498
+ // this — neither ref is ever nulled after $onMount assigns it — so a scroll/resize burst that
1499
+ // leaves a pass pending across unmount (a route transition mid-scroll) lands a state write on a
1500
+ // torn-down instance. Handle init is `null`, never 0: an Angular leaf types this as the DOM
1501
+ // handle and TS2322s on a numeric seed.
1502
+ remeasureRaf: any = null;
1503
+
1504
+ remeasureDisposed = false;
1505
+
1377
1506
  // ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
1378
1507
  // Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
1379
1508
  // via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
@@ -1533,34 +1662,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1533
1662
  // re-feed sources here so editing works whether or not the consumer binds r-model:data.
1534
1663
  currentData = (): any => this.data != null ? this.data : this._dataDefault.value;
1535
1664
 
1536
- // Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
1537
- // applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
1538
- // byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
1539
- // cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
1540
- // render callbacks — cells render via the single #cell/#header scoped slot on this
1541
- // component, dispatched by columnId; <Column> carries metadata only.)
1542
- isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
1543
-
1544
- // wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
1545
- // aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
1546
- // anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
1547
- // 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
1548
- // built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
1549
- // is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
1550
- // throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
1551
- // Anything else → undefined (no aggregation; the cell renders as a placeholder).
1552
- wrapAggregationFn = (fn: any) => {
1553
- if (typeof fn === 'string') return fn;
1554
- if (typeof fn !== 'function') return undefined;
1555
- return (columnId: any, leafRows: any, childRows: any) => {
1556
- try {
1557
- return fn(columnId, leafRows, childRows);
1558
- } catch (err: any) {
1559
- return undefined;
1560
- }
1561
- };
1562
- };
1563
-
1564
1665
  // Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
1565
1666
  // ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
1566
1667
  // ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
@@ -1590,7 +1691,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1590
1691
  if (gid == null) gid = c.header != null ? '__grp_' + kids.map((k: any) => k.id).join('_') : null;
1591
1692
  if (gid == null) return null;
1592
1693
  const id = String(gid);
1593
- if (!this.isSafeKey(id)) return null;
1694
+ if (!isSafeKey(id)) return null;
1594
1695
  return {
1595
1696
  id,
1596
1697
  header: c.header != null ? c.header : id,
@@ -1600,7 +1701,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1600
1701
  const rawId = c.id != null ? c.id : c.field;
1601
1702
  if (rawId == null) return null;
1602
1703
  const id = String(rawId);
1603
- if (!this.isSafeKey(id)) return null;
1704
+ if (!isSafeKey(id)) return null;
1604
1705
  return {
1605
1706
  id,
1606
1707
  accessorKey: c.field != null ? c.field : id,
@@ -1618,7 +1719,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1618
1719
  // aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
1619
1720
  // a custom fn defensively wrapped (T-50-04).
1620
1721
  groupable: c.groupable !== false,
1621
- aggregationFn: this.wrapAggregationFn(c.aggregationFn),
1722
+ aggregationFn: wrapAggregationFn(c.aggregationFn),
1622
1723
  pinned: c.pinned != null ? c.pinned : '',
1623
1724
  width: c.width != null ? c.width : '',
1624
1725
  // Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
@@ -1632,10 +1733,51 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1632
1733
  };
1633
1734
  };
1634
1735
 
1736
+ // ══ D-23 (gap-closure, Phase 87 follow-up) — columnDefs() memoization ═══════════════════
1737
+ // ROOT CAUSE (confirmed via CPU profiling the D-13 [vue] fill-drag auto-scroll failure):
1738
+ // defFor(colId) (columnChrome.rzts) — the sole resolver behind editMetaOf/columnEditable/
1739
+ // editorTypeOf/isEditing, all called PER CELL, PER RENDER (isEditing's own header comment:
1740
+ // "Called per-cell in both <td> bodies") — called columnDefs() with ZERO caching. columnDefs()
1741
+ // rebuilds the FULL column-def array (buildConfigDef for every entry in $props.columns,
1742
+ // including recursive group traversal) from scratch on every single invocation. With N
1743
+ // columns and M rendered rows that is an O(N x M x N) = O(N^2 x M) cost PER RENDER COMMIT —
1744
+ // on this plan's 60-column fixture, up to ~1200 cells x 60 rebuilt defs = ~72,000
1745
+ // buildConfigDef calls per commit, repeated on every windowVer-driven re-render during a
1746
+ // sustained scroll gesture (the fill-drag edge-auto-scroll rAF loop bumps windowVer on
1747
+ // every frame). A CPU profile of the failing case showed buildConfigDef consuming ~39% of
1748
+ // ALL wall-clock time during the gesture, with a FURTHER ~27% in Vue's own reactive-Proxy
1749
+ // `get` trap — $props.columns/$data.colReg are reactive on Vue, so every one of the
1750
+ // ~15 property reads buildConfigDef performs per column pays Vue's track()/Reflect.get
1751
+ // overhead, an expense React's plain (non-Proxy) props do not pay. The SAME O(N^2 x M)
1752
+ // algorithmic cost exists on all six targets (this is shared-engine code, dissolved
1753
+ // identically everywhere) — Vue's per-access reactivity overhead is what tips an already
1754
+ // wasteful recompute over the fixed real-time budget used by this test's rAF-driven
1755
+ // auto-scroll hold, not a Vue-specific logic defect. Fixing the recompute (not the timing)
1756
+ // removes the accidental n^2 blowup for every target, and Vue-specifically restores enough
1757
+ // per-frame headroom to keep pace with the auto-scroll loop.
1758
+ //
1759
+ // FIX: cache the resolved array, keyed on the REFERENCE identity of $props.columns and
1760
+ // $data.colReg — the exact two dependencies the reFeed() $watch (DataTable.rozie) already
1761
+ // treats as "changed only when the reference changes" (columns is consumer-memoized like
1762
+ // $props.data/$props.sorting; colReg is reassigned via `{ ...colReg, [key]: spec }` on every
1763
+ // real mutation, NEVER written in place — see DataTable.rozie's own colReg-mutation comment).
1764
+ // A cache hit is an O(1) reference-equality check; a cache miss re-runs the exact same
1765
+ // computation as before (byte-identical output either way — this is a pure memoization, not
1766
+ // a behavior change).
1767
+ columnDefsCache: any[] | null = null;
1768
+
1769
+ columnDefsCacheColumnsRef: any = undefined;
1770
+
1771
+ columnDefsCacheColRegRef: any = undefined;
1772
+
1635
1773
  columnDefs = () => {
1774
+ const cfg = this.columns || [];
1775
+ const reg = this._colReg.value || {};
1776
+ if (this.columnDefsCache && this.columns === this.columnDefsCacheColumnsRef && this._colReg.value === this.columnDefsCacheColRegRef) {
1777
+ return this.columnDefsCache;
1778
+ }
1636
1779
  const byId = Object.create(null);
1637
1780
  const order = [];
1638
- const cfg = this.columns || [];
1639
1781
  for (const c of cfg as any) {
1640
1782
  const def = this.buildConfigDef(c);
1641
1783
  if (!def) continue;
@@ -1643,9 +1785,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1643
1785
  if (!(id in byId)) order.push(id);
1644
1786
  byId[id] = def;
1645
1787
  }
1646
- const reg = this._colReg.value || {};
1647
1788
  for (const id in reg) {
1648
- if (!this.isSafeKey(id)) continue;
1789
+ if (!isSafeKey(id)) continue;
1649
1790
  const spec = reg[id];
1650
1791
  if (!spec) continue;
1651
1792
  if (!(id in byId)) order.push(id);
@@ -1660,7 +1801,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1660
1801
  expandable: spec.expandable === true,
1661
1802
  // Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
1662
1803
  groupable: spec.groupable !== false,
1663
- aggregationFn: this.wrapAggregationFn(spec.aggregationFn),
1804
+ aggregationFn: wrapAggregationFn(spec.aggregationFn),
1664
1805
  pinned: spec.pinned != null ? spec.pinned : '',
1665
1806
  width: spec.width != null ? spec.width : '',
1666
1807
  // Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
@@ -1674,6 +1815,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1674
1815
  }
1675
1816
  const out = [];
1676
1817
  for (const id of order as any) if (byId[id]) out.push(byId[id]);
1818
+ this.columnDefsCache = out;
1819
+ this.columnDefsCacheColumnsRef = this.columns;
1820
+ this.columnDefsCacheColRegRef = this._colReg.value;
1677
1821
  return out;
1678
1822
  };
1679
1823
 
@@ -1762,8 +1906,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1762
1906
  this.programmatic--;
1763
1907
  };
1764
1908
 
1765
- applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
1766
-
1767
1909
  // ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
1768
1910
  // table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
1769
1911
  // applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
@@ -2145,51 +2287,51 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2145
2287
  // fresh (recreated each render on React, reading fresh currentState) in the re-feed
2146
2288
  // keeps the Updater base value current. No-op cost on the other five.
2147
2289
  onSortingChangeCb = (updater: any) => {
2148
- this.writeSorting(this.applyUpdater(updater, this.currentState().sorting));
2290
+ this.writeSorting(applyUpdater(updater, this.currentState().sorting));
2149
2291
  };
2150
2292
 
2151
2293
  onExpandedChangeCb = (updater: any) => {
2152
- this.writeExpanded(this.applyUpdater(updater, this.currentState().expanded));
2294
+ this.writeExpanded(applyUpdater(updater, this.currentState().expanded));
2153
2295
  };
2154
2296
 
2155
2297
  onGroupingChangeCb = (updater: any) => {
2156
- this.writeGrouping(this.applyUpdater(updater, this.currentState().grouping));
2298
+ this.writeGrouping(applyUpdater(updater, this.currentState().grouping));
2157
2299
  };
2158
2300
 
2159
2301
  onGlobalFilterChangeCb = (updater: any) => {
2160
- this.writeGlobalFilter(this.applyUpdater(updater, this.currentState().globalFilter));
2302
+ this.writeGlobalFilter(applyUpdater(updater, this.currentState().globalFilter));
2161
2303
  };
2162
2304
 
2163
2305
  onColumnFiltersChangeCb = (updater: any) => {
2164
- this.writeColumnFilters(this.applyUpdater(updater, this.currentState().columnFilters));
2306
+ this.writeColumnFilters(applyUpdater(updater, this.currentState().columnFilters));
2165
2307
  };
2166
2308
 
2167
2309
  onPaginationChangeCb = (updater: any) => {
2168
- this.writePagination(this.applyUpdater(updater, this.currentState().pagination));
2310
+ this.writePagination(applyUpdater(updater, this.currentState().pagination));
2169
2311
  };
2170
2312
 
2171
2313
  onRowSelectionChangeCb = (updater: any) => {
2172
- this.writeRowSelection(this.applyUpdater(updater, this.currentState().rowSelection));
2314
+ this.writeRowSelection(applyUpdater(updater, this.currentState().rowSelection));
2173
2315
  };
2174
2316
 
2175
2317
  onColumnVisibilityChangeCb = (updater: any) => {
2176
- this.writeColumnVisibility(this.applyUpdater(updater, this.currentState().columnVisibility));
2318
+ this.writeColumnVisibility(applyUpdater(updater, this.currentState().columnVisibility));
2177
2319
  };
2178
2320
 
2179
2321
  onColumnSizingChangeCb = (updater: any) => {
2180
- this.writeColumnSizing(this.applyUpdater(updater, this.currentState().columnSizing));
2322
+ this.writeColumnSizing(applyUpdater(updater, this.currentState().columnSizing));
2181
2323
  };
2182
2324
 
2183
2325
  onColumnOrderChangeCb = (updater: any) => {
2184
- this.writeColumnOrder(this.applyUpdater(updater, this.currentState().columnOrder));
2326
+ this.writeColumnOrder(applyUpdater(updater, this.currentState().columnOrder));
2185
2327
  };
2186
2328
 
2187
2329
  onColumnPinningChangeCb = (updater: any) => {
2188
- this.writeColumnPinning(this.applyUpdater(updater, this.currentState().columnPinning));
2330
+ this.writeColumnPinning(applyUpdater(updater, this.currentState().columnPinning));
2189
2331
  };
2190
2332
 
2191
2333
  onColumnSizingInfoChangeCb = (updater: any) => {
2192
- const next = this.applyUpdater(updater, this._columnSizingInfo.value);
2334
+ const next = applyUpdater(updater, this._columnSizingInfo.value);
2193
2335
  this._columnSizingInfo.value = next != null ? next : this._columnSizingInfo.value;
2194
2336
  };
2195
2337
 
@@ -2209,13 +2351,157 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2209
2351
  // dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
2210
2352
  // windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
2211
2353
  // scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.
2354
+ // ══ D-05 predicate layer (Phase 87 87-02) — resolves the windowing prop into an axis state,
2355
+ // replacing every bare truthiness read of $props.virtual (which is unsafe once the prop widens
2356
+ // to a string grammar in 87-03: 'columns' is truthy but does NOT mean "windows rows"). Modeled on
2357
+ // resolveAppendTo() in CommandPalette.rozie (the D-01 value-grammar precedent): branch on
2358
+ // typeof === 'string', switch on the known string values, fall back to the boolean cases. THIS
2359
+ // plan still declares `virtual: Boolean` (87-03 widens the declaration to [Boolean, String]), so
2360
+ // resolveVirtual() can currently only return 'off' or 'rows' and colsWindowed() is constantly
2361
+ // false — inert until 87-03/87-04 land the column axis.
2362
+ //
2363
+ // Declared BEFORE windowSource() (below): windowSource() calls rowsWindowed(), and the React
2364
+ // emitter lowers each of these to a useCallback whose dependency array is evaluated eagerly at
2365
+ // the declaration site — a forward reference here is a genuine TDZ (TS2448), not just a style
2366
+ // preference (found running the strict react typecheck gate this task, Rule 1).
2367
+ resolveVirtual = () => {
2368
+ const v = this.virtual;
2369
+ if (typeof v === 'string') {
2370
+ if (v === 'rows') return 'rows';
2371
+ if (v === 'columns') return 'columns';
2372
+ if (v === 'both') return 'both';
2373
+ return 'off';
2374
+ }
2375
+ return v === true ? 'rows' : 'off';
2376
+ };
2377
+
2378
+ // rowsWindowed(): the windowing.rzts host-contract symbol (D-05). Byte-behaviorally identical to
2379
+ // today's `$props.virtual` truthiness — this plan changes NO user-visible behavior.
2380
+ rowsWindowed = () => {
2381
+ const s = this.resolveVirtual();
2382
+ return s === 'rows' || s === 'both';
2383
+ };
2384
+
2385
+ colsWindowed = () => {
2386
+ const s = this.resolveVirtual();
2387
+ return s === 'columns' || s === 'both';
2388
+ };
2389
+
2390
+ isWindowed = () => this.rowsWindowed() || this.colsWindowed();
2391
+
2392
+ // autoMeasureOn() (Phase 87 87-07, D-18): the windowing.rzts host-contract gate for the
2393
+ // running-mean row-size estimator. Real body here: $props.autoMeasure === true. listbox/
2394
+ // combobox define this as a permanent `() => false` one-liner (D-20) so estimateRowSize()'s
2395
+ // accumulator branch is dead code for them.
2396
+ autoMeasureOn = () => this.autoMeasure === true;
2397
+
2398
+ // D-10 column-axis host-contract symbols. columnCount()/columnSize() got REAL bodies in
2399
+ // 87-04 (the tracer); forcedColumns() stays an empty-array stub until 87-05 gives it the
2400
+ // pinned + active-cell + editing-column union. Explicit return-type annotations (columnSize /
2401
+ // forcedColumns) copy the pinMeasurement() trick (windowing.rzts) so the strict bundled-leaf
2402
+ // tsc does not flow-narrow a no-op host's return to `never`.
2403
+ //
2404
+ // columnCount(): the leaf-column count the column virtualizer windows over — visibleColCount()
2405
+ // (gridFocusNav.rzts), the SAME index space colIndexOf()/data-col-index already address (D-08).
2406
+ columnCount = (): number => this.visibleColCount();
2407
+
2408
+ // columnSize(i): the authoritative width of absolute leaf column i (D-06). Reads table-core's
2409
+ // OWN getVisibleLeafColumns() directly — NOT the host's allLeafColumns() wrapper
2410
+ // (columnChrome.rzts), which returns plain `{ id, label, visible }` POJOs with no getSize()
2411
+ // and also EXCLUDES the auto-injected chrome columns (select/expander), breaking the index
2412
+ // alignment with columnCount()/colIndexOf(). getVisibleLeafColumns() is the array
2413
+ // row.getVisibleCells() itself is built from, so its index space matches exactly. Falls back
2414
+ // to table-core's own default column width (150) when the index is out of range.
2415
+ columnSize = (i: number): number => {
2416
+ if (!this.table || !this.table.getVisibleLeafColumns) return 150;
2417
+ const cols = this.table.getVisibleLeafColumns();
2418
+ const c = cols[i];
2419
+ return c && typeof c.getSize === 'function' ? c.getSize() : 150;
2420
+ };
2421
+
2422
+ // forcedColumns() (Phase 87 87-05, D-10): the column-axis mirror of pinnedEditIndex()/
2423
+ // pinnedMeasurement() (the row axis's D-05 pin-extension hook) — unions pinned + active-cell +
2424
+ // editing column indices into the window slice so none of them ever recycle out from under the
2425
+ // user. Reads table.getVisibleLeafColumns() DIRECTLY (the SAME array columnCount()/columnSize()
2426
+ // index against, and the array row.getVisibleCells() is itself built from) — NOT
2427
+ // allLeafColumns() (columnChrome.rzts), which excludes the auto-injected chrome columns and
2428
+ // returns plain POJOs with no getSize(), breaking the index alignment (the exact columnSize()
2429
+ // Rule-1 fix from 87-04, mirrored here rather than repeated).
2430
+ //
2431
+ // The fill-handle corner is DELIBERATELY not forced (D-10 explicit) — no call to
2432
+ // isFillHandleCell here. The whole selected RANGE is not forced either: a range's moving
2433
+ // corner tracks into $data.activeColIndex (extendRange/setRangeFocus), so even a 200-column
2434
+ // range forces at most the ONE active-cell column via the branch below, never the full span —
2435
+ // forcing the whole range would defeat the feature precisely where it is most wanted (D-10).
2436
+ forcedColumns = (): number[] => {
2437
+ if (!this.colsWindowed()) return [];
2438
+ const out = [];
2439
+ if (this.table && this.table.getVisibleLeafColumns) {
2440
+ const cols = this.table.getVisibleLeafColumns();
2441
+ for (let i = 0; i < cols.length; i++) {
2442
+ const c = cols[i];
2443
+ if (c && c.getIsPinned && c.getIsPinned()) out.push(i);
2444
+ }
2445
+ }
2446
+ // Active cell's column (D-10): required or focusActiveCell()'s [data-col-index="N"]
2447
+ // querySelector cannot resolve for an off-window active cell. Body cells only — a header-
2448
+ // active cell has no column-axis rendering dependency here (headers window separately).
2449
+ if (!this._activeIsHeader.value && this._activeColIndex.value >= 0 && out.indexOf(this._activeColIndex.value) === -1) {
2450
+ out.push(this._activeColIndex.value);
2451
+ }
2452
+ // Editing column (D-10): required or an open editor unmounts mid-keystroke when a
2453
+ // horizontal scroll recycles it — the exact failure D-02 was built to fix on the row axis
2454
+ // (T-87-05-01, the phase's one HIGH-severity threat). Single-cell edit mode only
2455
+ // ($data.editingRow/editingCol, mirroring editingColumnId()'s own index pair) — full-row
2456
+ // edit mode (editingRowIndex) has no single "the editing column" to force.
2457
+ if (this._editingRow.value >= 0 && this._editingCol.value >= 0 && out.indexOf(this._editingCol.value) === -1) {
2458
+ out.push(this._editingCol.value);
2459
+ }
2460
+ return out;
2461
+ };
2462
+
2463
+ // windowedCells(row) (D-09): the TEMPLATE-ONLY windowed slice of a row's visible cells — wraps
2464
+ // visibleCellsFor(row) (columnChrome.rzts) WITHOUT modifying it. Degrades to the full,
2465
+ // unsliced set when columns are not windowed (byte-identical to today). visibleCellsFor()
2466
+ // itself must never be sliced: colIndexOf/visibleColCount/columnIdAt/cellValueAt/beginEdit all
2467
+ // index into its FULL result (the A2 invariant, 87-02), and slicing it would silently break
2468
+ // all five with no compile error.
2469
+ windowedCells = (row: any) => {
2470
+ // subscribe-first (the windowedRows() discipline) so the fine-grained targets' body <td>
2471
+ // r-for re-derives when the column window moves.
2472
+ void this._windowVer.value;
2473
+ const cells = this.visibleCellsFor(row);
2474
+ if (!this.colsWindowed()) return cells;
2475
+ const idx = this.windowedColIndices();
2476
+ const out = [];
2477
+ for (let i = 0; i < idx.length; i++) {
2478
+ // WR-01 shrink-window guard, one axis over: drop any index that outruns the row's
2479
+ // current cell list (a brief stale-count window analogous to windowedRows()'s WR-01).
2480
+ const cell = cells[idx[i]];
2481
+ if (cell) out.push(cell);
2482
+ }
2483
+ return out;
2484
+ };
2485
+
2486
+ // remeasureColumnWindow() (Phase 87 87-04) is declared in DataTable.rozie's OWN script, NOT
2487
+ // here — even though it is conceptually this file's D-10 host-shell twin of remeasureWindow()
2488
+ // above. Reason (Rule 1, found via the react typecheck gate): remeasureColumnWindow() must call
2489
+ // columnVirtualizerOptions() (headless-core windowing.rzts). inlineScriptPartials() groups a
2490
+ // RELATIVE partial import (this file) BEFORE a bare-package-specifier partial import
2491
+ // (@rozie-ui/headless-core/windowing.rzts) in the merged per-target output, regardless of which
2492
+ // import statement appears first in DataTable.rozie's own script (verified: columnVirtualizerOptions
2493
+ // emits as a `useCallback` LATER in the react output than every symbol this file exports). A
2494
+ // function declared HERE calling INTO windowing.rzts is therefore a forward reference — the
2495
+ // exact TS2448 TDZ class the 87-02 predicate-layer reordering already fixed once. DataTable.rozie's
2496
+ // OWN top-level script code lands AFTER both partial groups, so remeasureColumnWindow() is safe
2497
+ // declared there instead (see the comment beside windowedHeaderRow()/windowedColSpan()).
2212
2498
  // windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
2213
2499
  // host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
2214
2500
  // (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
2215
2501
  // else the normal (paginated) row model — the non-virtual path is byte-unchanged.
2216
2502
  windowSource = () => {
2217
2503
  if (!this.table) return [];
2218
- if (this.virtual) return this.table.getPrePaginationRowModel().rows;
2504
+ if (this.rowsWindowed()) return this.table.getPrePaginationRowModel().rows;
2219
2505
  return this.table.getRowModel().rows;
2220
2506
  };
2221
2507
 
@@ -2238,6 +2524,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2238
2524
  // Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
2239
2525
  // already-observed node, so running both is cheap and loop-free.
2240
2526
  scheduleRemeasure = () => {
2527
+ // 87-13: never arm a new sweep after teardown — an onChange can still fire from a
2528
+ // virtualizer whose own ResizeObserver cleanup has run but whose queued tick was already
2529
+ // in flight.
2530
+ if (this.remeasureDisposed) return;
2241
2531
  if (this.remeasurePending) return;
2242
2532
  this.remeasurePending = true;
2243
2533
  let ranMicro = false;
@@ -2245,6 +2535,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2245
2535
  this.remeasureWindow();
2246
2536
  };
2247
2537
  const rafPass = () => {
2538
+ this.remeasureRaf = null;
2248
2539
  this.remeasurePending = false;
2249
2540
  this.remeasureWindow();
2250
2541
  };
@@ -2252,7 +2543,25 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2252
2543
  ranMicro = true;
2253
2544
  queueMicrotask(microPass);
2254
2545
  }
2255
- if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
2546
+ // 87-13: retain the handle so teardownRemeasure() can cancel a pass that is still pending
2547
+ // at unmount. Without this the callback survives teardown and writes $data.windowVer.
2548
+ if (typeof requestAnimationFrame === 'function') this.remeasureRaf = requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else this.remeasureRaf = setTimeout(rafPass, 0);
2549
+ };
2550
+
2551
+ // 87-13 (CR-01): idempotent teardown for the deferred sweep — the same discipline
2552
+ // teardownFillDrag()/teardownRangeDrag() already follow. Cancels a pending pass and latches
2553
+ // `remeasureDisposed` so the microtask twin (which carries no cancellable handle) and any
2554
+ // in-flight onChange both bail instead of writing to a torn-down instance.
2555
+ teardownRemeasure = () => {
2556
+ this.remeasureDisposed = true;
2557
+ if (this.remeasureRaf != null) {
2558
+ // Discriminate on requestAnimationFrame — the SAME predicate scheduleRemeasure() branched
2559
+ // on when it produced this handle. Testing cancelAnimationFrame instead would hand a
2560
+ // setTimeout id to cancelAnimationFrame in any env where the two disagree.
2561
+ if (typeof requestAnimationFrame === 'function') cancelAnimationFrame(this.remeasureRaf);else clearTimeout(this.remeasureRaf);
2562
+ this.remeasureRaf = null;
2563
+ }
2564
+ this.remeasurePending = false;
2256
2565
  };
2257
2566
 
2258
2567
  // pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
@@ -2288,6 +2597,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2288
2597
  // idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
2289
2598
  // visible window each commit is cheap and loop-free.
2290
2599
  remeasureWindow = () => {
2600
+ // 87-13 (CR-01): the disposed latch, checked BEFORE the ref guard below. That guard cannot
2601
+ // cover unmount — virtualizer/gridRoot are assigned in $onMount and never nulled — so this
2602
+ // is the only thing standing between a pass queued pre-unmount and refineRowEstimate()'s
2603
+ // setOptions/scrollTop/$data.windowVer writes against a torn-down instance.
2604
+ if (this.remeasureDisposed) return;
2291
2605
  if (!this.virtualizer || !this.gridRoot) return;
2292
2606
  // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
2293
2607
  // exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
@@ -2299,6 +2613,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2299
2613
  if (this.virtualizer.scrollState) return;
2300
2614
  const trs = this.gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
2301
2615
  for (const tr of trs as any) this.virtualizer.measureElement(tr);
2616
+ // D-15 (Phase 87 87-07): trigger the shared engine's post-measurement fold + hysteresis
2617
+ // re-feed via the afterRowRemeasure mutable-let hook (DataTable.rozie's own script,
2618
+ // assigned to windowing.rzts's refineRowEstimate()) — NEVER a direct call from this file
2619
+ // into windowing.rzts (see windowing.rzts's HOST CONTRACT comment for the forward-
2620
+ // reference TDZ this indirection avoids, the same class remeasureColumnWindow()'s comment
2621
+ // documents for columnVirtualizerOptions()). Inert (no-op) until the host assigns it; the
2622
+ // off/pre-mount path never reaches this line since virtualizer is null there.
2623
+ if (this.afterRowRemeasure) this.afterRowRemeasure();
2302
2624
  };
2303
2625
 
2304
2626
  // D-04: this shell exports ONLY the impure, data-table-specific host pieces. The pure windowing
@@ -2329,6 +2651,61 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2329
2651
  // defaulting to no-op): the DataTable host passes its edit-pinning hooks;
2330
2652
  // listbox passes nothing. Routing pinning through this host hook (NOT
2331
2653
  // inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
2654
+ // - rowsWindowed(): boolean — is the ROW axis windowed. REQUIRED, no default — replaces every bare
2655
+ // truthiness read of the host's windowing prop (D-05); `windowedRows()` /
2656
+ // `padTop()` / `padBottom()` / `rowIsOutsideWindow()` below call it by
2657
+ // convention exactly as they already call `pinnedEditIndex()`.
2658
+ // - colsWindowed(): boolean — is the COLUMN axis windowed. REQUIRED, no default. `false` for every
2659
+ // host until it defines the real column-axis mechanism (87-04+).
2660
+ // - columnCount(): number — the leaf-column count the column virtualizer windows over. REQUIRED,
2661
+ // no default.
2662
+ // - columnSize(i: number): number — the authoritative width of absolute leaf column `i`, sourced
2663
+ // from table-core's `getSize()` under D-06. REQUIRED, no default.
2664
+ // - forcedColumns(): number[] — the D-10 OPTIONAL column-axis mirror of `pinnedEditIndex()`: the
2665
+ // DataTable host unions pinned + active-cell + editing column indices into
2666
+ // the column-window slice; listbox/combobox pass an empty array (host-
2667
+ // provided, defaulting to `[]`).
2668
+ // - colVirtualizer — the host's SECOND virtual-core instance, windowing the COLUMN axis
2669
+ // (see the AXIS MECHANISM note below). Host-provided, defaulting to `null`.
2670
+ // - autoMeasureOn(): boolean — the D-18 REQUIRED content-driven-estimate gate (Phase 87 87-07):
2671
+ // data-table's real body reads `$props.autoMeasure === true`; listbox/
2672
+ // combobox/command-palette return `false` so the accumulator branch
2673
+ // estimateRowSize() gates on is dead code for them (D-20).
2674
+ // - afterRowRemeasure — OPTIONAL host-owned mutable `let` (defaults to a no-op / undefined),
2675
+ // assigned to refineRowEstimate() (below) by the host. The DataTable
2676
+ // host's remeasureWindow() (virtualization.rzts) calls it AFTER its
2677
+ // measureElement sweep so the fold + hysteresis re-feed run on every
2678
+ // window commit. Routed through a mutable `let` rather than a direct
2679
+ // call FROM virtualization.rzts INTO this file: a relative-partial CONST
2680
+ // calling a bare-specifier-partial CONST is the exact forward-reference
2681
+ // TDZ class remeasureColumnWindow()'s own DataTable.rozie comment
2682
+ // documents for columnVirtualizerOptions() (inlineScriptPartials()
2683
+ // groups the relative partial BEFORE the bare-specifier partial in the
2684
+ // merged per-target output, regardless of source import order). A
2685
+ // mutable `let` hoists to `useRef` on React and is excluded from a
2686
+ // useCallback's dependency array, sidestepping the hazard entirely — the
2687
+ // SAME mechanism `refreshRowModel` already relies on.
2688
+ //
2689
+ // AXIS MECHANISM (OQ1 / Assumption A1 — resolved from the installed source in 87-02;
2690
+ // LANDED in 87-04: `columnVirtualizerOptions()` below IS the second, horizontal instance this
2691
+ // note originally only documented). `horizontal` is a PER-INSTANCE field of `VirtualizerOptions`
2692
+ // (`node_modules/@tanstack/virtual-core/dist/esm/index.d.ts:67`, installed version 3.17.1 per
2693
+ // `package.json`), and every axis-sensitive internal read consults `instance.options.horizontal` —
2694
+ // `measureElement`'s inlineSize/blockSize + offsetWidth/offsetHeight branch
2695
+ // (`dist/esm/index.js:137,150`), `observeElementOffset`'s scrollLeft/scrollTop branch
2696
+ // (`dist/esm/index.js:118-121`), `getMaxScrollOffset`'s scrollWidth/scrollHeight branch
2697
+ // (`dist/esm/index.js:907-915`), and `scrollWithAdjustments`'s left/top branch
2698
+ // (`dist/esm/index.js:152-161`). So ONE `Virtualizer` instance windows exactly ONE axis: the column
2699
+ // axis needs its own SECOND, independent `Virtualizer` instance constructed with `horizontal: true`,
2700
+ // sharing the SAME `getScrollElement()` (the `rdt-scroll` wrapper) the row instance already uses.
2701
+ // Two options the row axis does not set that the column instance will need: `isRtl?: boolean`
2702
+ // (data-table ships an RTL grid path) and `overscan?: number` (D-07 gives the column axis its own
2703
+ // hardcoded constant, separate from the row axis's `overscan: 8` below).
2704
+ //
2705
+ // isRtl WIRING (gap-closure 87-09, LANDED — see `ensureColRtlWatch()`/`isColRtl()` below,
2706
+ // immediately ahead of `columnVirtualizerOptions()`): data-table has no construction-time RTL
2707
+ // signal (no `dir`/`rtl` prop), and `dir` can be set on `gridScrollEl` at ANY point relative to
2708
+ // mount. `isRtl` is therefore computed LIVE via `getComputedStyle`, not baked in once.
2332
2709
  // getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
2333
2710
  // React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
2334
2711
  // id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
@@ -2337,16 +2714,321 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2337
2714
  return src && src[i] ? src[i].id : undefined;
2338
2715
  };
2339
2716
 
2717
+ // COL_OVERSCAN (D-07): the column axis's own hardcoded overscan constant, separate from the
2718
+ // row axis's `overscan: 8` below. Columns are far wider than rows are tall, so one number
2719
+ // cannot serve both axes; no prop is exposed because no consumer has asked to tune the row
2720
+ // overscan across the four phases it has shipped. Unused until 87-04 constructs the second,
2721
+ // horizontal Virtualizer instance (see the AXIS MECHANISM note above).
2722
+ COL_OVERSCAN = 3;
2723
+
2724
+ // columnVirtualizerOptions() (Phase 87 87-04): the SECOND, horizontal Virtualizer instance's
2725
+ // full options set — the AXIS MECHANISM note's second instance, landed. Mirrors
2726
+ // virtualizerOptions() exactly except: count/estimateSize read the COLUMN host symbols,
2727
+ // horizontal:true selects the axis, overscan is the column axis's OWN constant (D-07), and
2728
+ // there is NO getItemKey — columns have no stable per-column id in the virtual-core sense, so
2729
+ // they key by index (virtual-core's default). onChange bumps the SAME $data.windowVer counter
2730
+ // the row axis uses (87-02 checkpoint decision (b): no separate $data.colWindowVer) but does
2731
+ // NOT call scheduleRemeasure(): under D-06 column widths come from table-core's getSize()
2732
+ // oracle and are NEVER measured from the DOM, so a horizontal scroll has nothing new to
2733
+ // observe (unlike the row axis's CR-01 recycled-row remeasure sweep).
2734
+ // ══ Gap-closure 87-09 (dir="rtl" column windowing) ═══════════════════════════════════════
2735
+ // virtual-core reads `instance.options.isRtl` FRESH on every native 'scroll' event — the
2736
+ // negation lives inside `observeElementOffset`'s internal per-event closure
2737
+ // (`dist/esm/index.js:119-120`: `el.scrollLeft * (isRtl && -1 || 1)`), called anew each time
2738
+ // the browser fires 'scroll'. But `instance.options` itself is only a plain VALUE snapshot
2739
+ // taken by the LAST `setOptions()` call (`dist/esm/index.js:277-280`:
2740
+ // `for (const key in opts2) merged[key] = opts2[key]` copies the boolean, not a live binding
2741
+ // or getter) — so a static `isRtl` computed ONCE at `colVirtualizer` construction would only
2742
+ // ever reflect direction AT THAT INSTANT. `dir` can be set on `gridScrollEl` at any point
2743
+ // relative to mount (data-table has no construction-time RTL prop), so a MutationObserver on
2744
+ // `gridScrollEl`'s own `dir` attribute re-feeds `colVirtualizer`'s options — a cheap
2745
+ // object-merge via `setOptions()`, deliberately NOT a forced `_willUpdate()` recompute, since
2746
+ // nothing needs to repaint until the next real scroll — the moment direction flips. By the
2747
+ // time the NEXT native 'scroll' event's own offset read runs, `instance.options.isRtl`
2748
+ // already reflects the correction. Ordering is spec-guaranteed, not a race: MutationObserver
2749
+ // callbacks run on the microtask queue, which the HTML event loop always drains before the
2750
+ // next task — and a native 'scroll' event dispatched after a programmatic `scrollLeft` write
2751
+ // is itself a subsequent task, not a microtask racing the same tick.
2752
+ //
2753
+ // F6 STALE-CLOSURE HAZARD (found + fixed this task, React only): calling
2754
+ // columnVirtualizerOptions() FRESH from inside the MutationObserver's callback would
2755
+ // re-derive `count`/`estimateSize` via columnCount() (virtualization.rzts's
2756
+ // visibleColCount(), which reads `$data.rows`). The MutationObserver is attached ONCE,
2757
+ // inside ensureColRtlWatch() at colVirtualizer CONSTRUCTION time ($onMount) — a closure
2758
+ // FROZEN to that render's scope on React, where `$data.rows` is a useState value.
2759
+ // $onMount's row-model seed (refreshRowModel()) resolves via an ASYNC setState, so AT THE
2760
+ // EXACT INSTANT colVirtualizer is constructed, `rows` may still be `[]` in THAT render's
2761
+ // closure — and unlike a `let`-hoisted ref (virtualizer, table, gridScrollEl — always read
2762
+ // via `.current`, always live no matter which render's closure holds the reference),
2763
+ // `$data.rows` is genuine React state: a plain local binding recreated fresh EVERY render,
2764
+ // so a function captured from an OLDER render reads that OLDER render's snapshot FOREVER,
2765
+ // no matter how many later renders populate it. Confirmed empirically: calling
2766
+ // columnVirtualizerOptions() from the frozen callback read `count` back as 0 (not 60),
2767
+ // collapsing getVirtualItems() to []. This is the exact class of bug `virtualItemKey`'s own
2768
+ // header comment already documents ("never a frozen mount-render $data.rows closure") — and
2769
+ // a MUTABLE-LET INDIRECTION (the `afterRowRemeasure` shape, 87-07) does NOT fix it here,
2770
+ // because the hazard lives INSIDE columnVirtualizerOptions()'s own dependency chain
2771
+ // ($data.rows), not in "which function identity gets called" — wrapping a stale call in
2772
+ // another layer of ref does not make an inner React-state read fresh.
2773
+ //
2774
+ // FIX: never call columnVirtualizerOptions() again from this callback at all. Read
2775
+ // `colVirtualizer.options` — virtual-core's OWN internal state, mutated in place on every
2776
+ // setOptions() call regardless of which render's closure triggered it (a REAL object
2777
+ // reference the SAME way `virtualizer`/`table` are, not React state) — and merge in ONLY
2778
+ // the corrected `isRtl`. `count`/`estimateSize`/etc. keep whatever value the LAST correct
2779
+ // setOptions() call gave them (from construction, or from remeasureColumnWindow()'s own
2780
+ // FRESH-per-render re-feed via the $watch-driven refreshRowModel cycle) — untouched,
2781
+ // because this dir-flip has nothing to do with row/column count. isColRtl() itself reads
2782
+ // only `gridScrollEl` (also a ref) and does a live DOM getComputedStyle read — no React
2783
+ // state involved, so it is never stale regardless of which render's closure calls it.
2784
+ colRtlObserver: any = null;
2785
+
2786
+ colRtlObserverEl: any = null;
2787
+
2788
+ // isColRtl(): getComputedStyle (not a raw `.getAttribute('dir')` read) so an ANCESTOR's `dir`
2789
+ // or a CSS `direction: rtl` rule is honored the same way the browser's own bidi layout honors
2790
+ // it, not merely a literal attribute on gridScrollEl itself.
2791
+ isColRtl = (): boolean => {
2792
+ if (!this.gridScrollEl || typeof getComputedStyle !== 'function') return false;
2793
+ return getComputedStyle(this.gridScrollEl).direction === 'rtl';
2794
+ };
2795
+
2796
+ // ensureColRtlWatch(): idempotent — a no-op once already attached to the CURRENT gridScrollEl
2797
+ // (a stable ref for the component's lifetime once mounted). Self-attaching from inside
2798
+ // columnVirtualizerOptions() means no new host-contract symbol is needed: listbox/combobox/
2799
+ // command-palette never call columnVirtualizerOptions() at all (colsWindowed() is permanently
2800
+ // false for them, D-20), so this code path — and the observer it creates — never exists there.
2801
+ ensureColRtlWatch = (): void => {
2802
+ if (!this.gridScrollEl || this.colRtlObserverEl === this.gridScrollEl || typeof MutationObserver !== 'function') return;
2803
+ if (this.colRtlObserver) this.colRtlObserver.disconnect();
2804
+ this.colRtlObserver = new MutationObserver(() => {
2805
+ if (this.colVirtualizer) this.colVirtualizer.setOptions({
2806
+ ...this.colVirtualizer.options,
2807
+ isRtl: this.isColRtl()
2808
+ });
2809
+ });
2810
+ this.colRtlObserver.observe(this.gridScrollEl, {
2811
+ attributes: true,
2812
+ attributeFilter: ['dir']
2813
+ });
2814
+ this.colRtlObserverEl = this.gridScrollEl;
2815
+ };
2816
+
2817
+ // teardownColRtlWatch(): exported so the DataTable host disconnects the observer alongside
2818
+ // colVirtualizerCleanup() in $onUnmount — mirrors virtual-core's own _didMount()/teardown
2819
+ // discipline for the ResizeObserver it manages internally. No-op if never attached.
2820
+ teardownColRtlWatch = (): void => {
2821
+ if (this.colRtlObserver) this.colRtlObserver.disconnect();
2822
+ this.colRtlObserver = null;
2823
+ this.colRtlObserverEl = null;
2824
+ };
2825
+
2826
+ columnVirtualizerOptions = (): any => {
2827
+ this.ensureColRtlWatch();
2828
+ return {
2829
+ count: this.columnCount(),
2830
+ getScrollElement: () => this.gridScrollEl,
2831
+ estimateSize: (i: any) => this.columnSize(i),
2832
+ horizontal: true,
2833
+ isRtl: this.isColRtl(),
2834
+ observeElementRect,
2835
+ observeElementOffset,
2836
+ scrollToFn: elementScroll,
2837
+ measureElement,
2838
+ overscan: this.COL_OVERSCAN,
2839
+ onChange: () => {
2840
+ this._windowVer.value = this._windowVer.value + 1;
2841
+ }
2842
+ };
2843
+ };
2844
+
2845
+ // ══ Phase 87 87-07 (D-15/D-18) — content-driven auto-measure: the shared engine's FIRST
2846
+ // mutable top-level state. Hoisted to `useRef` PER-INSTANCE by the React emitter's
2847
+ // hoistModuleLet — the SAME mechanism already load-bearing for `table`, `virtualizer`,
2848
+ // `remeasurePending`, and `gridScrollEl` in the DataTable host (Task 1's confirmed
2849
+ // precedent), so two DataTable instances on one page never share an accumulator
2850
+ // (T-87-07-04). measuredRowTotal/measuredRowCount together give the running MEAN of every
2851
+ // row folded in so far; lastFedRowEstimate is the estimate value most recently pushed into
2852
+ // virtual-core (the hysteresis comparison baseline). ══
2853
+ measuredRowTotal = 0;
2854
+
2855
+ measuredRowCount = 0;
2856
+
2857
+ lastFedRowEstimate = 0;
2858
+
2859
+ // foldedRowHeights: dedupe map (full-model row index -> its last-folded height) so a row
2860
+ // measured TWICE (e.g. scrolled back into view) UPDATES the running mean rather than being
2861
+ // added a second time (T-87-07-03) — a skewed mean produces a lying scrollbar, the exact
2862
+ // defect this feature exists to remove.
2863
+ foldedRowHeights: Record<number, number> = {};
2864
+
2865
+ // ══ Gap-closure 87-10 — windowVerBumpPending / bumpWindowVer(): coalesce EVERY $data.windowVer
2866
+ // write behind a SINGLE microtask-deferred increment, regardless of how many callers request
2867
+ // one within the same synchronous JS task. ══
2868
+ //
2869
+ // ROOT CAUSE (framework-agnostic; the Solid-specific symptom this closes only EXPOSES it) —
2870
+ // confirmed by instrumenting the installed @tanstack/virtual-core@3.17.1 source directly
2871
+ // (dist/esm/index.js), not by reasoning abstractly: virtual-core's resizeItem() calls
2872
+ // `this.notify(false)` — synchronously invoking `virtualizerOptions().onChange` below — EVERY
2873
+ // TIME a measured row's real size differs from its cached one (`delta !== 0`), independent of
2874
+ // framework (dist/esm/index.js:836-874). remeasureWindow()'s CR-01 sweep
2875
+ // (packages/ui/data-table/src/virtualization.rzts) measures EVERY currently-rendered `<tr>` in
2876
+ // ONE for-loop BEFORE calling afterRowRemeasure() (refineRowEstimate() below) — so a single
2877
+ // synchronous JS task (e.g. the very first measurement pass, which transitions N never-before-
2878
+ // measured rows from the flat seed to their real heights) can fire onChange, and therefore an
2879
+ // UNCOALESCED `$data.windowVer = $data.windowVer + 1`, MANY times in a row — well BEFORE
2880
+ // refineRowEstimate()'s own fold-then-re-feed (which runs only AFTER that loop finishes) has
2881
+ // folded those same measurements into the running mean or re-fed the converged estimate into
2882
+ // virtual-core via setOptions(). A live trace of this exact sequence (instrumented resizeItem/
2883
+ // getMeasurements calls, DataTableColumnVirtualDemo, autoMeasure on) showed Vue batching 3
2884
+ // resizeItem calls before its ONE downstream re-render reads getMeasurements() — already
2885
+ // reflecting the fully-folded, re-fed state — versus Solid re-running its padTop()/padBottom()
2886
+ // effects SYNCHRONOUSLY and IMMEDIATELY on EVERY individual windowVer write (11 interleaved
2887
+ // resize-then-immediate-recompute pairs, each recompute happening mid-sweep, before
2888
+ // refineRowEstimate() had run even once). React/Vue/Svelte/Angular/Lit all batch their own
2889
+ // reactivity to at least a microtask boundary, so their downstream reads land AFTER the whole
2890
+ // synchronous burst (measurement sweep + fold + re-feed) completes — accidentally correct, not
2891
+ // correct by construction. Solid does not auto-batch a signal write made from outside a
2892
+ // Solid-owned event/effect context, so it is the one target where the mid-burst TORN read is
2893
+ // externally observable. Because `setOptions()` + `_willUpdate()` alone do NOT invalidate
2894
+ // virtual-core's own `getMeasurements()` memo (keyed on itemSizeCacheVersion /
2895
+ // getMeasurementOptions() — never on the estimateSize FUNCTION reference itself; confirmed from
2896
+ // the same installed source, dist/esm/index.js:585-587,624), Solid's LAST such mid-sweep
2897
+ // recompute is also the LAST time getMeasurements() is ever invoked for that sweep once no
2898
+ // further row happens to differ from its cache — so the DOM stays frozen on that stale,
2899
+ // pre-fold/pre-re-feed snapshot indefinitely, even though the accumulator itself has already
2900
+ // converged correctly (T-87-07's own confirmed finding).
2901
+ //
2902
+ // FIX: coalesce every requester of a windowVer bump — virtual-core's own onChange AND
2903
+ // refineRowEstimate()'s explicit re-feed bump — behind ONE microtask-deferred write, the SAME
2904
+ // idiom scheduleRemeasure() already uses in virtualization.rzts. This makes the render happen
2905
+ // EXACTLY ONCE, strictly AFTER the entire synchronous burst (including refineRowEstimate()'s
2906
+ // fold + re-feed) on EVERY target, by construction rather than by incidental host-framework
2907
+ // batching. Scoped to the ROW axis only: colVirtualizer never calls resizeItem() at all (D-06 —
2908
+ // column widths come from table-core's getSize() oracle, never measured from the DOM), so
2909
+ // columnVirtualizerOptions()'s onChange cannot hit this burst class and is left untouched.
2910
+ windowVerBumpPending = false;
2911
+
2912
+ bumpWindowVer = (): void => {
2913
+ if (this.windowVerBumpPending) return;
2914
+ this.windowVerBumpPending = true;
2915
+ const flush = () => {
2916
+ this.windowVerBumpPending = false;
2917
+ this._windowVer.value = this._windowVer.value + 1;
2918
+ };
2919
+ // Mirrors scheduleRemeasure()'s own defensive queueMicrotask-with-setTimeout-fallback
2920
+ // (virtualization.rzts) for environments where queueMicrotask is unavailable.
2921
+ if (typeof queueMicrotask !== 'undefined') queueMicrotask(flush);else setTimeout(flush, 0);
2922
+ };
2923
+
2924
+ // ESTIMATE_REFEED_DELTA_PX (D-15): the hysteresis threshold gating a re-feed into
2925
+ // virtual-core. Without it, a mean nudging by a fraction of a pixel on every fold would
2926
+ // re-feed on every window commit — the T-87-07-01 DoS control, paired with virtual-core's
2927
+ // own measureElement/resizeItem idempotence (see refineRowEstimate() below).
2928
+ ESTIMATE_REFEED_DELTA_PX = 4;
2929
+
2930
+ // estimateRowSize(i) (D-15/D-17): the estimateSize() resolver. MUST check !autoMeasureOn()
2931
+ // FIRST so the off path touches zero accumulator state and returns $props.estimateRowHeight
2932
+ // verbatim (D-17's byte-behavioral no-op). The zero-measurements case (first paint,
2933
+ // regardless of autoMeasure) still returns the seed — the very first render has nothing
2934
+ // measured yet either way (D-15).
2935
+ estimateRowSize = (i: number): number => {
2936
+ if (!this.autoMeasureOn()) return this.estimateRowHeight;
2937
+ if (this.measuredRowCount === 0) return this.estimateRowHeight;
2938
+ return Math.round(this.measuredRowTotal / this.measuredRowCount);
2939
+ };
2940
+
2941
+ // foldMeasuredRow(index, height): fold ONE measured row's height into the running-mean
2942
+ // accumulator, UPDATING (not double-adding) an already-folded index (T-87-07-03).
2943
+ foldMeasuredRow = (index: number, height: number): void => {
2944
+ const prev = this.foldedRowHeights[index];
2945
+ if (prev === height) return;
2946
+ if (prev == null) {
2947
+ this.measuredRowTotal = this.measuredRowTotal + height;
2948
+ this.measuredRowCount = this.measuredRowCount + 1;
2949
+ } else {
2950
+ this.measuredRowTotal = this.measuredRowTotal - prev + height;
2951
+ }
2952
+ this.foldedRowHeights[index] = height;
2953
+ };
2954
+
2955
+ // refineRowEstimate() (D-15/D-16, Phase 87 87-07): the shared engine's post-measurement
2956
+ // orchestration, called via the host's afterRowRemeasure hook (see the HOST CONTRACT note
2957
+ // above for why this is never called directly FROM virtualization.rzts). Folds every
2958
+ // CURRENTLY RENDERED row's REAL measurement (from virtual-core's own getMeasurements() —
2959
+ // never a DOM read), then hysteresis-gates a re-feed into virtual-core, reusing the EXACT
2960
+ // `virtualizer.scrollState` bail remeasureWindow() already applies (T-87-07-02) rather than
2961
+ // inventing a second gating mechanism, and finally (Task 3, D-16) anchor-corrects scrollTop
2962
+ // around the re-feed so the row under the user's eye does not visibly lurch. Entirely a
2963
+ // no-op when !autoMeasureOn() — the off path never folds a measurement, touches
2964
+ // lastFedRowEstimate, or adjusts scrollTop (D-17).
2965
+ refineRowEstimate = (): void => {
2966
+ if (!this.autoMeasureOn() || !this.virtualizer) return;
2967
+ const items = this.virtualizer.getVirtualItems();
2968
+ const measurements = this.virtualizer.getMeasurements();
2969
+ for (let i = 0; i < items.length; i++) {
2970
+ const idx = items[i].index;
2971
+ const m = measurements && measurements[idx];
2972
+ if (m) this.foldMeasuredRow(idx, m.size);
2973
+ }
2974
+ // T-87-07-02: never re-feed while a PROGRAMMATIC scroll is in flight — the exact bail
2975
+ // remeasureWindow() already applies (virtualizer.scrollState is non-null only during
2976
+ // scrollToIndex/scrollToOffset).
2977
+ if (this.virtualizer.scrollState) return;
2978
+ const est = this.estimateRowSize(0);
2979
+ if (Math.abs(est - this.lastFedRowEstimate) < this.ESTIMATE_REFEED_DELTA_PX) return;
2980
+ // D-16 anchor capture: the topmost rendered row's full-model index + its CURRENT offset,
2981
+ // taken from virtual-core's own measurements (never a getBoundingClientRect() layout read
2982
+ // — slower, and inconsistent with the measurements the spacer math already uses) — taken
2983
+ // BEFORE the re-feed so the correction below can compute exactly the delta the re-feed
2984
+ // itself introduces. Not restricted to scroll offset 0 (D-16 explicit): a table restored
2985
+ // mid-scroll, or whose content changes height after scrolling, must refine too.
2986
+ const anchorIndex = items.length ? items[0].index : -1;
2987
+ const anchorStart = items.length ? items[0].start : 0;
2988
+ this.virtualizer.setOptions(this.virtualizerOptions());
2989
+ this.virtualizer._willUpdate();
2990
+ this.lastFedRowEstimate = est;
2991
+ // D-16 anchor correction: read the SAME index's fresh measurement post-re-feed and adjust
2992
+ // scrollTop by the delta so the row under the user's eye stays visually put. Guarded by
2993
+ // the SAME path the re-feed itself is gated on (autoMeasureOn() && scrollState === null,
2994
+ // both already checked above), so it can never fight a programmatic scroll (T-87-07-02).
2995
+ if (anchorIndex >= 0 && this.gridScrollEl) {
2996
+ const freshMeasurements = this.virtualizer.getMeasurements();
2997
+ const fresh = freshMeasurements && freshMeasurements[anchorIndex];
2998
+ if (fresh) {
2999
+ const delta = fresh.start - anchorStart;
3000
+ if (delta !== 0) this.gridScrollEl.scrollTop = this.gridScrollEl.scrollTop + delta;
3001
+ }
3002
+ }
3003
+ // Explicit windowVer bump (87-07, Rule 1): virtual-core's own setOptions()+_willUpdate()
3004
+ // re-feed above does NOT itself invoke notify()/onChange (confirmed from the installed
3005
+ // source: _willUpdate() is a no-op once `scrollElement` is already set, dist/esm/
3006
+ // index.js:389-393), so a re-feed needs its own explicit trigger to make the spacer
3007
+ // re-derivation happen at all. Routed through bumpWindowVer() (87-10, see its own comment
3008
+ // above) rather than a raw `$data.windowVer = $data.windowVer + 1` — coalescing this bump
3009
+ // with any onChange-triggered ones already pending from the SAME synchronous sweep is what
3010
+ // makes the eventual render land strictly AFTER this fold + re-feed has fully landed, on
3011
+ // every target (the 87-10 fix for the confirmed Solid-specific rendering gap).
3012
+ this.bumpWindowVer();
3013
+ };
3014
+
2340
3015
  // The FULL virtualizer options. virtual-core's setOptions REPLACES options with
2341
3016
  // `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
2342
3017
  // source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
2343
3018
  // Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
2344
- // on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
2345
- // increment the React emitter lowers to functional setState — correct even from a mount closure.
3019
+ // on virtual-core's generic option inference. onChange's windowVer write is routed through
3020
+ // bumpWindowVer() (87-10) rather than a raw `$data.x = $data.x + 1` — resizeItem() can call
3021
+ // this onChange MANY times in a single synchronous sweep (once per row whose real measured
3022
+ // size differs from its cache, e.g. every never-before-measured row in the FIRST window),
3023
+ // and coalescing those into one microtask-deferred write is what keeps every target's render
3024
+ // landing strictly AFTER the whole sweep (see bumpWindowVer()'s own comment for the confirmed
3025
+ // Solid-specific rendering gap this closes). The React emitter still lowers the underlying
3026
+ // `$data.windowVer = $data.windowVer + 1` to functional setState — correct even deferred to a
3027
+ // microtask, exactly as it was correct from a mount closure before.
2346
3028
  virtualizerOptions = (): any => ({
2347
3029
  count: this.windowSource().length,
2348
3030
  getScrollElement: () => this.gridScrollEl,
2349
- estimateSize: () => this.estimateRowHeight,
3031
+ estimateSize: (i: any) => this.estimateRowSize(i),
2350
3032
  observeElementRect,
2351
3033
  observeElementOffset,
2352
3034
  scrollToFn: elementScroll,
@@ -2354,7 +3036,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2354
3036
  overscan: 8,
2355
3037
  getItemKey: this.virtualItemKey,
2356
3038
  onChange: () => {
2357
- this._windowVer.value = this._windowVer.value + 1;
3039
+ this.bumpWindowVer();
2358
3040
  // CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
2359
3041
  // virtual-core only observe()s a node you explicitly hand to measureElement (it does
2360
3042
  // NOT auto-discover rendered rows — measureElement is the SOLE caller of
@@ -2406,14 +3088,23 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2406
3088
  void this._windowVer.value;
2407
3089
  void this._editVer.value;
2408
3090
  if (!this.virtualizer) {
2409
- // Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
2410
- // but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
2411
- // the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
2412
- // rows appear on the first onChange after _didMount.
2413
- if (!this.virtual) {
3091
+ // Rows OFF (Phase 87 D-04: this now includes the colsWindowed()-only path, since the
3092
+ // wrapper template is entered whenever isWindowed(), not just rowsWindowed() — the row
3093
+ // virtualizer is never constructed when only the column axis is windowed, D-04) → the FULL
3094
+ // set, with a SYNTHETIC `vi.index` set to each row's array position (matching rowIndexOf's
3095
+ // own `$data.rows.indexOf(row)` semantics exactly, since $data.rows IS windowSource()'s
3096
+ // output here). Every windowed body binding reads wr.vi.index (data-row, aria-rowindex,
3097
+ // colIndexOf, isEditing, the fill handle) — a bare `null` there is a hard crash the moment
3098
+ // this branch is reached with the wrapper mounted, which colsWindowed()-only now does.
3099
+ // Row-virtual ON but the virtualizer is not yet constructed (pre-$onMount first paint) →
3100
+ // render NOTHING so the template never dereferences a not-yet-real `vi`; the rows appear on
3101
+ // the first onChange after _didMount.
3102
+ if (!this.rowsWindowed()) {
2414
3103
  const rowList = this._rows.value || [];
2415
- return rowList.map((r: any) => ({
2416
- vi: null,
3104
+ return rowList.map((r: any, i: any) => ({
3105
+ vi: {
3106
+ index: i
3107
+ },
2417
3108
  row: r
2418
3109
  }));
2419
3110
  }
@@ -2470,7 +3161,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2470
3161
  // and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
2471
3162
  void this._windowVer.value;
2472
3163
  void this._editVer.value;
2473
- if (!this.virtual || !this.virtualizer) return 0;
3164
+ if (!this.rowsWindowed() || !this.virtualizer) return 0;
2474
3165
  const items = this.virtualizer.getVirtualItems();
2475
3166
  let pad = items.length ? items[0].start : 0;
2476
3167
  // D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
@@ -2491,7 +3182,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2491
3182
  // on pin/unpin.
2492
3183
  void this._windowVer.value;
2493
3184
  void this._editVer.value;
2494
- if (!this.virtual || !this.virtualizer) return 0;
3185
+ if (!this.rowsWindowed() || !this.virtualizer) return 0;
2495
3186
  const items = this.virtualizer.getVirtualItems();
2496
3187
  if (!items.length) return 0;
2497
3188
  let pad = this.virtualizer.getTotalSize() - items[items.length - 1].end;
@@ -2525,12 +3216,141 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2525
3216
  // rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
2526
3217
  // window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
2527
3218
  rowIsOutsideWindow = (r: any) => {
2528
- if (!this.virtual || !this.virtualizer) return false;
3219
+ if (!this.rowsWindowed() || !this.virtualizer) return false;
2529
3220
  const items = this.virtualizer.getVirtualItems();
2530
3221
  for (const it of items as any) if (it.index === r) return false;
2531
3222
  return true;
2532
3223
  };
2533
3224
 
3225
+ // ══ Phase 87 87-04 — the column-axis analogs of windowedRows()/padTop()/padBottom()/
3226
+ // rowIsOutsideWindow() above. The column axis has no "row-shaped" identity to carry alongside
3227
+ // a VirtualItem (a column is not a full-model object the way a row is), so windowedColIndices()
3228
+ // returns bare ABSOLUTE leaf-column indices; the template resolves each index back to a header/
3229
+ // cell through the host's own header-group / visibleCellsFor lookups (D-08/D-09). ══
3230
+ // windowedColIndices(): the ordered array of ABSOLUTE leaf-column indices to render.
3231
+ windowedColIndices = () => {
3232
+ // SUBSCRIBE FIRST (the windowedRows() discipline): colVirtualizer is a non-reactive `let`,
3233
+ // null at first render, so a $data.windowVer read placed BELOW the guards below would mean
3234
+ // Solid's <For> / Svelte's {#each} accessors never subscribe — the column window would stay
3235
+ // blank forever on exactly those two targets. Also subscribe to editVer (mirrors the row
3236
+ // axis's pin/unpin re-derive — the D-10 forced-column union lands on top of this in 87-05).
3237
+ void this._windowVer.value;
3238
+ void this._editVer.value;
3239
+ if (!this.colsWindowed()) {
3240
+ // Columns OFF (or rows-only windowed) → every absolute index, the degrade-to-full-set
3241
+ // path that keeps the caller (windowedCells / the header loop) axis-agnostic.
3242
+ const n = this.columnCount();
3243
+ const out = [];
3244
+ for (let i = 0; i < n; i++) out.push(i);
3245
+ return out;
3246
+ }
3247
+ // Columns windowed but the column virtualizer is not yet constructed (pre-$onMount first
3248
+ // paint) → render NOTHING, matching windowedRows()'s pre-mount behavior.
3249
+ if (!this.colVirtualizer) return [];
3250
+ const items = this.colVirtualizer.getVirtualItems();
3251
+ const idx = items.map((it: any) => it.index);
3252
+ // D-10 forced-column union (host hook — empty array until 87-05 gives it a real body):
3253
+ // dedupe-then-append any forced index not already in the virtualized window.
3254
+ const forced = this.forcedColumns();
3255
+ for (let i = 0; i < forced.length; i++) {
3256
+ if (idx.indexOf(forced[i]) === -1) idx.push(forced[i]);
3257
+ }
3258
+ // Sorted ascending: the body <td> sequence and the header <th> sequence both follow visual
3259
+ // left-to-right order, and the virtual items + forced union are not guaranteed sorted.
3260
+ idx.sort((a: any, b: any) => a - b);
3261
+ return idx;
3262
+ };
3263
+
3264
+ // colPadLeft() / colPadRight() (D-06): the horizontal spacer <td>/<th> widths — the leading
3265
+ // spacer occupies items[0].start, the trailing spacer the gap between the last rendered
3266
+ // item's end and getTotalSize(). Mirrors padTop()/padBottom() exactly, INCLUDING the D-02-style
3267
+ // pin subtraction (87-05): forcedColumns() can return MULTIPLE forced indices (pinned + active +
3268
+ // editing, not just one pin), so each forced index outside the virtual window is checked
3269
+ // individually. A forced column rendered outside the window contributes its OWN real width to
3270
+ // the row (via its <th>/<td>'s own inline width under table-layout:fixed), so that width must
3271
+ // come OUT of the spacer or the row's total overshoots getTotalSize() and the horizontal
3272
+ // scrollbar lengthens on every such scroll. WR-01: decide before/after the window by comparing
3273
+ // the forced INDEX against the window's first/last item INDEX, never by start-OFFSET —
3274
+ // measurement drift makes the offset comparison wrong for the variable-width case (mirrors
3275
+ // padBottom()'s existing WR-01 discipline). A forced column's width is columnSize(i) (the
3276
+ // table-core size oracle, D-06's authoritative source), never a DOM measurement.
3277
+ colPadLeft = () => {
3278
+ void this._windowVer.value;
3279
+ void this._editVer.value;
3280
+ if (!this.colsWindowed() || !this.colVirtualizer) return 0;
3281
+ const items = this.colVirtualizer.getVirtualItems();
3282
+ let pad = items.length ? items[0].start : 0;
3283
+ if (items.length) {
3284
+ const firstIdx = items[0].index;
3285
+ const forced = this.forcedColumns();
3286
+ for (let i = 0; i < forced.length; i++) {
3287
+ if (forced[i] < firstIdx) pad = pad - this.columnSize(forced[i]);
3288
+ }
3289
+ }
3290
+ return pad < 0 ? 0 : pad;
3291
+ };
3292
+
3293
+ colPadRight = () => {
3294
+ void this._windowVer.value;
3295
+ void this._editVer.value;
3296
+ if (!this.colsWindowed() || !this.colVirtualizer) return 0;
3297
+ const items = this.colVirtualizer.getVirtualItems();
3298
+ if (!items.length) return 0;
3299
+ let pad = this.colVirtualizer.getTotalSize() - items[items.length - 1].end;
3300
+ const lastIdx = items[items.length - 1].index;
3301
+ const forced = this.forcedColumns();
3302
+ for (let i = 0; i < forced.length; i++) {
3303
+ if (forced[i] > lastIdx) pad = pad - this.columnSize(forced[i]);
3304
+ }
3305
+ return pad < 0 ? 0 : pad;
3306
+ };
3307
+
3308
+ // colIsOutsideWindow(c): is the absolute leaf-column index c absent from the currently
3309
+ // rendered column window? The column-axis analog of rowIsOutsideWindow(r) (D-12's
3310
+ // scroll-then-focus seam, wired starting 87-06). Gated on colsWindowed() (never a bare prop
3311
+ // truthiness read — the 87-02 prohibition gate).
3312
+ colIsOutsideWindow = (c: any) => {
3313
+ if (!this.colsWindowed() || !this.colVirtualizer) return false;
3314
+ const items = this.colVirtualizer.getVirtualItems();
3315
+ for (const it of items as any) if (it.index === c) return false;
3316
+ return true;
3317
+ };
3318
+
3319
+ // afterRowRemeasure (Phase 87 87-07, D-15): a mutable-`let` indirection so
3320
+ // virtualization.rzts's remeasureWindow() (a relative-partial CONST, textually EARLIER
3321
+ // than windowing.rzts in the merged per-target output) can trigger the shared engine's
3322
+ // post-measurement fold + hysteresis re-feed (refineRowEstimate(), windowing.rzts) without
3323
+ // calling a bare-specifier-partial CONST directly — the exact forward-reference TDZ class
3324
+ // remeasureColumnWindow()'s own comment below documents for columnVirtualizerOptions(). A
3325
+ // mutable `let` hoists to `useRef` on React and never enters a useCallback's dependency
3326
+ // array, sidestepping the hazard there.
3327
+ //
3328
+ // DECLARED HERE — a plain top-level statement placed AFTER both import lines above, NOT
3329
+ // grouped with the other windowing `let`s near the top of this file, and NOT deferred into
3330
+ // $onMount. Two Rule-1 bugs were found and fixed in this exact spot this task, both via the
3331
+ // VR suite:
3332
+ // (a) A top-level `let X = refineRowEstimate` placed BEFORE the import statements (where
3333
+ // the other windowing `let`s live) reads a windowing.rzts symbol at its OWN
3334
+ // declaration's evaluation time. React's own emitter re-groups DataTable.rozie's
3335
+ // entire top-level script AFTER both inlined partials regardless of source position,
3336
+ // so it tolerated that placement — but Vue/Svelte/Solid/Angular/Lit splice each
3337
+ // partial's content at its IMPORT STATEMENT's source position, so a `let` positioned
3338
+ // BEFORE the import genuinely executes before `refineRowEstimate` exists:
3339
+ // `ReferenceError: Cannot access 'refineRowEstimate' before initialization`. Moving the
3340
+ // declaration to AFTER the windowing.rzts import fixes every non-React target — the
3341
+ // splice has already run by this point in the source.
3342
+ // (b) Deferring the assignment into $onMount instead (`afterRowRemeasure = refineRowEstimate`
3343
+ // inside the mount body, mirroring `refreshRowModel`) fixed (a) but broke REACT: it
3344
+ // triggered the react emitter's mount-referenced-helper stable-ref synthesis for
3345
+ // `refineRowEstimate` (a NEW code path for a plain script helper, not the `$computed`
3346
+ // case that mechanism was built for), producing a genuine
3347
+ // `ReferenceError: Cannot access '...' before initialization` in the component's
3348
+ // render body. Reverted; the emitter path itself is out of this plan's scope.
3349
+ // This placement (top-level, immediately after the windowing.rzts import) is therefore the
3350
+ // ONE spot that is simultaneously safe for React (unaffected by source position) and every
3351
+ // other target (past the import-position splice).
3352
+ afterRowRemeasure = this.refineRowEstimate;
3353
+
2534
3354
  // ── Sort/filter live-announcement (#14) ─────────────────────────────────────────────
2535
3355
  // A polite aria-live announcement whenever the consumer changes sorting or filtering, so a
2536
3356
  // screen-reader user hears that the rows were reordered / narrowed (which is otherwise silent).
@@ -3315,7 +4135,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3315
4135
  // or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
3316
4136
  getRowIndexRelativeToPage = (absRow: any) => {
3317
4137
  const abs = absRow == null ? this.toAbsRow(this._activeRow.value) : Math.trunc(Number(absRow)) || 0;
3318
- if (this.virtual) return abs;
4138
+ if (this.rowsWindowed()) return abs;
3319
4139
  return abs - this.pageRowOffset();
3320
4140
  };
3321
4141
 
@@ -3334,11 +4154,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3334
4154
  // DataTable.rozie) — so DataTable.rozie's $expose references them BY NAME with zero
3335
4155
  // indirection. This file stays the seam for verbs that need a rename/adapter, not a mandatory
3336
4156
  // stop for every $expose entry.
3337
- // ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
3338
- // This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
3339
- // the active-cell index-pair state, the data-* cell markers, and the SINGLE
3340
- // focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
3341
- // (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.
3342
4157
  // interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
3343
4158
  // identical to phase 48 (roles fall back to the literals, tabindex drops).
3344
4159
  isGrid = () => this.interactionMode === 'grid';
@@ -3374,7 +4189,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3374
4189
  // isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
3375
4190
  // state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
3376
4191
  pageRowOffset = () => {
3377
- if (!this.isGrid() || this.virtual) return 0;
4192
+ if (!this.isGrid() || this.rowsWindowed()) return 0;
3378
4193
  return this.pageIndex() * this.pageSize();
3379
4194
  };
3380
4195
 
@@ -3388,7 +4203,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3388
4203
  // In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
3389
4204
  // non-virtual paginated body $data.rows is only the page slice, so read the live model.
3390
4205
  prePaginationRowCount = () => {
3391
- if (!this.table || this.virtual) return this.bodyRowCount();
4206
+ if (!this.table || this.rowsWindowed()) return this.bodyRowCount();
3392
4207
  const pm = this.table.getPrePaginationRowModel();
3393
4208
  return pm && pm.rows ? pm.rows.length : this.bodyRowCount();
3394
4209
  };
@@ -3494,24 +4309,37 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3494
4309
  // here returns the PRE-write value, resolving focus to the BODY cell instead of the
3495
4310
  // header. Callers pass the fresh isHeader local; falls back to $data when omitted.
3496
4311
  const header = nextIsHeader == null ? this._activeIsHeader.value : nextIsHeader;
3497
- // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
3498
- // rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
3499
- // double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
3500
- // in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
3501
- // The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
3502
- // while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
3503
- if (this.virtual && this.virtualizer && !header && this.rowIsOutsideWindow(r)) {
3504
- this.virtualizer.scrollToIndex(r, {
4312
+ // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row and/or column
4313
+ // is OUTSIDE the rendered window, scroll it in first, then defer focus to AFTER the new
4314
+ // window commits (the double-rAF — a single rAF can fire before React's async commit, Pitfall
4315
+ // 4). Header cells and fully in-window cells keep the synchronous path below (table-mode /
4316
+ // non-windowed stay byte-stable). The guard reads the resolved `header` (NOT the raw
4317
+ // `nextIsHeader`) so an omitted-arg call while a header cell is active falls back to
4318
+ // $data.activeIsHeader and skips the scroll path.
4319
+ // Phase 87 87-06 (D-12): widened to the SECOND axis — colIsOutsideWindow(c)/colsWindowed()
4320
+ // mirror rowIsOutsideWindow(r)/rowsWindowed() exactly (both landed in windowing.rzts in
4321
+ // 87-04). Either axis being out triggers the scroll+poll; both scrollToIndex calls fire when
4322
+ // both are out, and the ONE existing poll below is reused unchanged — it already resolves the
4323
+ // target cell by BOTH (row, col) via resolveCellEl(String(r), c), so a cell that becomes
4324
+ // reachable via the column axis needs no new poll logic (per the plan's own key_links note).
4325
+ const rowOut = this.rowsWindowed() && this.virtualizer && this.rowIsOutsideWindow(r);
4326
+ const colOut = this.colsWindowed() && this.colVirtualizer && this.colIsOutsideWindow(c);
4327
+ if (!header && (rowOut || colOut)) {
4328
+ if (rowOut) this.virtualizer.scrollToIndex(r, {
4329
+ align: 'center'
4330
+ });
4331
+ if (colOut) this.colVirtualizer.scrollToIndex(c, {
3505
4332
  align: 'center'
3506
4333
  });
3507
4334
  // Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
3508
- // bump → the framework commits the scrolled-in row. On React that commit is async (setState →
3509
- // reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
3510
- // fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
3511
- // React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
3512
- // fast-committing targets resolve on the first attempt (behavior unchanged), React retries
3513
- // across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
3514
- // cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
4335
+ // bump → the framework commits the scrolled-in row/column. On React that commit is async
4336
+ // (setState → reconcile) and for a far scroll (e.g. row 4000, or col 55 on a 60-col table)
4337
+ // spans several frames — a one-shot double-rAF fires BEFORE resolveCellEl can find the cell,
4338
+ // so focus is silently lost (the deterministic React off-window-focus failure). Poll
4339
+ // resolveCellEl for up to ~30 frames: the five fast-committing targets resolve on the first
4340
+ // attempt (behavior unchanged), React retries across the few frames its async commit needs.
4341
+ // The poll ONLY focuses (never measures), so it cannot re-introduce the remeasure-vs-scroll
4342
+ // fight. Inside the rowOut||colOut guard only.
3515
4343
  let focusAttempts = 0;
3516
4344
  // #9: capture the epoch AFTER this call's own bump (above) so the poll never aborts itself
3517
4345
  // (its captured value equals the current epoch). A LATER focusActiveCell / focusCell /
@@ -3603,8 +4431,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3603
4431
 
3604
4432
  bodyRowCount = () => (this._rows.value || []).length;
3605
4433
 
3606
- clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
3607
-
3608
4434
  // ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
3609
4435
  // $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
3610
4436
  // body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
@@ -3689,7 +4515,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3689
4515
  // phantom cell past that level's headers. Body cells + the leaf header level keep visibleColCount().
3690
4516
  const count = this._activeIsHeader.value ? this.headerCountAtLevel(this._activeHeaderLevel.value) : this.visibleColCount();
3691
4517
  const max = count - 1;
3692
- const nextCol = this.clamp(this._activeColIndex.value + delta, 0, max < 0 ? 0 : max);
4518
+ const nextCol = clamp(this._activeColIndex.value + delta, 0, max < 0 ? 0 : max);
3693
4519
  this._activeColIndex.value = nextCol;
3694
4520
  return nextCol;
3695
4521
  };
@@ -3734,7 +4560,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3734
4560
  // (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
3735
4561
  // page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
3736
4562
  // (delta-1 = 0); clamped to the page-last body row.
3737
- const landRow = this.clamp(delta - 1, 0, maxRow);
4563
+ const landRow = clamp(delta - 1, 0, maxRow);
3738
4564
  this._activeIsHeader.value = false;
3739
4565
  this._activeRow.value = landRow;
3740
4566
  return {
@@ -3779,7 +4605,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3779
4605
  level: leafLevel
3780
4606
  };
3781
4607
  }
3782
- const nextRow = this.clamp(this._activeRow.value + delta, 0, maxRow);
4608
+ const nextRow = clamp(this._activeRow.value + delta, 0, maxRow);
3783
4609
  this._activeRow.value = nextRow;
3784
4610
  this._activeIsHeader.value = false;
3785
4611
  return {
@@ -3847,19 +4673,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3847
4673
  return this.resolveCellEl(rowKey, this._activeColIndex.value, this._activeIsHeader.value ? this._activeHeaderLevel.value : null);
3848
4674
  };
3849
4675
 
3850
- // The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
3851
- focusables = (cellEl: any) => {
3852
- if (!cellEl || !cellEl.querySelectorAll) return [];
3853
- const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
3854
- return list.filter((n: any) => !n.disabled);
3855
- };
3856
-
3857
4676
  // Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
3858
4677
  // (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
3859
4678
  // No-op (stay in navigation mode) if the cell has no focusable control.
3860
4679
  enterControl = () => {
3861
4680
  const cellEl = this.currentCellEl();
3862
- const list = this.focusables(cellEl);
4681
+ const list = focusables(cellEl);
3863
4682
  if (!list.length) return;
3864
4683
  this._activeInControl.value = true;
3865
4684
  list[0].focus();
@@ -3870,7 +4689,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3870
4689
  // activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
3871
4690
  // ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
3872
4691
  cycleWithinCell = (cellEl: any, forward: any) => {
3873
- const list = this.focusables(cellEl);
4692
+ const list = focusables(cellEl);
3874
4693
  if (!list.length) return;
3875
4694
  const active = this.gridRoot ? this.gridRoot.getRootNode().activeElement : null;
3876
4695
  const cur = list.indexOf(active);
@@ -4515,7 +5334,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4515
5334
  }
4516
5335
  }
4517
5336
  const maxCol = colN - 1;
4518
- const col = this.clamp(this._activeColIndex.value, 0, maxCol < 0 ? 0 : maxCol);
5337
+ const col = clamp(this._activeColIndex.value, 0, maxCol < 0 ? 0 : maxCol);
4519
5338
  if (col !== this._activeColIndex.value) this._activeColIndex.value = col;
4520
5339
  // B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
4521
5340
  // cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
@@ -4550,7 +5369,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4550
5369
  if (!this._activeIsHeader.value) {
4551
5370
  const lastRow = rowN - 1;
4552
5371
  const maxRow = lastRow < 0 ? 0 : lastRow;
4553
- const row = this.clamp(this._activeRow.value, 0, maxRow);
5372
+ const row = clamp(this._activeRow.value, 0, maxRow);
4554
5373
  if (row !== this._activeRow.value) this._activeRow.value = row;
4555
5374
  }
4556
5375
  // B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
@@ -4561,12 +5380,81 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4561
5380
  // when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
4562
5381
  // into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
4563
5382
  if (recoverFocus) {
4564
- const recRow = this.clamp(doomedRow, 0, rowN - 1);
4565
- const recCol = this.clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
5383
+ const recRow = clamp(doomedRow, 0, rowN - 1);
5384
+ const recCol = clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
4566
5385
  this.recoverGridFocus(String(recRow), recCol, null);
4567
5386
  }
4568
5387
  };
4569
5388
 
5389
+ // ══ Phase 87 87-04/87-05 — column-axis template windowing (D-06/D-11; declared HERE (after
5390
+ // the columnChrome/gridFocusNav imports above) so a forward cross-partial reference to
5391
+ // colsWindowed()/windowedColIndices() in the merged per-target output never TDZs on React —
5392
+ // the 87-02 useCallback-deps-array ordering lesson, the SAME reason remeasureColumnWindow()
5393
+ // below stays out of virtualization.rzts). ══
5394
+ // windowedHeadersFor(hg, hgLevel) (87-05): generalizes 87-04's windowedHeaderRow() (leaf-level
5395
+ // only) to EVERY header level with a CLAMPED colspan (D-11). A header spans the CONTIGUOUS run
5396
+ // of leaf-column indices [cursor, cursor + header.colSpan) — table-core lays out every header
5397
+ // level's `headers` array left-to-right over the SAME full leaf-column order, so walking a
5398
+ // running cursor by each header's OWN colSpan reconstructs its absolute leaf range with no
5399
+ // extra table-core calls. Intersect that range with windowedColIndices() (which already unions
5400
+ // the D-10 forced set): a header whose intersection is EMPTY is dropped entirely (a group
5401
+ // wholly outside the window renders no <th> at all — D-11); otherwise it is emitted as
5402
+ // { header, span } with span = the intersection SIZE (the clamp). Degrades to hg.headers
5403
+ // UNCHANGED (span = the header's own colSpan, or 1) when columns are not windowed — the
5404
+ // off-path the template's `:colspan="wh.span > 1 ? wh.span : null"` binding preserves
5405
+ // byte-identically (a bare colspan="1" would change the emitted markup on the unwindowed path).
5406
+ windowedHeadersFor = (hg: any, hgLevel: any) => {
5407
+ const headers = hg && hg.headers || [];
5408
+ if (!this.colsWindowed()) {
5409
+ return headers.map((h: any) => ({
5410
+ header: h,
5411
+ span: h && h.colSpan > 1 ? h.colSpan : 1
5412
+ }));
5413
+ }
5414
+ const idx = this.windowedColIndices();
5415
+ const idxSet = {};
5416
+ for (let i = 0; i < idx.length; i++) idxSet[idx[i]] = true;
5417
+ let cursor = 0;
5418
+ const out = [];
5419
+ for (let i = 0; i < headers.length; i++) {
5420
+ const h = headers[i];
5421
+ const leafCount = h && h.colSpan ? h.colSpan : 1;
5422
+ const start = cursor;
5423
+ const end = cursor + leafCount;
5424
+ cursor = end;
5425
+ let span = 0;
5426
+ for (let c = start; c < end; c++) if (idxSet[c]) span = span + 1;
5427
+ if (span > 0) out.push({
5428
+ header: h,
5429
+ span
5430
+ });
5431
+ }
5432
+ return out;
5433
+ };
5434
+
5435
+ // windowedColSpan(): the body row's rendered <td> COUNT under column windowing — the windowed
5436
+ // cell slice length plus the two rdt-col-spacer cells, so the leading/trailing full-row spacer
5437
+ // <tr>s and the #detail <tr> span the SAME column count the body row actually renders (D-06/
5438
+ // D-11's alignment obligation under table-layout:fixed). Off → visibleColCount() unchanged
5439
+ // (byte-identical).
5440
+ windowedColSpan = () => this.colsWindowed() ? this.windowedColIndices().length + 2 : this.visibleColCount();
5441
+
5442
+ // remeasureColumnWindow() (Phase 87 87-04): push a fresh column count/size into the SECOND
5443
+ // (horizontal) virtualizer on every row-model refresh — mirrors the row axis's
5444
+ // virtualizer.setOptions(virtualizerOptions()) + _willUpdate() re-feed in refreshRowModel, so a
5445
+ // runtime column-visibility/order/pinning change (all watched in the $watch list below) keeps
5446
+ // the column virtualizer's count in sync. Never a render helper (Pitfall 1); rides the EXISTING
5447
+ // refreshRowModel imperative re-feed cycle rather than adding a third scheduling path. Declared
5448
+ // HERE (DataTable.rozie's own script), not in virtualization.rzts — see that file's comment for
5449
+ // why: it must call columnVirtualizerOptions() (windowing.rzts), and a function inside
5450
+ // virtualization.rzts calling into windowing.rzts is a forward-reference TDZ on React (the
5451
+ // 87-02 predicate-layer lesson, one file-grouping boundary over).
5452
+ remeasureColumnWindow = () => {
5453
+ if (!this.colsWindowed() || !this.colVirtualizer) return;
5454
+ this.colVirtualizer.setOptions(this.columnVirtualizerOptions());
5455
+ this.colVirtualizer._willUpdate();
5456
+ };
5457
+
4570
5458
  // B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
4571
5459
  // flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
4572
5460
  // plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
@@ -4645,8 +5533,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4645
5533
  focus: null
4646
5534
  };
4647
5535
  const clampCorner = (c: any) => c == null ? null : {
4648
- rowIndex: this.clamp(c.rowIndex, 0, maxRow),
4649
- colIndex: this.clamp(c.colIndex, 0, maxCol)
5536
+ rowIndex: clamp(c.rowIndex, 0, maxRow),
5537
+ colIndex: clamp(c.colIndex, 0, maxCol)
4650
5538
  };
4651
5539
  return {
4652
5540
  anchor: clampCorner(a),
@@ -4706,8 +5594,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4706
5594
  colIndex: this._activeColIndex.value
4707
5595
  };
4708
5596
  }
4709
- const nextRow = this.clamp(focus.rowIndex + dRow, 0, maxRow);
4710
- const nextCol = this.clamp(focus.colIndex + dCol, 0, maxCol);
5597
+ const nextRow = clamp(focus.rowIndex + dRow, 0, maxRow);
5598
+ const nextCol = clamp(focus.colIndex + dCol, 0, maxCol);
4711
5599
  const nextFocus = {
4712
5600
  rowIndex: nextRow,
4713
5601
  colIndex: nextCol
@@ -4744,8 +5632,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4744
5632
  rowIndex: this._activeRow.value,
4745
5633
  colIndex: this._activeColIndex.value
4746
5634
  };
4747
- const r = this.clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
4748
- const c = this.clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
5635
+ const r = clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
5636
+ const c = clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
4749
5637
  const nextFocus = {
4750
5638
  rowIndex: r,
4751
5639
  colIndex: c
@@ -4822,16 +5710,16 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4822
5710
  return;
4823
5711
  }
4824
5712
  if (a) {
4825
- const ar = this.clamp(a.rowIndex, 0, maxRow);
4826
- const ac = this.clamp(a.colIndex, 0, maxCol);
5713
+ const ar = clamp(a.rowIndex, 0, maxRow);
5714
+ const ac = clamp(a.colIndex, 0, maxCol);
4827
5715
  if (ar !== a.rowIndex || ac !== a.colIndex) this._rangeAnchor.value = {
4828
5716
  rowIndex: ar,
4829
5717
  colIndex: ac
4830
5718
  };
4831
5719
  }
4832
5720
  if (f) {
4833
- const fr = this.clamp(f.rowIndex, 0, maxRow);
4834
- const fc = this.clamp(f.colIndex, 0, maxCol);
5721
+ const fr = clamp(f.rowIndex, 0, maxRow);
5722
+ const fc = clamp(f.colIndex, 0, maxCol);
4835
5723
  if (fr !== f.rowIndex || fc !== f.colIndex) this._rangeFocus.value = {
4836
5724
  rowIndex: fr,
4837
5725
  colIndex: fc
@@ -4839,14 +5727,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4839
5727
  }
4840
5728
  };
4841
5729
 
4842
- // ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
4843
- // The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
4844
- // TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
4845
- // N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
4846
- // ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
4847
- // cell is written as plain string DATA through the per-column validator and rendered via the
4848
- // SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
4849
- // cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).
4850
5730
  // announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
4851
5731
  // pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
4852
5732
  announce = (msg: any) => {
@@ -4882,10 +5762,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4882
5762
  const maxRow = this.bodyRowCount() - 1;
4883
5763
  const maxCol = this.visibleColCount() - 1;
4884
5764
  if (maxRow < 0 || maxCol < 0) return null;
4885
- const ar = this.clamp(a.rowIndex, 0, maxRow);
4886
- const ac = this.clamp(a.colIndex, 0, maxCol);
4887
- const fr = this.clamp(f.rowIndex, 0, maxRow);
4888
- const fc = this.clamp(f.colIndex, 0, maxCol);
5765
+ const ar = clamp(a.rowIndex, 0, maxRow);
5766
+ const ac = clamp(a.colIndex, 0, maxCol);
5767
+ const fr = clamp(f.rowIndex, 0, maxRow);
5768
+ const fc = clamp(f.colIndex, 0, maxCol);
4889
5769
  return {
4890
5770
  r0: ar < fr ? ar : fr,
4891
5771
  r1: ar > fr ? ar : fr,
@@ -4894,17 +5774,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4894
5774
  };
4895
5775
  };
4896
5776
 
4897
- // B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
4898
- // or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
4899
- // field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
4900
- // newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
4901
- escapeTsvField = (s: any) => {
4902
- if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
4903
- return '"' + s.replace(/"/g, '""') + '"';
4904
- }
4905
- return s;
4906
- };
4907
-
4908
5777
  // rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
4909
5778
  // reading each cell's value off the visible model by index (cellValueAt). A single active
4910
5779
  // cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
@@ -4919,91 +5788,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4919
5788
  const cells = [];
4920
5789
  for (let c = c0; c <= c1; c++) {
4921
5790
  const v = this.cellValueAt(r, c);
4922
- cells.push(this.escapeTsvField(v == null ? '' : String(v)));
5791
+ cells.push(escapeTsvField(v == null ? '' : String(v)));
4923
5792
  }
4924
5793
  lines.push(cells.join('\t'));
4925
5794
  }
4926
5795
  return lines.join('\n');
4927
5796
  };
4928
5797
 
4929
- // parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
4930
- // newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
4931
- // the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
4932
- parseTsv = (text: any) => {
4933
- const str = text != null ? String(text) : '';
4934
- // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
4935
- // large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
4936
- // single-pass scan allocates a cell-per-character grid.
4937
- if (str === '' || str.length > 2000000) return [];
4938
- // B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
4939
- // cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
4940
- // newlines, and doubled quotes ("") inside it are literal content until the closing quote;
4941
- // an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
4942
- const rows = [];
4943
- let row = [];
4944
- let field = '';
4945
- let inQuotes = false;
4946
- let i = 0;
4947
- const n = str.length;
4948
- while (i < n) {
4949
- const ch = str[i];
4950
- if (inQuotes) {
4951
- if (ch === '"') {
4952
- if (i + 1 < n && str[i + 1] === '"') {
4953
- field = field + '"';
4954
- i = i + 2;
4955
- continue;
4956
- }
4957
- inQuotes = false;
4958
- i = i + 1;
4959
- continue;
4960
- }
4961
- field = field + ch;
4962
- i = i + 1;
4963
- continue;
4964
- }
4965
- if (ch === '"' && field === '') {
4966
- inQuotes = true;
4967
- i = i + 1;
4968
- continue;
4969
- }
4970
- if (ch === '\t') {
4971
- row.push(field);
4972
- field = '';
4973
- i = i + 1;
4974
- continue;
4975
- }
4976
- if (ch === '\r') {
4977
- if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
4978
- row.push(field);
4979
- field = '';
4980
- rows.push(row);
4981
- row = [];
4982
- i = i + 1;
4983
- continue;
4984
- }
4985
- if (ch === '\n') {
4986
- row.push(field);
4987
- field = '';
4988
- rows.push(row);
4989
- row = [];
4990
- i = i + 1;
4991
- continue;
4992
- }
4993
- field = field + ch;
4994
- i = i + 1;
4995
- }
4996
- // Flush the trailing field + row.
4997
- row.push(field);
4998
- rows.push(row);
4999
- // Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
5000
- if (rows.length > 1) {
5001
- const last = rows[rows.length - 1];
5002
- if (last.length === 1 && last[0] === '') rows.pop();
5003
- }
5004
- return rows;
5005
- };
5006
-
5007
5798
  // copyRange(): write the current range as TSV to the clipboard (async). No-op when the
5008
5799
  // async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
5009
5800
  copyRange = () => {
@@ -5057,7 +5848,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5057
5848
  const field = this.fieldOfColId(colId);
5058
5849
  const srcIndex = this.sourceIndexOfRow(r);
5059
5850
  const oldValue = rowObj ? rowObj[field] : null;
5060
- next = this.replaceRowValue(next, srcIndex, field, value);
5851
+ next = replaceRowValue(next, srcIndex, field, value);
5061
5852
  committed.push({
5062
5853
  rowId: this.rowIdAt(r),
5063
5854
  columnId: colId,
@@ -5101,43 +5892,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5101
5892
  return row ? row.id : null;
5102
5893
  };
5103
5894
 
5104
- // C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
5105
- // paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
5106
- // dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
5107
- // the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
5108
- // clipboard LARGER than the selection pastes its full block from the top-left (preserving the
5109
- // no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
5110
- // yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
5111
- // nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
5112
- tileGridToBox = (grid: any, box: any) => {
5113
- const srcRows = grid.length;
5114
- // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
5115
- // (a later row WIDER than the first, e.g. TSV "a\tb\nc\td\te") would otherwise never read
5116
- // the extra column and silently drop those cells. A row SHORTER than srcCols tiles its
5117
- // missing cells as '' (the `v != null ? v : ''` coercion below), never undefined.
5118
- let srcCols = 0;
5119
- for (let i = 0; i < srcRows; i++) {
5120
- const w = grid[i] && grid[i].length ? grid[i].length : 0;
5121
- if (w > srcCols) srcCols = w;
5122
- }
5123
- if (srcRows <= 0 || srcCols <= 0) return grid;
5124
- const boxRows = box.r1 - box.r0 + 1;
5125
- const boxCols = box.c1 - box.c0 + 1;
5126
- const rows = boxRows > srcRows ? boxRows : srcRows;
5127
- const cols = boxCols > srcCols ? boxCols : srcCols;
5128
- const out = [];
5129
- for (let r = 0; r < rows; r++) {
5130
- const srcLine = grid[r % srcRows] || [];
5131
- const line = [];
5132
- for (let c = 0; c < cols; c++) {
5133
- const v = srcLine[c % srcCols];
5134
- line.push(v != null ? v : '');
5135
- }
5136
- out.push(line);
5137
- }
5138
- return out;
5139
- };
5140
-
5141
5895
  // pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
5142
5896
  // (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
5143
5897
  // clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
@@ -5169,11 +5923,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5169
5923
  }
5170
5924
  if (!p || !p.then) return;
5171
5925
  p.then((text: any) => {
5172
- const grid = this.parseTsv(text);
5926
+ const grid = parseTsv(text);
5173
5927
  if (!grid.length) return;
5174
5928
  // C3: tile the clipboard block to fill the destination range (single→range fill,
5175
5929
  // smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
5176
- const tiled = this.tileGridToBox(grid, destBox);
5930
+ const tiled = tileGridToBox(grid, destBox);
5177
5931
  this.applyGridToRange(tiled, anchorRow, anchorCol);
5178
5932
  }).catch(() => {});
5179
5933
  };
@@ -5238,18 +5992,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5238
5992
  this.applyGridToRange(grid, r0, c0);
5239
5993
  };
5240
5994
 
5241
- // tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
5242
- // the source block), handling indices below lo (negative offset) correctly. A 1-wide source
5243
- // (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
5244
- // cell it copies — so each column copies its OWN source value down its OWN column.
5245
- tileIndex = (i: any, lo: any, hi: any) => {
5246
- const span = hi - lo + 1;
5247
- if (span <= 1) return lo;
5248
- let k = (i - lo) % span;
5249
- if (k < 0) k = k + span;
5250
- return lo + k;
5251
- };
5252
-
5253
5995
  // fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
5254
5996
  // SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
5255
5997
  // the range); each target cell copies the source cell in its OWN column (and row, when the
@@ -5295,8 +6037,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5295
6037
  for (let r = box.r0; r <= box.r1; r++) {
5296
6038
  const cols = [];
5297
6039
  for (let c = box.c0; c <= box.c1; c++) {
5298
- const sr = this.tileIndex(r, src.r0, src.r1);
5299
- const sc = this.tileIndex(c, src.c0, src.c1);
6040
+ const sr = tileIndex(r, src.r0, src.r1);
6041
+ const sc = tileIndex(c, src.c0, src.c1);
5300
6042
  const v = this.cellValueAt(sr, sc);
5301
6043
  cols.push(v == null ? '' : String(v));
5302
6044
  }
@@ -5318,6 +6060,52 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5318
6060
 
5319
6061
  fillDragUp: any = null;
5320
6062
 
6063
+ // Phase 87 87-06 (D-13, T-87-06-02): the single rAF handle driving the fill-drag per-axis
6064
+ // edge auto-scroll — a module-`let` beside fillDragMove/fillDragUp (the `let table` precedent;
6065
+ // React hoists to useRef), cleared IDEMPOTENTLY in teardownFillDrag() so an interrupted gesture
6066
+ // never leaves an orphaned scroll loop running forever.
6067
+ fillEdgeScrollRaf: any = null;
6068
+
6069
+ // Phase 87 87-06 (D-13): per-axis edge-triggered auto-scroll for the fill drag.
6070
+ // FILL_EDGE_SCROLL_PX — the pixel distance from the scroll container's edge, on either axis,
6071
+ // that engages auto-scroll. 24px: comfortably wider than the fill handle's own hit target but
6072
+ // well inside a typical row/column size, so the gesture recognizably nears an edge before
6073
+ // scrolling engages (not so wide that an ordinary drag near the middle of the viewport
6074
+ // spuriously triggers it).
6075
+ FILL_EDGE_SCROLL_PX = 24;
6076
+
6077
+ // FILL_EDGE_SCROLL_STEP — pixels scrolled per animation frame while the pointer sits inside an
6078
+ // edge zone. A fixed step (not distance-proportional) keeps the scroll speed predictable and
6079
+ // the WR-01-style spacer/window math simple to reason about frame-to-frame.
6080
+ FILL_EDGE_SCROLL_STEP = 16;
6081
+
6082
+ // edgeDelta(clientX, clientY): the per-frame (dx, dy) scroll delta for a pointer at the given
6083
+ // screen position, relative to the scroll container `gridScrollEl` already observed by the row
6084
+ // AND column virtualizers (D-03 — one shared `.rdt-scroll` wrapper for whichever axis/axes are
6085
+ // windowed). Each axis is independently gated on colsWindowed()/rowsWindowed() (never a bare
6086
+ // prop truthiness read — the 87-02 prohibition gate) so a drag near the left/right edge of a
6087
+ // row-only-windowed table (or vice versa) never scrolls an axis that has nothing to reveal.
6088
+ // Pure — reads no gesture state, only the live container rect + the windowing predicates.
6089
+ edgeDelta = (clientX: any, clientY: any) => {
6090
+ if (!this.gridScrollEl) return {
6091
+ dx: 0,
6092
+ dy: 0
6093
+ };
6094
+ const rect = this.gridScrollEl.getBoundingClientRect();
6095
+ let dx = 0;
6096
+ let dy = 0;
6097
+ if (this.colsWindowed()) {
6098
+ if (clientX - rect.left < this.FILL_EDGE_SCROLL_PX) dx = -this.FILL_EDGE_SCROLL_STEP;else if (rect.right - clientX < this.FILL_EDGE_SCROLL_PX) dx = this.FILL_EDGE_SCROLL_STEP;
6099
+ }
6100
+ if (this.rowsWindowed()) {
6101
+ if (clientY - rect.top < this.FILL_EDGE_SCROLL_PX) dy = -this.FILL_EDGE_SCROLL_STEP;else if (rect.bottom - clientY < this.FILL_EDGE_SCROLL_PX) dy = this.FILL_EDGE_SCROLL_STEP;
6102
+ }
6103
+ return {
6104
+ dx,
6105
+ dy
6106
+ };
6107
+ };
6108
+
5321
6109
  teardownFillDrag = () => {
5322
6110
  if (typeof document !== 'undefined') {
5323
6111
  if (this.fillDragMove) document.removeEventListener('pointermove', this.fillDragMove);
@@ -5326,6 +6114,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5326
6114
  this.fillDragMove = null;
5327
6115
  this.fillDragUp = null;
5328
6116
  this.fillDragging = false;
6117
+ // D-13 (T-87-06-02): idempotent cleanup of the edge-auto-scroll rAF loop — the SAME discipline
6118
+ // fillDragMove/fillDragUp already follow. Called both on a normal pointerup release AND at the
6119
+ // TOP of the next onFillHandlePointerDown (the #leak guard below), so an interrupted gesture
6120
+ // (pointer released off-window, context menu, alt-tab) can never leave a stray rAF scrolling
6121
+ // the table forever.
6122
+ if (this.fillEdgeScrollRaf != null) {
6123
+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.fillEdgeScrollRaf);
6124
+ this.fillEdgeScrollRaf = null;
6125
+ }
5329
6126
  };
5330
6127
 
5331
6128
  cellIndexFromPoint = (clientX: any, clientY: any) => {
@@ -5363,7 +6160,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5363
6160
  // pointerup was missed (pointer released off-window, context menu, alt-tab), the prior fillDrag's
5364
6161
  // document pointermove/pointerup stay attached; overwriting fillDragMove/fillDragUp below would
5365
6162
  // strand them (removeEventListener could never reach the old refs) → a permanent global
5366
- // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached).
6163
+ // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached) — it ALSO
6164
+ // cancels any orphaned edge-auto-scroll rAF from the same prior gesture.
5367
6165
  this.teardownFillDrag();
5368
6166
  this.fillDragging = true;
5369
6167
  // B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
@@ -5377,21 +6175,74 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5377
6175
  r: sourceBox.r1,
5378
6176
  c: sourceBox.c1
5379
6177
  } : null;
6178
+ // D-13: the pointer's LAST known screen position, updated on every real pointermove AND read
6179
+ // by the rAF loop below on every frame. The scroll loop must re-resolve the cell under this
6180
+ // FIXED screen point each frame — the pointer itself may not move at all while the user simply
6181
+ // holds it at the edge, but the content sliding beneath it means a DIFFERENT cell sits there
6182
+ // each frame, which is what must extend the range (this is the whole point of "edge-triggered
6183
+ // auto-scroll": a real pointermove would never fire on a stationary pointer).
6184
+ let lastClientX = 0;
6185
+ let lastClientY = 0;
6186
+ // applyPointAt: resolve the cell under (clientX, clientY) and extend the range to it if it is
6187
+ // a NEW cell (B20 dedup — unchanged from the pre-87-06 shape). Shared by both a real
6188
+ // pointermove and every frame of the edge-auto-scroll loop.
6189
+ const applyPointAt = (clientX: any, clientY: any) => {
6190
+ const cell = this.cellIndexFromPoint(clientX, clientY);
6191
+ if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
6192
+ lastCell = cell;
6193
+ this.setRangeFocus(cell.r, cell.c);
6194
+ }
6195
+ };
6196
+ // scrollStep: the single rAF loop (T-87-06-02) — reads the LAST known pointer position each
6197
+ // frame, scrolls the windowed axis/axes still within the edge threshold, then re-resolves the
6198
+ // cell now under that same screen point. Self-terminates (never reschedules) the moment the
6199
+ // gesture ends OR the pointer no longer sits in an edge zone — so leaving the edge, or a
6200
+ // pointerup, stops the scroll immediately with no lingering frame.
6201
+ const scrollStep = () => {
6202
+ if (!this.fillDragging) {
6203
+ this.fillEdgeScrollRaf = null;
6204
+ return;
6205
+ }
6206
+ const d = this.edgeDelta(lastClientX, lastClientY);
6207
+ if (d.dx === 0 && d.dy === 0) {
6208
+ this.fillEdgeScrollRaf = null;
6209
+ return;
6210
+ }
6211
+ if (this.gridScrollEl) {
6212
+ // T-87-06-03: direct scrollLeft/scrollTop writes — NEVER virtualizer.scrollToOffset /
6213
+ // colVirtualizer.scrollToOffset. A programmatic virtual-core scroll sets
6214
+ // virtualizer.scrollState, which remeasureWindow() bails on for as long as it is non-null;
6215
+ // a SUSTAINED auto-scroll driven through that API would suppress row remeasurement for the
6216
+ // whole gesture. scrollLeft/scrollTop are ordinary user-scroll-shaped writes (scrollState
6217
+ // stays null), so remeasureWindow() keeps observing recycled rows normally throughout.
6218
+ if (d.dx !== 0) this.gridScrollEl.scrollLeft = this.gridScrollEl.scrollLeft + d.dx;
6219
+ if (d.dy !== 0) this.gridScrollEl.scrollTop = this.gridScrollEl.scrollTop + d.dy;
6220
+ }
6221
+ applyPointAt(lastClientX, lastClientY);
6222
+ this.fillEdgeScrollRaf = requestAnimationFrame(scrollStep);
6223
+ };
5380
6224
  const move = (ev: any) => {
5381
6225
  if (!this.fillDragging) return;
5382
- const cell = this.cellIndexFromPoint(ev.clientX, ev.clientY);
6226
+ lastClientX = ev.clientX;
6227
+ lastClientY = ev.clientY;
5383
6228
  // B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
5384
6229
  // pointermove (the pointer fires many per cell) spams the event with identical payloads.
5385
6230
  // Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
5386
6231
  // seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
5387
6232
  // or re-enters the same cell is suppressed (the range is unchanged).
5388
- if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
5389
- lastCell = cell;
5390
- this.setRangeFocus(cell.r, cell.c);
6233
+ applyPointAt(ev.clientX, ev.clientY);
6234
+ // D-13: start the edge-auto-scroll loop the FIRST time the pointer enters an edge zone
6235
+ // (idempotent — fillEdgeScrollRaf is only null when no loop is currently scheduled). The
6236
+ // loop itself (scrollStep) re-evaluates edgeDelta every frame and self-terminates once the
6237
+ // pointer leaves the zone, so no separate "still in zone" check is needed here.
6238
+ if (this.fillEdgeScrollRaf == null && typeof requestAnimationFrame === 'function') {
6239
+ const d = this.edgeDelta(ev.clientX, ev.clientY);
6240
+ if (d.dx !== 0 || d.dy !== 0) this.fillEdgeScrollRaf = requestAnimationFrame(scrollStep);
5391
6241
  }
5392
6242
  };
5393
6243
  const up = () => {
5394
- // teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
6244
+ // teardownFillDrag clears fillDragging + removes both listeners + cancels any in-flight
6245
+ // edge-auto-scroll rAF (CR-04 / T-87-06-02 shared path).
5395
6246
  this.teardownFillDrag();
5396
6247
  // A plain click on the fill handle (pointerdown+up with NO intervening drag) leaves lastCell
5397
6248
  // at the source box's own origin corner (r1,c1), so fillRange(sourceBox, corner) would
@@ -5490,12 +6341,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5490
6341
  }
5491
6342
  };
5492
6343
 
5493
- // ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
5494
- // Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
5495
- // the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
5496
- // onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
5497
- // D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
5498
- // editable columns lowers byte-identical (the editor branch r-if is always false).
5499
6344
  // The column id at the active cell (the active row's visible cell list @ activeColIndex).
5500
6345
  // Null when out of range (no body rows, or active cell is a header / select column).
5501
6346
  activeCellColumnId = () => {
@@ -5568,29 +6413,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5568
6413
  this._invalidMsg.value = msg != null ? msg : '';
5569
6414
  };
5570
6415
 
5571
- // replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
5572
- // set to the new value); the rest share by reference (the family immutable whole-array
5573
- // replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
5574
- // is over currentData() (== the visible model order for the non-virtual, unsorted/
5575
- // unfiltered single-cell case; the row id is carried for the commit payload).
5576
- replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
5577
- const src = rows || [];
5578
- const out = [];
5579
- for (let i = 0; i < src.length; i++) {
5580
- if (i === rowIndex) {
5581
- // WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
5582
- // and would copy inherited enumerable props of typed/class-instance row objects.
5583
- out.push({
5584
- ...(src[i] || {}),
5585
- [field]: value
5586
- });
5587
- } else {
5588
- out.push(src[i]);
5589
- }
5590
- }
5591
- return out;
5592
- };
5593
-
5594
6416
  // Map a visible-model body-row index ($data.rows index) to its underlying currentData()
5595
6417
  // index via the row's original object identity (sorting/filtering/pagination may reorder
5596
6418
  // the visible model away from the source array order). Falls back to the same index.
@@ -5775,23 +6597,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5775
6597
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
5776
6598
  };
5777
6599
 
5778
- // B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
5779
- // row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
5780
- // across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
5781
- // via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
5782
- // only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
5783
- // the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
5784
- indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
5785
- const list = rows || [];
5786
- for (let i = 0; i < list.length; i++) {
5787
- const r = list[i];
5788
- if (!r) continue;
5789
- if (rowId != null && r.id === rowId) return i;
5790
- if (rowOriginal != null && r.original === rowOriginal) return i;
5791
- }
5792
- return -1;
5793
- };
5794
-
5795
6600
  // endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
5796
6601
  // draft + invalid state and returns to navigation mode. Does NOT move focus (callers
5797
6602
  // decide where focus lands — commit/cancel return it to the owning cell).
@@ -5905,7 +6710,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5905
6710
  this.committedThisSession = true;
5906
6711
  if (changed) {
5907
6712
  const srcIndex = this.sourceIndexOfRow(this._editingRow.value);
5908
- const next = this.replaceRowValue(this.currentData(), srcIndex, field, newValue);
6713
+ const next = replaceRowValue(this.currentData(), srcIndex, field, newValue);
5909
6714
  this.writeData(next);
5910
6715
  // Exactly one emit per commit, from this single call site (writeData does NOT emit).
5911
6716
  this.dispatchEvent(new CustomEvent("cell-edit-commit", {
@@ -5979,7 +6784,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5979
6784
  // Sync idempotency latch: this toggle is a commit-equivalent (mirrors commitEdit's D-07
5980
6785
  // single-emit discipline) — flip it too so a stray re-entry after this toggle no-ops.
5981
6786
  this.committedThisSession = true;
5982
- this.writeData(this.replaceRowValue(this.currentData(), srcIndex, field, newValue));
6787
+ this.writeData(replaceRowValue(this.currentData(), srcIndex, field, newValue));
5983
6788
  // Exactly one emit per toggle, from this single call site (writeData does NOT emit) —
5984
6789
  // mirrors commitEdit's D-07 single-emit discipline.
5985
6790
  this.dispatchEvent(new CustomEvent("cell-edit-commit", {
@@ -6018,12 +6823,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6018
6823
  this.focusCellWhenReady(focusRow, focusCol);
6019
6824
  };
6020
6825
 
6021
- // ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
6022
- // Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
6023
- // edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
6024
- // replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
6025
- // validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
6026
- // editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.
6027
6826
  // The editable [columnId, field] pairs for a body row at the given visible-model index,
6028
6827
  // in visible-cell order. field is the column's accessorKey (the row-object key to write).
6029
6828
  editableColumnsForRow = (rowIndex: any) => {
@@ -6200,7 +6999,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6200
6999
  if (changed) {
6201
7000
  // ONE fresh-array replace of the SINGLE row object with all field values applied at once.
6202
7001
  const srcIndex = this.sourceIndexOfRow(rowIndex);
6203
- const next = this.replaceRowValues(this.currentData(), srcIndex, fieldValues);
7002
+ const next = replaceRowValues(this.currentData(), srcIndex, fieldValues);
6204
7003
  this.writeData(next);
6205
7004
  // EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
6206
7005
  this.dispatchEvent(new CustomEvent("row-edit-commit", {
@@ -6250,27 +7049,6 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6250
7049
  this.focusCellWhenReady(focusRow, focusCol);
6251
7050
  };
6252
7051
 
6253
- // replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
6254
- // in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
6255
- replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
6256
- const src = rows || [];
6257
- const fv = fieldValues || {};
6258
- const out = [];
6259
- for (let i = 0; i < src.length; i++) {
6260
- if (i === rowIndex) {
6261
- // WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
6262
- // prototype-walking copy. Spread copies own enumerable props only.
6263
- out.push({
6264
- ...(src[i] || {}),
6265
- ...fv
6266
- });
6267
- } else {
6268
- out.push(src[i]);
6269
- }
6270
- }
6271
- return out;
6272
- };
6273
-
6274
7052
  // Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
6275
7053
  // rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
6276
7054
  // cell at the row's end; stop (return null) at grid end. Pure index math over the visible
@@ -6602,8 +7380,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6602
7380
  const lastRow = this.bodyRowCount() - 1;
6603
7381
  const maxRow = lastRow < 0 ? 0 : lastRow;
6604
7382
  const maxCol = this.visibleColCount() - 1;
6605
- const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
6606
- const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
7383
+ const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
7384
+ const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
6607
7385
  // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
6608
7386
  this.committedThisSession = false;
6609
7387
  this._activeIsHeader.value = false;
@@ -6634,7 +7412,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6634
7412
  editRow = (rowIndex: any) => {
6635
7413
  const lastRow = this.bodyRowCount() - 1;
6636
7414
  const maxRow = lastRow < 0 ? 0 : lastRow;
6637
- const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
7415
+ const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
6638
7416
  const rowList = this._rows.value || [];
6639
7417
  const row = rowList[r];
6640
7418
  if (!row) return;
@@ -6706,16 +7484,24 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6706
7484
  // or user nav bumps again → a pending focusAbsCellWhenReady from THIS call aborts.
6707
7485
  this.focusIntentEpoch = this.focusIntentEpoch + 1;
6708
7486
  const maxCol = this.visibleColCount() - 1;
6709
- const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
7487
+ const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
6710
7488
  // C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
6711
7489
  const absLast = this.prePaginationRowCount() - 1;
6712
- const absRow = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
7490
+ const absRow = clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
6713
7491
  // B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
6714
7492
  // real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
6715
7493
  // active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
7494
+ // Phase 87 87-06 (D-08, deferred-items.md "87-03: focusCell's activecell-change emit guard
7495
+ // ignores column-only moves"): also snapshot the PRE-write column so a column-ONLY move (e.g.
7496
+ // focusCell(sameRow, differentCol) — the exact D-08 case) is recognized as a real move too. The
7497
+ // pre-fix guard compared only the row, so DOM focus moved correctly but the event never fired —
7498
+ // logged as a genuine, pre-existing bug in 87-03 and explicitly revisited here because this
7499
+ // plan's absolute-column-index work (and its own acceptance criteria) makes fixing it correct
7500
+ // and cheap now, rather than leaving it deferred.
6716
7501
  const prevAbs = this.toAbsRow(this._activeRow.value);
7502
+ const prevCol = this._activeColIndex.value;
6717
7503
  const prevIsHeader = this._activeIsHeader.value;
6718
- if (this.virtual) {
7504
+ if (this.rowsWindowed()) {
6719
7505
  // Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
6720
7506
  // the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
6721
7507
  // focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
@@ -6749,7 +7535,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6749
7535
  this.focusActiveCell(localRow, c, false);
6750
7536
  }
6751
7537
  }
6752
- if (absRow !== prevAbs || prevIsHeader) {
7538
+ if (absRow !== prevAbs || c !== prevCol || prevIsHeader) {
6753
7539
  this.dispatchEvent(new CustomEvent("activecell-change", {
6754
7540
  detail: {
6755
7541
  rowIndex: absRow,