@rozie-ui/data-table-lit 0.2.4 → 0.2.6

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
@@ -427,7 +427,7 @@ export default class DataTable extends SignalWatcher(LitElement) {
427
427
  /**
428
428
  * The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through `r-model:data` (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher).
429
429
  * @example
430
- * <DataTable r-model:data="rows" :columns="cols" />
430
+ * <rozie-data-table .data=${rows} @data-change=${…} .columns=${cols}></rozie-data-table>
431
431
  */
432
432
  @property({ type: Array, attribute: 'data' }) _data_attr!: any[];
433
433
  private _dataControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'data-change', defaultValue: [], initialControlledValue: undefined });
@@ -661,6 +661,16 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
661
661
  @state() private _hasSlotDetail = false;
662
662
  @queryAssignedElements({ slot: 'detail', flatten: true }) private _slotDetailElements!: Element[];
663
663
  @property({ attribute: false }) detail?: (scope: { row: any }) => unknown;
664
+ // Phase 79 Plan 08 (R4) contract for 79-09: the record intake for
665
+ // record-routed slot fills. 79-09's consumer-side emitSlotFiller
666
+ // accumulates an object literal onto the SAME `.rozieSlots=${{ ... }}`
667
+ // open-tag binding; the KEY is the fill's authored (possibly
668
+ // non-identifier) name and the VALUE is a scope-taking render
669
+ // function. `rozieSlots?.[name]` must be checked BEFORE the legacy
670
+ // named function-prop / <slot> fallback (AC-9). Attribute
671
+ // deserialization is disabled — this is a function-valued record,
672
+ // never reflected to/from an HTML attribute.
673
+ @property({ attribute: false }) rozieSlots?: Record<string, (scope: any) => unknown>;
664
674
 
665
675
  private _disconnectCleanups: Array<() => void> = [];
666
676
  // Re-parenting guard: set true once the deferred teardown has actually
@@ -1342,22 +1352,86 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1342
1352
 
1343
1353
  table: any = null;
1344
1354
 
1355
+ // ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
1356
+ // Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
1357
+ // const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
1358
+ // holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
1359
+ // the virtualizer observes.
1345
1360
  virtualizer: any = null;
1346
1361
 
1347
1362
  virtualizerCleanup: any = null;
1348
1363
 
1349
1364
  gridScrollEl: any = null;
1350
1365
 
1366
+ // CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
1367
+ // rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
1368
+ // pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
1369
+ // callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
1370
+ // scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
1371
+ // disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
1372
+ // (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
1373
+ // onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
1374
+ // once motion settles between scroll steps) is unaffected.
1351
1375
  remeasurePending = false;
1352
1376
 
1377
+ // ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
1378
+ // Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
1379
+ // via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
1380
+ // later change is a one-line edit.
1353
1381
  GRID_PAGE_STEP = 10;
1354
1382
 
1383
+ // The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
1384
+ // $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
1385
+ // shadow-safe because the query runs from INSIDE the component's own scope (the listbox
1386
+ // querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
1387
+ // computed/template binding (ROZ123).
1355
1388
  gridRoot: any = null;
1356
1389
 
1390
+ // Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
1391
+ // the funnel. A counter (not a boolean) so nested writes are safe.
1357
1392
  programmatic = 0;
1358
1393
 
1394
+ // Focus-intent epoch (#9) — a monotonic counter bumped at every focus-INTENT entry point
1395
+ // (focusActiveCell / focusCell+focusAbsCellWhenReady arm / a genuine active-cell-moving
1396
+ // focusin in syncActiveFromEvent). The two async focus-recovery polls (focusWhenReady for the
1397
+ // virtual off-window scroll, focusAbsCellWhenReady for the paginated page-switch) CAPTURE this
1398
+ // value at arm time (AFTER their own bump) and abort at the top of each iteration if it has since
1399
+ // changed — so a LATER user nav (ArrowKey / click) supersedes a stale poll instead of the poll
1400
+ // yanking focus back frames later. A naive guardMoved "abort if focus moved" check is WRONG here:
1401
+ // both polls arm while focus still sits on the OLD/being-left cell BY DESIGN (scroll-to /
1402
+ // page-switch), so an epoch — bumped only by a NEWER intent — is the correct abort signal.
1359
1403
  focusIntentEpoch = 0;
1360
1404
 
1405
+ // ── Grid-wide undo/redo (260709-8ct) — history STATE lives in top-level `let` (mirroring
1406
+ // `programmatic` above), NOT $data: recording a snapshot on every keystroke must not trigger
1407
+ // a reactive re-render. React hoists each to useRef. undoStack/redoStack hold `data` array
1408
+ // REFERENCES (never deep copies — see undoHistory.rzts's header comment on the shared-row
1409
+ // invariant). restoringHistory suppresses re-recording while an undo()/redo() replay is
1410
+ // in flight.
1411
+ //
1412
+ // The external-swap history reset keys on data ORIGIN, not a timing window. Every internal
1413
+ // writeback stamps its fresh `data` array with a durable, non-enumerable marker under
1414
+ // DATA_WRITE_TOKEN_KEY (see writeData in writeFunnels.rzts); the reset (maybeClearHistoryOnExternal
1415
+ // Swap, below) clears history ONLY when a newly-supplied `$props.data` carries no marker — it did
1416
+ // not come from us, so it is a genuine external dataset swap. Presence of the marker ⟺ "descends
1417
+ // from one of our writes", and it survives EVERYTHING that defeated the four flag/timer variants:
1418
+ // 1. A raw-reference latch (`lastWrittenData === currentData()`) — Vue `reactive()` / Svelte 5
1419
+ // `$state` / Solid store re-wrap a written array in a NEW Proxy on its way back through props,
1420
+ // so `===` never holds. (A non-enumerable own PROPERTY, by contrast, is forwarded through
1421
+ // every target's reactive Proxy via `Reflect.get` — readable through the wrap.)
1422
+ // 2. A single-consume boolean — the re-feed watch fires MULTIPLE times per write; the first pass
1423
+ // consumed the flag, a later pass wrongly cleared.
1424
+ // 3. A content signature (`JSON.stringify`) — the watch can fire with a TRANSIENTLY STALE
1425
+ // `currentData()` mid-settle (Solid/Lit), a real-but-older value → false mismatch.
1426
+ // 4. A deferred settle-window flag (rAF, then a 96ms macrotask) — a slow re-feed on a LARGE
1427
+ // controlled table OUTRAN the window (#8); no fixed timeout can be correct (re-feed latency
1428
+ // scales with dataset size).
1429
+ // A STRING key (not a JS Symbol) is deliberate: it is stable BY VALUE on all six targets with ZERO
1430
+ // caching, whereas a `Symbol()` needs a per-instance memo to hold one identity — and Lit lowers
1431
+ // `$computed(() => Symbol())` to a plain getter that RE-MINTS the Symbol on every read, so writeData
1432
+ // and the reset would stamp/read DIFFERENT symbols and the marker would never match. Non-enumerable
1433
+ // → invisible to JSON.stringify / spread / Object.keys (the consumer's data stays clean); namespaced
1434
+ // so a consumer array never collides.
1361
1435
  DATA_WRITE_TOKEN_KEY = '__rozieDataWriteToken';
1362
1436
 
1363
1437
  undoStack: unknown[] = [];
@@ -1366,10 +1440,37 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1366
1440
 
1367
1441
  restoringHistory: boolean = false;
1368
1442
 
1443
+ // Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
1444
+ // has not bound `expanded` and has not yet toggled any group, group-header rows default to
1445
+ // EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
1446
+ // roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
1447
+ // writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
1448
+ // non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
1449
+ // exactly as before, both for the plain table AND the expandable-rows feature).
1369
1450
  expandedTouched = false;
1370
1451
 
1452
+ // groupingActiveDefault(): is grouping currently engaged (a non-empty ordered key list)? Reads
1453
+ // the same source order as currentState().grouping ($props.grouping ?? $data.groupingDefault) so
1454
+ // the expanded auto-default below tracks the live grouping state on every target.
1371
1455
  groupingActiveDefault = () => ((this.grouping != null ? this.grouping : this._groupingDefault.value) || []).length > 0;
1372
1456
 
1457
+ // effectiveColumnPinning(): the auto-injected select/expander chrome columns are a STRUCTURAL
1458
+ // left-pinned rail — they ALWAYS lead the pinned-left group so the checkbox/chevron stay the
1459
+ // leftmost body cells in EVERY case (fresh, pinned, AND grouped). Two forces would otherwise
1460
+ // push a data column ahead of the checkbox:
1461
+ // 1. Pinning — a consumer pinning `name` left makes it left-pinned; getVisibleCells() returns
1462
+ // [left-pinned, center, right-pinned], so an unpinned (center) checkbox renders AFTER it.
1463
+ // 2. Grouping — table-core's groupedColumnMode defaults to 'reorder', which moves a grouped
1464
+ // column to the FRONT of the order, ahead of an unpinned center checkbox.
1465
+ // Pinning the rail left beats BOTH: the left group always precedes the (grouped-reordered)
1466
+ // center group. We prepend SELECT_COL_ID then EXPANDER_COL_ID (matching the tableColumns
1467
+ // injection order [select, expander, ...userCols]) ahead of any consumer left-pins.
1468
+ // REQUIRES: the chrome column defs carry an explicit `size` (columnBuilders.rzts) — pinStyle's
1469
+ // sticky offset is col.getStart('left') = Σ preceding pinned SIZES, so a size-less chrome column
1470
+ // (table-core's 150px default) would inflate every real pinned column's `left` and overlap.
1471
+ // The consumer's columnPinning model never sees these ids: writeColumnPinning() strips them
1472
+ // on the way back out (writeFunnels.rzts). Note: this ALWAYS-pin makes the default (no-pin)
1473
+ // checkbox a sticky-left rail — an intentional baseline change (VR/snapshot baselines drift).
1373
1474
  effectiveColumnPinning = (): any => {
1374
1475
  const base = this.columnPinning != null ? this.columnPinning : this._columnPinningDefault.value;
1375
1476
  const rail: string[] = [];
@@ -1384,6 +1485,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1384
1485
  };
1385
1486
  };
1386
1487
 
1488
+ // Assemble the live state object from bound r-model slices (?? uncontrolled fallback).
1489
+ // All NINE slices are wired (each ?? its own $data.<slice>Default). table-core reads
1490
+ // this whole object as `state`. Return type annotated `any`: the inferred object-literal
1491
+ // type does not structurally match table-core's `Partial<TableState>` under the strict
1492
+ // bundled-leaf tsc (the columnSizingInfo/pagination shapes widen to Record) — the
1493
+ // runtime shape is correct; `any` sidesteps the over-strict structural check (the
1494
+ // deferred-items strict-tsc #2 / leaf-output-strict-typecheck close).
1387
1495
  currentState = (): any => ({
1388
1496
  sorting: this.sorting != null ? this.sorting : this._sortingDefault.value,
1389
1497
  globalFilter: this.globalFilter != null ? this.globalFilter : this._globalFilterDefault.value,
@@ -1419,10 +1527,28 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1419
1527
  columnSizingInfo: this._columnSizingInfo.value
1420
1528
  });
1421
1529
 
1530
+ // The live row data (Phase 51 req-4): the bound `data` prop when controlled, else the
1531
+ // uncontrolled $data.dataDefault fallback (mirrors currentState's per-slice ?? pattern).
1532
+ // A committed edit funnels a FRESH array through writeData, which writes BOTH sinks; the
1533
+ // re-feed sources here so editing works whether or not the consumer binds r-model:data.
1422
1534
  currentData = (): any => this.data != null ? this.data : this._dataDefault.value;
1423
1535
 
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.)
1424
1542
  isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
1425
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).
1426
1552
  wrapAggregationFn = (fn: any) => {
1427
1553
  if (typeof fn === 'string') return fn;
1428
1554
  if (typeof fn !== 'function') return undefined;
@@ -1435,6 +1561,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1435
1561
  };
1436
1562
  };
1437
1563
 
1564
+ // Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
1565
+ // ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
1566
+ // ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
1567
+ // whose children are built recursively (B12 — grouped/multi-level column headers).
1568
+ // Returns null for an unusable entry (no id/field, unsafe key, empty group).
1438
1569
  buildConfigDef = (c: any) => {
1439
1570
  if (!c) return null;
1440
1571
  // Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
@@ -1546,10 +1677,24 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1546
1677
  return out;
1547
1678
  };
1548
1679
 
1680
+ // The constant id of the auto-injected leading checkbox column (D-04). Distinct from
1681
+ // any consumer column id (the registry/config guard never produces a leading "__").
1549
1682
  SELECT_COL_ID = '__rdt_select';
1550
1683
 
1684
+ // The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
1685
+ // Distinct from any consumer column id (the registry/config guard never produces a leading
1686
+ // "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
1551
1687
  EXPANDER_COL_ID = '__rdt_expander';
1552
1688
 
1689
+ // The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
1690
+ // user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
1691
+ // 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
1692
+ // and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
1693
+ // accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
1694
+ // renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
1695
+ // ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
1696
+ // not a select-all, so without injecting the column single mode would expose NO
1697
+ // selection UI at all.
1553
1698
  selectionEnabled = () => this.selectionMode === 'single' || this.selectionMode === 'multiple';
1554
1699
 
1555
1700
  tableColumns = () => {
@@ -1597,6 +1742,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1597
1742
  return withExpander;
1598
1743
  };
1599
1744
 
1745
+ // ── sorting slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────────
1746
+ // table-core hands an Updater<SortingState> = value | (old)=>new; the onSortingChange
1747
+ // callback applies it against the CURRENT sorting, then this funnel writes a FRESH
1748
+ // array to the uncontrolled default + the two-way model + fires the change event
1749
+ // REGARDLESS of binding. STATIC key (`$data.sortingDefault` / `$model.sorting`) — a
1750
+ // dynamic-key funnel is ROZ106 on all six. The remaining 8 slices each get their own
1751
+ // such funnel in Plans 04/05.
1600
1752
  writeSorting = (next: any) => {
1601
1753
  if (this.programmatic) return;
1602
1754
  this.programmatic++;
@@ -1612,6 +1764,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1612
1764
 
1613
1765
  applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
1614
1766
 
1767
+ // ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
1768
+ // table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
1769
+ // applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
1770
+ // uncontrolled default + the two-way model + fires `expanded-change` REGARDLESS of binding.
1771
+ // `next` may be the `true` expand-all literal OR a { [rowId]: true } object — written
1772
+ // verbatim (Pitfall 2). One emit per change (the shared `programmatic` guard dedups the
1773
+ // React multi-render re-entry, D-07). STATIC key ($data.expandedDefault / $model.expanded).
1615
1774
  writeExpanded = (next: any) => {
1616
1775
  if (this.programmatic) return;
1617
1776
  this.programmatic++;
@@ -1635,6 +1794,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1635
1794
  this.programmatic--;
1636
1795
  };
1637
1796
 
1797
+ // ── grouping slice: STATIC-KEY fresh-array echo-guarded write funnel (phase 50 reqs 4-7) ──
1798
+ // table-core hands an Updater<GroupingState> = value | (old)=>new; onGroupingChange applies it
1799
+ // against the CURRENT grouping, then this funnel writes a FRESH ordered array to the
1800
+ // uncontrolled default + the two-way model + fires `group-change` REGARDLESS of binding. One
1801
+ // emit per change (the shared `programmatic` guard dedups the React multi-render re-entry, D-07).
1802
+ // STATIC key ($data.groupingDefault / $model.grouping). Event stem is `group-change`, NOT
1803
+ // `grouping-change`: the model:true `grouping` prop auto-generates an `onGroupingChange` callback
1804
+ // on the React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
1805
+ // SAME identifier → duplicate-identifier TS2300 (the model-prop==emit-name collision class 50-02
1806
+ // hit with expanded/expanded-change → expand-change). Every sibling slice stems off a DISTINCT
1807
+ // name (sorting→sort-change, rowSelection→selection-change); grouping→group-change follows suit.
1638
1808
  writeGrouping = (next: any) => {
1639
1809
  if (this.programmatic) return;
1640
1810
  this.programmatic++;
@@ -1648,6 +1818,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1648
1818
  this.programmatic--;
1649
1819
  };
1650
1820
 
1821
+ // ── globalFilter slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────
1822
+ // A fresh string (primitive) to the uncontrolled default + the two-way model + fires
1823
+ // `filter-change` REGARDLESS of binding.
1651
1824
  writeGlobalFilter = (next: any) => {
1652
1825
  if (this.programmatic) return;
1653
1826
  this.programmatic++;
@@ -1663,6 +1836,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1663
1836
  this.programmatic--;
1664
1837
  };
1665
1838
 
1839
+ // ── columnFilters slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ─────
1840
+ // table-core hands ColumnFiltersState = [{ id, value }]; write a FRESH array (never
1841
+ // in-place push) + fire `filter-change`. globalFilter + columnFilters both surface
1842
+ // through `filter-change` (per the plan: filter-change fires regardless of binding).
1666
1843
  writeColumnFilters = (next: any) => {
1667
1844
  if (this.programmatic) return;
1668
1845
  this.programmatic++;
@@ -1678,6 +1855,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1678
1855
  this.programmatic--;
1679
1856
  };
1680
1857
 
1858
+ // ── pagination slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ───────
1859
+ // table-core hands { pageIndex, pageSize }; write a FRESH object + fire `page-change`.
1681
1860
  writePagination = (next: any) => {
1682
1861
  if (this.programmatic) return;
1683
1862
  this.programmatic++;
@@ -1691,6 +1870,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1691
1870
  this.programmatic--;
1692
1871
  };
1693
1872
 
1873
+ // ── rowSelection slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
1874
+ // table-core hands RowSelectionState = { [rowId]: true }; write a FRESH object (never
1875
+ // in-place key-set) + fire `selection-change` REGARDLESS of binding.
1694
1876
  writeRowSelection = (next: any) => {
1695
1877
  if (this.programmatic) return;
1696
1878
  this.programmatic++;
@@ -1704,6 +1886,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1704
1886
  this.programmatic--;
1705
1887
  };
1706
1888
 
1889
+ // ── columnVisibility slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──
1890
+ // table-core hands VisibilityState = { [colId]: boolean }; write a FRESH object (never
1891
+ // in-place key-set) + fire `visibility-change` REGARDLESS of binding.
1707
1892
  writeColumnVisibility = (next: any) => {
1708
1893
  if (this.programmatic) return;
1709
1894
  this.programmatic++;
@@ -1717,6 +1902,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1717
1902
  this.programmatic--;
1718
1903
  };
1719
1904
 
1905
+ // ── columnSizing slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────
1906
+ // table-core hands ColumnSizingState = { [colId]: number }; the pointer-drag resize
1907
+ // handle funnels a FRESH sizing object + fires `resize-change` REGARDLESS of binding.
1720
1908
  writeColumnSizing = (next: any) => {
1721
1909
  if (this.programmatic) return;
1722
1910
  this.programmatic++;
@@ -1730,6 +1918,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1730
1918
  this.programmatic--;
1731
1919
  };
1732
1920
 
1921
+ // ── columnOrder slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ────────
1922
+ // table-core hands ColumnOrderState = string[]; write a FRESH order array (never an
1923
+ // in-place splice) + fire `reorder-change` REGARDLESS of binding.
1733
1924
  writeColumnOrder = (next: any) => {
1734
1925
  if (this.programmatic) return;
1735
1926
  this.programmatic++;
@@ -1743,6 +1934,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1743
1934
  this.programmatic--;
1744
1935
  };
1745
1936
 
1937
+ // ── columnPinning slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
1938
+ // table-core hands ColumnPinningState = { left: string[], right: string[] }; write a
1939
+ // FRESH object (never in-place push into left/right) + fire `pin-change` REGARDLESS of
1940
+ // binding.
1746
1941
  writeColumnPinning = (next: any) => {
1747
1942
  if (this.programmatic) return;
1748
1943
  // effectiveColumnPinning() forces the auto-injected chrome ids (select/expander) into the
@@ -1766,6 +1961,38 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1766
1961
  this.programmatic--;
1767
1962
  };
1768
1963
 
1964
+ // ── data slice: STATIC-KEY fresh-array echo-guarded write funnel (Phase 51 req-4) ──
1965
+ // A committed cell/row edit (or paste/fill in a later wave) replaces ONE row object in
1966
+ // a FRESH array and funnels it here. Writes the uncontrolled default + the two-way
1967
+ // model so editing works controlled OR uncontrolled. CRITICAL: writeData does NOT emit —
1968
+ // unlike the 9 state slices (each has one change event fired inside its funnel), the
1969
+ // `data` slice's commit event (`cell-edit-commit`) carries a PER-CELL payload and fires
1970
+ // from the SINGLE commitEdit call site so the count stays exactly one per commit (React
1971
+ // multi-emit dedup, D-07). Echo-guarded by the shared `programmatic` counter so the
1972
+ // re-feed watch never re-enters mid-write.
1973
+ //
1974
+ // 260709-8ct (grid-wide undo/redo): record the PRE-mutation snapshot BEFORE writing, but
1975
+ // ONLY when `$props.undoable` is on AND we are not mid-replay (`!restoringHistory` — an
1976
+ // undo()/redo() call routes back through THIS SAME writeData to reuse the two-way model +
1977
+ // re-feed watch; without the guard the replay would re-record itself and corrupt the
1978
+ // stack). `emitHistoryChangeIfEdged` fires `history-change` only when canUndo/canRedo
1979
+ // availability actually flipped (a long streak of edits that doesn't change availability
1980
+ // must not spam consumers).
1981
+ //
1982
+ // External-swap origin marker: stamp EVERY array we write (undoable or not, incl. an undo/
1983
+ // redo replay) with the durable, non-enumerable marker under DATA_WRITE_TOKEN_KEY. The reset
1984
+ // (maybeClearHistoryOnExternalSwap in DataTable.rozie) clears history only when a new $props.data
1985
+ // lacks the marker → it did not come from us → a genuine external swap. This replaces the
1986
+ // `dataWriteSettling` settle-window flag that a slow re-feed on a large controlled table outran
1987
+ // (#8) — see DATA_WRITE_TOKEN_KEY's declaration in DataTable.rozie for the four flag/timer
1988
+ // variants it supersedes and why the marker is timing-independent. Stamped on the fresh `next`
1989
+ // array (never the consumer's original), non-enumerable so JSON.stringify / spread / Object.keys
1990
+ // never see it. We write a FRESH RAW shallow copy (`fresh`) so the marker lands on an UNWRAPPED
1991
+ // array: an undo/redo replay reuses a snapshot that, in controlled mode, is a framework reactive
1992
+ // PROXY (svelte `$state`, vue `reactive`) — and `Object.defineProperty` does NOT reliably stick
1993
+ // through a proxy's trap, so stamping the raw copy (never the possibly-proxied `next`) keeps the
1994
+ // marker readable. Normal edits already pass a fresh array; the copy shares row references (cheap).
1995
+ // `try` guards the (never-expected) frozen/sealed-array case.
1769
1996
  writeData = (next: any) => {
1770
1997
  if (this.programmatic) return;
1771
1998
  if (this.undoable && !this.restoringHistory) {
@@ -1789,12 +2016,18 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1789
2016
  this.programmatic--;
1790
2017
  };
1791
2018
 
2019
+ // Read the live columnFilters value for a given column id (string-safe; drives the
2020
+ // per-column filter input's bound value). Reads currentState() (NOT a $data re-read
2021
+ // of a just-written key → React stale-read safe).
1792
2022
  columnFilterValue = (colId: any) => {
1793
2023
  const cf = this.currentState().columnFilters || [];
1794
2024
  for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
1795
2025
  return '';
1796
2026
  };
1797
2027
 
2028
+ // Apply a per-column filter value: build a FRESH ColumnFiltersState array (drop the
2029
+ // column's prior entry, append the new one unless empty) and funnel it. Never mutate
2030
+ // the existing array in place (silent on React/Solid/Angular/Lit).
1798
2031
  setColumnFilter = (colId: any, value: any) => {
1799
2032
  const prev = this.currentState().columnFilters || [];
1800
2033
  const next = [];
@@ -1806,6 +2039,25 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1806
2039
  this.writeColumnFilters(next);
1807
2040
  };
1808
2041
 
2042
+ // ── grid-wide undo/redo (260709-8ct) — snapshot-stack history engine ──────────────────────
2043
+ // Per the approved design (docs/superpowers/specs/2026-07-09-data-table-undo-history-design.md,
2044
+ // decisions 1-6, LOCKED). A pure, unit-testable buffer over the FOUR history lets declared
2045
+ // top-level in DataTable.rozie beside `let programmatic = 0` (undoStack/redoStack/
2046
+ // restoringHistory/lastPropsData — NOT $data, so recording an edit causes no reactive
2047
+ // re-render churn on every keystroke). This module holds the FUNCTIONS only; it references
2048
+ // those component-scope lets + `$props`, `$emit`, `writeData`, `currentData` BARE (by name,
2049
+ // zero ES imports) — the SAME inlined-partial pattern writeFunnels.rzts uses for
2050
+ // `programmatic`/`$data`/`$model` (DataTable.rozie is the ONLY place that ES-imports across
2051
+ // .rzts partials; a cross-import between writeFunnels and undoHistory would create an ES
2052
+ // cycle and/or a TDZ on the inlined lets).
2053
+ //
2054
+ // Collision-safe (ROZ121/124/137): none of undo/redo/canUndo/canRedo/clearHistory are
2055
+ // HTMLElement methods, model props, or React auto-generated setters.
2056
+ // Push the PRE-mutation snapshot (a `data` array reference — never a deep copy; unchanged
2057
+ // rows are shared across every retained snapshot because every write funnel already builds a
2058
+ // fresh array reusing unchanged row references, per the design's Memory analysis). Evict the
2059
+ // oldest snapshot once the stack exceeds `undoLimit` (default 100 — DataTable.rozie prop).
2060
+ // Any NEW recording invalidates the redo stack (standard undo semantics).
1809
2061
  recordSnapshot = (current: any) => {
1810
2062
  this.undoStack.push(current);
1811
2063
  const limit = this.undoLimit != null ? this.undoLimit : 100;
@@ -1817,11 +2069,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1817
2069
 
1818
2070
  canRedo = () => this.redoStack.length > 0;
1819
2071
 
2072
+ // Both stacks empty — the external-swap latch (DataTable.rozie reFeed) and the
2073
+ // clearHistory() $expose verb share this single implementation.
1820
2074
  clearHistory = () => {
1821
2075
  this.undoStack = [];
1822
2076
  this.redoStack = [];
1823
2077
  };
1824
2078
 
2079
+ // `$emit('history-change', { canUndo, canRedo })` — the imperative/keyboard $expose verb
2080
+ // contract. Unconditional (used by undo()/redo() themselves, which always fire exactly once
2081
+ // per call per the design — NOT edge-gated there; only the writeData-triggered recording path
2082
+ // below is edge-gated, since a routine sequence of edits would otherwise spam the event).
1825
2083
  emitHistoryChange = () => {
1826
2084
  this.dispatchEvent(new CustomEvent("history-change", {
1827
2085
  detail: {
@@ -1833,12 +2091,22 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1833
2091
  }));
1834
2092
  };
1835
2093
 
2094
+ // Fire `history-change` ONLY when canUndo/canRedo availability flipped since `prevU`/`prevR`
2095
+ // were captured (BEFORE recordSnapshot ran). Called from writeData's recording hook so a
2096
+ // long streak of edits that doesn't change availability (canUndo already true, redo already
2097
+ // empty) does not spam consumers with a no-op event per keystroke.
1836
2098
  emitHistoryChangeIfEdged = (prevU: any, prevR: any) => {
1837
2099
  const nextU = this.canUndo();
1838
2100
  const nextR = this.canRedo();
1839
2101
  if (nextU !== prevU || nextR !== prevR) this.emitHistoryChange();
1840
2102
  };
1841
2103
 
2104
+ // undo(): pop the most recent pre-mutation snapshot, push the CURRENT data onto the redo
2105
+ // stack (so redo can restore it), then replay the popped snapshot through the SAME writeData
2106
+ // seam — under `restoringHistory = true` so writeData's own recording hook does not
2107
+ // re-capture this replay (which would corrupt the stack). Replaying through writeData
2108
+ // (rather than writing $data/$model directly) is deliberate: the two-way $model.data
2109
+ // writeback, the re-feed $watch, and the echo guard all keep working with zero new code.
1842
2110
  undo = () => {
1843
2111
  if (!this.canUndo()) return;
1844
2112
  const prev = this.undoStack.pop();
@@ -1849,6 +2117,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1849
2117
  this.emitHistoryChange();
1850
2118
  };
1851
2119
 
2120
+ // redo(): symmetric — pop the redo stack, push the CURRENT data back onto the undo stack,
2121
+ // replay through the same guarded writeData seam.
1852
2122
  redo = () => {
1853
2123
  if (!this.canRedo()) return;
1854
2124
  const next = this.redoStack.pop();
@@ -1859,8 +2129,21 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1859
2129
  this.emitHistoryChange();
1860
2130
  };
1861
2131
 
2132
+ // Re-read the row model + header groups into $data (fresh arrays → the template
2133
+ // re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
2134
+ // setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
2135
+ // `table`.
1862
2136
  refreshRowModel: any = null;
1863
2137
 
2138
+ // PER-SLICE callbacks hoisted to top-level consts (NOT inlined in createTable) so the
2139
+ // re-feed $watch can re-pass them on every setOptions. On React the createTable
2140
+ // callbacks would otherwise capture the MOUNT-render's currentState() closure (table
2141
+ // instance is built once in $onMount); table-core's setOptions keeps the prior
2142
+ // callbacks unless new ones are supplied, so a stale callback applied each updater
2143
+ // against the mount-time empty slice → the sort cycle never advances + multi-row
2144
+ // selection collapses to the last row (React stale-closure, F6). Re-passing these
2145
+ // fresh (recreated each render on React, reading fresh currentState) in the re-feed
2146
+ // keeps the Updater base value current. No-op cost on the other five.
1864
2147
  onSortingChangeCb = (updater: any) => {
1865
2148
  this.writeSorting(this.applyUpdater(updater, this.currentState().sorting));
1866
2149
  };
@@ -1910,12 +2193,50 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1910
2193
  this._columnSizingInfo.value = next != null ? next : this._columnSizingInfo.value;
1911
2194
  };
1912
2195
 
2196
+ // ══ Vertical row windowing (phase 53, req-1/2/3/6/9/10) — the virtual-core bridge ════════
2197
+ // virtual-core is a pure state machine EXACTLY like table-core: constructed once in $onMount
2198
+ // (ONLY when $props.virtual), its imperative onChange push converted to per-target reactivity
2199
+ // via the SEPARATE $data.windowVer tick, re-fed via setOptions()+_willUpdate() in the
2200
+ // refreshRowModel path (NEVER a render helper — Pitfall 1). Every runtime reference is guarded
2201
+ // so the virtual=false emitted path is dead (req-1).
2202
+ //
2203
+ // Phase 64 (D-04): the PURE windowing math (windowedRows / padTop / padBottom / pmIndexInWindow /
2204
+ // rowIsOutsideWindow / virtualizerOptions / virtualItemKey) now lives in the shared, target-agnostic
2205
+ // `@rozie-ui/headless-core/windowing.rzts` partial and is re-exported below — this file is now the
2206
+ // thin DATA-TABLE HOST SHELL holding only the impure, per-consumer pieces (the table-bound row
2207
+ // source + the DOM/refs/virtualizer-instance machinery + the D-05 edit-pinning hook). The math
2208
+ // dissolves in via inlineScriptPartials() byte-identically; behavior is unchanged (the B13 specs +
2209
+ // dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
2210
+ // windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
2211
+ // scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.
2212
+ // windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
2213
+ // host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
2214
+ // (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
2215
+ // else the normal (paginated) row model — the non-virtual path is byte-unchanged.
1913
2216
  windowSource = () => {
1914
2217
  if (!this.table) return [];
1915
2218
  if (this.virtual) return this.table.getPrePaginationRowModel().rows;
1916
2219
  return this.table.getRowModel().rows;
1917
2220
  };
1918
2221
 
2222
+ // Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
2223
+ // BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
2224
+ // test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
2225
+ // (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
2226
+ // polls for its target cell, so it no longer depends on remeasure timing).
2227
+ //
2228
+ // TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
2229
+ // - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
2230
+ // tick that the windowVer bump triggers, so the recycled nodes already exist by the next
2231
+ // microtask — measuring there observes them while they are still connected, BEFORE the next
2232
+ // fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
2233
+ // fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
2234
+ // frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
2235
+ // total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
2236
+ // - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
2237
+ // committed yet), so the rAF pass is what observes React's recycled rows.
2238
+ // Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
2239
+ // already-observed node, so running both is cheap and loop-free.
1919
2240
  scheduleRemeasure = () => {
1920
2241
  if (this.remeasurePending) return;
1921
2242
  this.remeasurePending = true;
@@ -1934,18 +2255,38 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1934
2255
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
1935
2256
  };
1936
2257
 
2258
+ // pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
2259
+ // or -1 when no editor is open. Under virtualization `$data.rows` is the FULL pre-pagination
2260
+ // model, so editingRow (single-cell) / editingRowIndex (full-row) — both in that index space —
2261
+ // ARE the full-model index. The pinned row must never recycle while editing (req-9): it is
2262
+ // unioned into the windowed slice when it scrolls off-window and its height is subtracted from
2263
+ // the appropriate spacer so the total stays exactly getTotalSize() (the 51-01-proven mechanism).
2264
+ // This is the data-table half of the D-05 windowing.rzts pin-extension hook (listbox provides none).
1937
2265
  pinnedEditIndex = () => {
1938
2266
  if (this._editingRow.value >= 0) return this._editingRow.value;
1939
2267
  if (this._editingRowIndex.value != null) return this._editingRowIndex.value;
1940
2268
  return -1;
1941
2269
  };
1942
2270
 
2271
+ // pinnedMeasurement(pin): the virtual-core measurement { index, start, size, end, key } for the
2272
+ // pinned full-model index — its measured (or estimated) height + offset, used to (a) decide
2273
+ // whether it sits above/below the rendered window and (b) subtract its height from the right
2274
+ // spacer. Null when out of range / not virtual.
1943
2275
  pinnedMeasurement = (pin: any) => {
1944
2276
  if (!this.virtualizer || pin < 0) return null;
1945
2277
  const ms = this.virtualizer.getMeasurements();
1946
2278
  return ms && ms[pin] ? ms[pin] : null;
1947
2279
  };
1948
2280
 
2281
+ // measureElement sweep (D-10 / CR-01): refine estimated heights to MEASURED ones. The off-root
2282
+ // querySelector idiom (chartjs/cropper/embla precedent — no per-row callback ref). Each rendered
2283
+ // <tr> MUST be handed to virtualizer.measureElement on every window commit for it to be observed:
2284
+ // virtual-core does NOT auto-register rendered rows — measureElement is the SOLE caller of its
2285
+ // internal ResizeObserver's observe() (virtual-core@3.17.1 dist/esm/index.js:794-817), keyed by
2286
+ // getItemKey. So this sweep must run not just once at mount but on every onChange tick (via
2287
+ // scheduleRemeasure), or recycled rows keep the estimateRowHeight seed forever. measureElement is
2288
+ // idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
2289
+ // visible window each commit is cheap and loop-free.
1949
2290
  remeasureWindow = () => {
1950
2291
  if (!this.virtualizer || !this.gridRoot) return;
1951
2292
  // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
@@ -1960,11 +2301,48 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1960
2301
  for (const tr of trs as any) this.virtualizer.measureElement(tr);
1961
2302
  };
1962
2303
 
2304
+ // D-04: this shell exports ONLY the impure, data-table-specific host pieces. The pure windowing
2305
+ // math (windowedRows / padTop / padBottom / pmIndexInWindow / rowIsOutsideWindow / virtualizerOptions
2306
+ // / virtualItemKey) is imported DIRECTLY by the host (DataTable.rozie) from
2307
+ // `@rozie-ui/headless-core/windowing.rzts` via bare specifier — the P0-proven cross-package inline
2308
+ // path that DISSOLVES the partial into the leaf (a re-export-from THROUGH this shell would survive as
2309
+ // a runtime import, not inline — verified). The math closes over these host symbols by convention.
2310
+ // ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
2311
+ // Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
2312
+ // holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
2313
+ // in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
2314
+ // compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
2315
+ //
2316
+ // HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
2317
+ // by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
2318
+ // - windowSource(): T[] — the full list to window (the KEY generalization; the DataTable host
2319
+ // returns its pre-pagination row model, listbox/combobox return the
2320
+ // filtered options). This partial MUST NOT reach into the host data engine
2321
+ // directly — rows arrive ONLY through windowSource().
2322
+ // - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
2323
+ // - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
2324
+ // - gridScrollEl — the scroll-container element handle.
2325
+ // - virtualizer — the host virtual-core instance (built in $onMount from the ref).
2326
+ // - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
2327
+ // - scheduleRemeasure() — the host's rAF/microtask remeasure defer.
2328
+ // - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
2329
+ // defaulting to no-op): the DataTable host passes its edit-pinning hooks;
2330
+ // listbox passes nothing. Routing pinning through this host hook (NOT
2331
+ // inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.
2332
+ // getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
2333
+ // React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
2334
+ // id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
1963
2335
  virtualItemKey = (i: any) => {
1964
2336
  const src = this.windowSource();
1965
2337
  return src && src[i] ? src[i].id : undefined;
1966
2338
  };
1967
2339
 
2340
+ // The FULL virtualizer options. virtual-core's setOptions REPLACES options with
2341
+ // `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
2342
+ // source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
2343
+ // 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.
1968
2346
  virtualizerOptions = (): any => ({
1969
2347
  count: this.windowSource().length,
1970
2348
  getScrollElement: () => this.gridScrollEl,
@@ -1992,6 +2370,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1992
2370
  }
1993
2371
  });
1994
2372
 
2373
+ // pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
2374
+ // shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
2375
+ // two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
2376
+ // no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
2377
+ // `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
2378
+ // guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
2379
+ // thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
2380
+ // gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
2381
+ // Typing-only: the runtime value (a measurement or null) is unchanged.
1995
2382
  pinMeasurement = (pin: number): {
1996
2383
  start: number;
1997
2384
  size: number;
@@ -1999,6 +2386,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1999
2386
  end: number;
2000
2387
  } | null => this.pinnedMeasurement(pin);
2001
2388
 
2389
+ // windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
2390
+ // { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
2391
+ // $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
2392
+ // full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
2393
+ // `rows` binding → TS2448 self-shadow, line ~1149 lesson).
2002
2394
  windowedRows = () => {
2003
2395
  // SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
2004
2396
  // early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
@@ -2069,6 +2461,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2069
2461
  return out;
2070
2462
  };
2071
2463
 
2464
+ // Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
2465
+ // the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
2466
+ // (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
2072
2467
  padTop = () => {
2073
2468
  // SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
2074
2469
  // spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
@@ -2121,11 +2516,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2121
2516
  return pad < 0 ? 0 : pad;
2122
2517
  };
2123
2518
 
2519
+ // pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
2124
2520
  pmIndexInWindow = (items: any, idx: any) => {
2125
2521
  for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
2126
2522
  return false;
2127
2523
  };
2128
2524
 
2525
+ // rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
2526
+ // window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
2129
2527
  rowIsOutsideWindow = (r: any) => {
2130
2528
  if (!this.virtual || !this.virtualizer) return false;
2131
2529
  const items = this.virtualizer.getVirtualItems();
@@ -2133,6 +2531,19 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2133
2531
  return true;
2134
2532
  };
2135
2533
 
2534
+ // ── Sort/filter live-announcement (#14) ─────────────────────────────────────────────
2535
+ // A polite aria-live announcement whenever the consumer changes sorting or filtering, so a
2536
+ // screen-reader user hears that the rows were reordered / narrowed (which is otherwise silent).
2537
+ // announceState holds the last-seen references so the lazy watch below can tell WHICH slice
2538
+ // changed (sort vs filter) and pick the message. It is a top-level mutable const → stabilized
2539
+ // once per instance on all six targets (React useMemo-wraps a mutable instance; the others run
2540
+ // setup once), so it PERSISTS across renders — unlike a top-level `let`, which React resets per
2541
+ // render. Seeded from the initial state in $onMount so the first (post-mount) change compares
2542
+ // against the true starting values, not a null sentinel.
2543
+ // Typed as `unknown` members: these hold opaque last-seen references compared only by
2544
+ // identity (!==) below, never read in a typed context — the annotation keeps the null seed
2545
+ // from narrowing the members to `null` (which would reject the real reassignments under
2546
+ // strictNullChecks in the emitted leaves).
2136
2547
  announceState: {
2137
2548
  sorting: unknown;
2138
2549
  columnFilters: unknown;
@@ -2143,12 +2554,18 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2143
2554
  globalFilter: null
2144
2555
  };
2145
2556
 
2557
+ // Effective (controlled-or-uncontrolled) reads of the sort/filter slices: the bound prop when
2558
+ // the consumer bound the matching r-model, else the uncontrolled $data default (mirrors currentState()).
2146
2559
  effectiveSorting = () => this.sorting != null ? this.sorting : this._sortingDefault.value;
2147
2560
 
2148
2561
  effectiveColumnFilters = () => this.columnFilters != null ? this.columnFilters : this._columnFiltersDefault.value;
2149
2562
 
2150
2563
  effectiveGlobalFilter = () => this.globalFilter != null ? this.globalFilter : this._globalFilterDefault.value;
2151
2564
 
2565
+ // Build the polite message for a sort/filter change and advance announceState. Sort takes
2566
+ // precedence when the sorting reference changed; otherwise a filter changed → the post-filter
2567
+ // result count (the FILTERED total via totalRowCount(), NOT the page slice). Returns '' when
2568
+ // neither actually changed (a no-op watch tick — do not re-announce).
2152
2569
  buildSortFilterAnnounce = () => {
2153
2570
  const nextSorting = this.effectiveSorting();
2154
2571
  const nextColumnFilters = this.effectiveColumnFilters();
@@ -2171,6 +2588,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2171
2588
  return '';
2172
2589
  };
2173
2590
 
2591
+ // Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
2592
+ // re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
2174
2593
  reFeed = () => {
2175
2594
  if (!this.table) return;
2176
2595
  // NOTE: the external-swap history reset does NOT live here. reFeed() fires on EVERY watched
@@ -2236,6 +2655,28 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2236
2655
  if (this.refreshRowModel) this.refreshRowModel();
2237
2656
  };
2238
2657
 
2658
+ // LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
2659
+ // `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
2660
+ // preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
2661
+ // never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
2662
+ // rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
2663
+ // slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
2664
+ // only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
2665
+ // fires on ANY property change incl `data`; guard with a stored last-seen data ref +
2666
+ // length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
2667
+ // targets the watch already covers it; this is a cheap idempotent backstop.
2668
+ // External-swap history reset (grid-wide undo/redo, 260709-8ct; #8 fix). Keyed on the CONTROLLED
2669
+ // `$props.data` REFERENCE changing — deliberately NOT on `currentData()` inside reFeed. An internal
2670
+ // writeback changes `$data.dataDefault` SYNCHRONOUSLY and only LATER round-trips into `$props.data`;
2671
+ // keying on `$props.data`'s OWN change means we never observe the transient window where a fine-
2672
+ // grained target's reFeed reads a stale, unstamped `$props.data` mid-write and wrongly wipes a
2673
+ // just-recorded edit's history (the stale-read false-clear — the SAME failure that broke the
2674
+ // content-signature variant — that regressed Solid/Lit when this clear lived in reFeed). When
2675
+ // `$props.data` genuinely changes: a new array carrying DATA_WRITE_TOKEN round-tripped from one of
2676
+ // OUR writes → keep; one without it is a dataset the consumer handed us → external swap → clear. A
2677
+ // non-data tick (sort/filter/pagination) never touches `$props.data` → never clears. Called from
2678
+ // BOTH the coarse re-feed watch AND the $onUpdate backstop (Lit's @property `data` the effect-
2679
+ // tracked watch can't observe); both are ref-gated so the redundant call is an idempotent no-op.
2239
2680
  lastPropsData: unknown = null;
2240
2681
 
2241
2682
  maybeClearHistoryOnExternalSwap = () => {
@@ -2251,6 +2692,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2251
2692
 
2252
2693
  lastDataLen = -1;
2253
2694
 
2695
+ // Header click → toggle sort. Shift-click → ADD a secondary sort (multi-sort). Driven
2696
+ // through table-core's column API so the onSortingChange funnel emits the fresh state.
2254
2697
  onHeaderSort = (colId: any, evt: any) => {
2255
2698
  if (!this.table) return;
2256
2699
  const col = this.table.getColumn(colId);
@@ -2260,8 +2703,20 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2260
2703
  col.toggleSorting(undefined, multi);
2261
2704
  };
2262
2705
 
2706
+ // aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
2707
+ // Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
2708
+ // template binding that calls a table-READING chrome helper (pagination/sort/pin/
2709
+ // visibility predicates below) re-evaluates when the row model changes. On the
2710
+ // coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
2711
+ // is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
2712
+ // non-reactive `table` let would be computed ONCE (when table is still null → the
2713
+ // default branch) and never update — pagination would read "Page 1 of 1" forever,
2714
+ // aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
2715
+ // helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
2263
2716
  tick = () => this._rowModelVer.value;
2264
2717
 
2718
+ // the live sort direction off the table-core column (string-safe — never a bound
2719
+ // boolean, the listbox aria lesson).
2265
2720
  ariaSortFor = (colId: any) => {
2266
2721
  if (this.tick() < 0 || !this.table) return 'none';
2267
2722
  const col = this.table.getColumn(colId);
@@ -2272,6 +2727,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2272
2727
  return 'none';
2273
2728
  };
2274
2729
 
2730
+ // A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
2275
2731
  sortIndicator = (colId: any) => {
2276
2732
  if (this.tick() < 0 || !this.table) return '';
2277
2733
  const col = this.table.getColumn(colId);
@@ -2282,36 +2738,63 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2282
2738
  return '';
2283
2739
  };
2284
2740
 
2741
+ // Template helpers reading the resolved column-def metadata by id (plain fns — used
2742
+ // in template predicates + interpolation; uniform on all 6, no $computed alias trap).
2285
2743
  defFor = (colId: any) => {
2286
2744
  const defs = this.columnDefs();
2287
2745
  for (const d of defs as any) if (d.id === colId) return d;
2288
2746
  return null;
2289
2747
  };
2290
2748
 
2749
+ // Per-row visible cells for the body loop. table-core memoizes row objects by id,
2750
+ // so a re-pull after a column change (visibility/reorder/pin, or the late <Column>
2751
+ // registry on first mount) returns the SAME row references with a different cell
2752
+ // set. On Solid the row loop keeps the existing <tr> across that pull (`:key="row.id"`
2753
+ // is stable, so the emitter's `<Key>` reconciler holds the node), and Solid will NOT
2754
+ // re-run a child loop whose `each` reads no signal — so a bare `row.getVisibleCells()`
2755
+ // goes stale (header reorders, cells don't). Reading `$data.rowModelVer` (bumped by every
2756
+ // refreshRowModel) inside the `each` puts the inner loop in the reactive scope, so it
2757
+ // re-derives the cells on every row-model change. No-op on the coarse-render targets.
2291
2758
  visibleCellsFor = (row: any) => this._rowModelVer.value >= 0 ? row.getVisibleCells() : [];
2292
2759
 
2760
+ // ── Editable-cell column-meta accessors (phase 51 req-1/2/5) ───────────────────────
2761
+ // editMetaOf: the resolved ColumnDef.meta for a column id (the editable config carried
2762
+ // from <Column>/`:columns` via columnDefs). Null-safe — an unknown/non-editable column
2763
+ // returns null and every predicate below short-circuits to the read-only path.
2293
2764
  editMetaOf = (colId: any) => {
2294
2765
  const d = this.defFor(colId);
2295
2766
  return d && d.meta ? d.meta : null;
2296
2767
  };
2297
2768
 
2769
+ // columnEditable: whether this column opted into editing (req-1). Drives every editor
2770
+ // gate; false → the cell stays the read-only #cell display (byte-identical-off).
2298
2771
  columnEditable = (colId: any) => {
2299
2772
  const m = this.editMetaOf(colId);
2300
2773
  return !!(m && m.editable === true);
2301
2774
  };
2302
2775
 
2776
+ // editorTypeOf: the built-in editor kind ('text'|'number'|'select'|'checkbox') OR
2777
+ // 'custom' (the #editor scoped-slot escape hatch, req-2). Defaults to 'text'.
2303
2778
  editorTypeOf = (colId: any) => {
2304
2779
  const m = this.editMetaOf(colId);
2305
2780
  return m && m.editor != null ? m.editor : 'text';
2306
2781
  };
2307
2782
 
2783
+ // editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
2308
2784
  editorOptionsOf = (colId: any) => {
2309
2785
  const m = this.editMetaOf(colId);
2310
2786
  return m && m.editorOptions != null ? m.editorOptions : [];
2311
2787
  };
2312
2788
 
2789
+ // hasEditorSlot: this column routes through the consumer's #editor scoped slot (req-2)
2790
+ // — true only when the column declared editor='custom' AND the consumer actually
2791
+ // provided an #editor slot. Falls through to the built-in editor otherwise (e.g. a
2792
+ // column marked 'custom' with no slot supplied degrades to the text editor, never blank).
2313
2793
  hasEditorSlot = (colId: any) => this.editorTypeOf(colId) === 'custom' && !!(this._hasSlotEditor || this.editor !== undefined);
2314
2794
 
2795
+ // hasFilterSlot: the consumer supplied a #filter scoped slot, so it OWNS the per-column
2796
+ // filter UI (re-added in 72-05 alongside the dedicated filter row's `<slot name="filter">`
2797
+ // host — see the 72-03 removal note in that plan's SUMMARY for why this was briefly gone).
2315
2798
  hasFilterSlot = () => !!(this._hasSlotFilter || this.filter !== undefined);
2316
2799
 
2317
2800
  columnIsFilterable = (colId: any) => {
@@ -2324,6 +2807,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2324
2807
  return d ? d.header : colId;
2325
2808
  };
2326
2809
 
2810
+ // ── Column-management chrome (req-8/9/10/11) ────────────────────────────────────────
2811
+ // Live header width (px) for a column — drives the <th> :style width binding. Reads the
2812
+ // table-core column size (post-mount) with a fallback to undefined (auto width).
2327
2813
  headerWidth = (colId: any) => {
2328
2814
  if (this.tick() < 0 || !this.table) return null;
2329
2815
  const col = this.table.getColumn(colId);
@@ -2332,6 +2818,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2332
2818
  return w != null && w > 0 ? w + 'px' : null;
2333
2819
  };
2334
2820
 
2821
+ // Pointer-drag resize handler for a resizable header — table-core's getResizeHandler()
2822
+ // returns a function bound to a pointerdown/touchstart event that drives the column
2823
+ // size through onColumnSizingChange (our writeColumnSizing funnel) under
2824
+ // columnResizeMode:'onChange'. Pure delegation; no scratch gesture state held in a
2825
+ // top-level const (the React fragile-binding rule — table-core owns the gesture state).
2335
2826
  onResizeStart = (colId: any, evt: any) => {
2336
2827
  // stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
2337
2828
  if (evt && evt.stopPropagation) evt.stopPropagation();
@@ -2342,6 +2833,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2342
2833
  if (handler) handler(evt);
2343
2834
  };
2344
2835
 
2836
+ // Find the live header object for a column id across the rendered header groups.
2345
2837
  findHeader = (colId: any) => {
2346
2838
  const groups = this._headerGroups.value || [];
2347
2839
  for (const hg of groups as any) {
@@ -2357,6 +2849,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2357
2849
  return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
2358
2850
  };
2359
2851
 
2852
+ // Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
2853
+ // onColumnVisibilityChange funnel emits the fresh state.
2360
2854
  columnIsVisible = (colId: any) => {
2361
2855
  if (this.tick() < 0 || !this.table) return true;
2362
2856
  const col = this.table.getColumn(colId);
@@ -2369,6 +2863,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2369
2863
  if (col && col.toggleVisibility) col.toggleVisibility();
2370
2864
  };
2371
2865
 
2866
+ // The full set of leaf columns (for the visibility-toggle menu) — id + header label +
2867
+ // current visibility. Excludes the auto-injected CHROME columns (select + expander) —
2868
+ // neither is a data column: they carry no header label (so they'd surface their raw
2869
+ // internal id, e.g. '__rdt_expander') and their presence is governed by the
2870
+ // selectionMode/expandable props, not user-toggleable visibility.
2372
2871
  allLeafColumns = () => {
2373
2872
  if (this.tick() < 0 || !this.table) return [];
2374
2873
  const cols = this.table.getAllLeafColumns ? this.table.getAllLeafColumns() : [];
@@ -2384,6 +2883,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2384
2883
  return out;
2385
2884
  };
2386
2885
 
2886
+ // Pinning (req-11) — drive table-core's column.pin('left'|'right'|false) so the
2887
+ // onColumnPinningChange funnel emits a fresh state. Sticky offsets read the live column
2888
+ // start/after positions (table-core computes them from the pinned column sizes).
2387
2889
  columnPinSide = (colId: any) => {
2388
2890
  if (this.tick() < 0 || !this.table) return false;
2389
2891
  const col = this.table.getColumn(colId);
@@ -2391,6 +2893,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2391
2893
  return col.getIsPinned();
2392
2894
  };
2393
2895
 
2896
+ // NOTE: the event is stopped HERE (evt.stopPropagation()) rather than via a `.stop`
2897
+ // template modifier. The Angular emitter, hoisting a `.stop`-modified handler that
2898
+ // lives INSIDE an `@for` loop into a class-field wrapper, drops the component `this.`
2899
+ // qualifier (→ `onPinColumn(...)` bare ReferenceError) and fails to capture the loop
2900
+ // var — so a `@click.stop="onPinColumn(...)"` inside the header `@for` breaks on
2901
+ // Angular (F5). Stopping inside the handler sidesteps the broken hoist on all six.
2394
2902
  onPinColumn = (colId: any, side: any, evt: any) => {
2395
2903
  if (evt && evt.stopPropagation) evt.stopPropagation();
2396
2904
  if (!this.table) return;
@@ -2398,6 +2906,22 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2398
2906
  if (col && col.pin) col.pin(side);
2399
2907
  };
2400
2908
 
2909
+ // Sticky inline style for a pinned header/cell — position:sticky + the computed left or
2910
+ // right offset. Returns '' (no sticky) for unpinned columns. Returned as a STRING (the
2911
+ // :style binding is value-driven — never an eval'd attr).
2912
+ //
2913
+ // `zIndex` (phase 72 fix, default 1 — body <td> / filter-row <th> layer): an INLINE style
2914
+ // ALWAYS wins over the stylesheet's `.rozie-data-table.rdt-sticky .rdt-thead .rdt-th
2915
+ // { z-index: var(--rdt-sticky-z, 2) }` rule, so a pinned header cell that unconditionally
2916
+ // got `z-index:1` here (same as the pinned body/filter-row cells) silently DOWNGRADED the
2917
+ // intended sticky-header stacking level from 2 to 1 — tying it with the dedicated filter
2918
+ // row's own pinned <th> (72-05), which sits LATER in DOM order (a sibling <tr> beneath the
2919
+ // header row) and therefore visually/interactively covers the header's ⋯ menu (phase 72,
2920
+ // z-index:1000 relative to Popover's OWN local stacking context — capped by the pinned
2921
+ // header <th>'s z-index, since a `position:fixed` descendant does not escape an ancestor's
2922
+ // stacking context, only its layout containing block) whenever that SAME column is both
2923
+ // pinned and filterable. thStyle() (the header caller) passes zIndex=2 so the header layer
2924
+ // always wins ties against the filter-row/body layers, which keep the default of 1.
2401
2925
  pinStyle = (colId: any, zIndex = 1) => {
2402
2926
  if (this.tick() < 0 || !this.table) return '';
2403
2927
  const col = this.table.getColumn(colId);
@@ -2414,6 +2938,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2414
2938
  return '';
2415
2939
  };
2416
2940
 
2941
+ // Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
2942
+ // uniform on all 6, no bound-object trap. zIndex=2 (see pinStyle) so a pinned header cell
2943
+ // — which hosts the ⋯ menu's floating content — always stacks above the pinned filter-row
2944
+ // cell for the same column (zIndex=1, its own default).
2417
2945
  thStyle = (colId: any) => {
2418
2946
  let s = '';
2419
2947
  const w = this.headerWidth(colId);
@@ -2422,6 +2950,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2422
2950
  return s;
2423
2951
  };
2424
2952
 
2953
+ // ── Filter chrome handlers ─────────────────────────────────────────────────────────
2954
+ // Global search input → funnel through table-core's setGlobalFilter so the
2955
+ // onGlobalFilterChange callback fires the echo-guarded writer. Capture the fresh local
2956
+ // value (never re-read a just-written $data key — React stale-read).
2425
2957
  onGlobalFilterInput = (evt: any) => {
2426
2958
  const value = evt && evt.target ? evt.target.value : '';
2427
2959
  if (this.table) {
@@ -2431,16 +2963,21 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2431
2963
  this.writeGlobalFilter(value);
2432
2964
  };
2433
2965
 
2966
+ // Per-column filter input → setColumnFilter (fresh-array funnel).
2434
2967
  onColumnFilterInput = (colId: any, evt: any) => {
2435
2968
  const value = evt && evt.target ? evt.target.value : '';
2436
2969
  this.setColumnFilter(colId, value);
2437
2970
  };
2438
2971
 
2972
+ // The live global filter value (bound to the search <input>, value-driven NOT eval'd).
2439
2973
  globalFilterValue = () => {
2440
2974
  const v = this.currentState().globalFilter;
2441
2975
  return v != null ? v : '';
2442
2976
  };
2443
2977
 
2978
+ // ── Pagination chrome ────────────────────────────────────────────────────────────
2979
+ // Read the live pagination state off table-core (post-mount) with a currentState()
2980
+ // fallback (pre-mount / SSR). All string-safe (no bound booleans).
2444
2981
  pageIndex = () => {
2445
2982
  if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageIndex;
2446
2983
  const p = this.currentState().pagination;
@@ -2453,6 +2990,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2453
2990
  return p && p.pageSize != null ? p.pageSize : 10;
2454
2991
  };
2455
2992
 
2993
+ // Renamed from `pageCount` → `displayPageCount`: `pageCount` is now a public prop
2994
+ // (server-side manual pagination), and a same-named top-level helper collides with the
2995
+ // destructured prop on Svelte and the @Input/@property class field on Angular/Lit. This
2996
+ // reader is internal (drives the "Page X of Y" chrome) and reads table-core's live
2997
+ // getPageCount(), which now reflects rowCount/pageCount when manual.
2456
2998
  displayPageCount = () => {
2457
2999
  if (this.tick() < 0 || !this.table) return 1;
2458
3000
  const c = this.table.getPageCount();
@@ -2478,16 +3020,44 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2478
3020
  this.table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
2479
3021
  };
2480
3022
 
3023
+ // ── Row-selection chrome (req-7) ───────────────────────────────────────────────────
3024
+ // Detect the auto-injected leading checkbox column by its constant id (template uses
3025
+ // this to render checkbox chrome instead of an accessor value).
2481
3026
  isSelectColumn = (colId: any) => colId === this.SELECT_COL_ID;
2482
3027
 
3028
+ // ── Expandable-rows template helpers (phase 50, D-04) ──────────────────────────────
3029
+ // isExpanderColumn: the auto-injected leading chevron column predicate (mirrors
3030
+ // isSelectColumn). rowIsExpanded / rowCanExpand read table-core row handles THROUGH the
3031
+ // reactive tick (rowModelVer) so the chevron glyph + aria-expanded + the #detail r-if
3032
+ // re-derive on a re-pull on the fine-grained targets (Solid/Lit) — same discipline as
3033
+ // visibleCellsFor. `!!`-coerced so a bound aria-expanded emits an UNWRAPPED boolean (the
3034
+ // listbox aria lesson — never a rozieAttr string → TS2322 on React/Solid).
2483
3035
  isExpanderColumn = (colId: any) => colId === this.EXPANDER_COL_ID;
2484
3036
 
3037
+ // rowCanExpand gates ONLY the leading expander-column detail chevron. Group-header rows
3038
+ // are excluded (`!getIsGrouped`): with `expandable` + grouping, getRowCanExpand returns
3039
+ // `() => true` for EVERY flattened row, so without this a group header rendered TWO
3040
+ // chevrons — the group-toggle in its grouped cell AND a redundant detail chevron in the
3041
+ // leading column (both fire onToggleExpand on the shared expanded state). A group row's
3042
+ // expand affordance is the group-toggle; the leading-column chevron is detail-only.
2485
3043
  rowCanExpand = (row: any) => !!(this.tick() >= 0 && row && row.getCanExpand && row.getCanExpand() && !(row.getIsGrouped && row.getIsGrouped()));
2486
3044
 
2487
3045
  rowIsExpanded = (row: any) => !!(this.tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
2488
3046
 
3047
+ // rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
3048
+ // row is expanded AND is NOT a group-header row. With getSubRows the children arrive as
3049
+ // ordinary depth-indented rows in $data.rows (table-core flattens) — NO additive detail
3050
+ // row, NO nested r-for (Pitfall 1). The `!rowIsGrouped` guard is load-bearing: grouping
3051
+ // and detail-expand share table-core's SINGLE `expanded` state, so a group-header row is
3052
+ // `getIsExpanded()===true` the moment its group opens; without this guard that expanded
3053
+ // group row also satisfied `getSubRows==null && rowIsExpanded`, painting a spurious
3054
+ // #detail panel under every opened group (the group-toggle looked "linked" to detail).
2489
3055
  rowShowsDetail = (row: any) => this.getSubRows == null && !this.rowIsGrouped(row) && this.rowIsExpanded(row);
2490
3056
 
3057
+ // Toggle a row's expanded state through table-core so onExpandedChange → writeExpanded
3058
+ // fires exactly one expanded-change. Used by the chevron @click (native <button> handles
3059
+ // Enter/Space → click, so NO explicit @keydown.enter/.space — that would DOUBLE-toggle on
3060
+ // a real button; the grid @keydown is inert in 'table' mode, isGrid()-gated).
2491
3061
  onToggleExpand = (row: any, evt: any) => {
2492
3062
  if (!row || !row.toggleExpanded) return;
2493
3063
  // Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
@@ -2511,6 +3081,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2511
3081
  }
2512
3082
  };
2513
3083
 
3084
+ // bodyCellStyle: the non-virtual <td> inline style — pinStyle PLUS a depth-proportional
3085
+ // left pad on the EXPANDER cell so nested getSubRows children visibly indent (row.depth).
3086
+ // Only the expander column indents (the tree affordance lives in its dedicated column);
3087
+ // data columns stay grid-aligned. depth 0 → unchanged (byte-identical-off).
2514
3088
  bodyCellStyle = (row: any, colId: any) => {
2515
3089
  const base = this.pinStyle(colId);
2516
3090
  if (this.isExpanderColumn(colId) && row && row.depth) {
@@ -2521,20 +3095,51 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2521
3095
  return base;
2522
3096
  };
2523
3097
 
3098
+ // ── Grouping template helpers (phase 50 reqs 4-7, D-04/D-05) ───────────────────────────
3099
+ // Group-header rows ARE expandable rows: table-core's getGroupedRowModel FLATTENS them into
3100
+ // $data.rows carrying getIsGrouped()/subRows, so they ride the SAME D-04 <template r-for> seam
3101
+ // (no parallel render path, no nested r-for). These predicates read through the reactive tick
3102
+ // (rowModelVer) so the group chrome + collapse state re-derive on a re-pull on the fine-grained
3103
+ // targets (Solid/Lit) — same discipline as rowIsExpanded/visibleCellsFor. `!!`-coerced (the
3104
+ // listbox aria lesson — a bound boolean must be UNWRAPPED, never a rozieAttr string → TS2322).
3105
+ // rowIsGrouped: this flattened row is a group-header row.
2524
3106
  rowIsGrouped = (row: any) => !!(this.tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
2525
3107
 
3108
+ // groupingActive: grouping is currently engaged (a non-empty ordered key list). Drives the
3109
+ // data-group-leaf marker so it is ABSENT when ungrouped (byte-identical-off, req-10).
2526
3110
  groupingActive = () => this.tick() >= 0 && (this.currentState().grouping || []).length > 0;
2527
3111
 
3112
+ // cellIsGrouped / cellIsAggregated: per-CELL roles on a group-header row. The grouped cell shows
3113
+ // the group key + toggle + count; an aggregated cell shows the rolled-up value through the
3114
+ // EXISTING #cell slot (cell.getValue()) — NO new aggregatedCell template (RESEARCH State of the
3115
+ // Art). A placeholder cell (neither) falls through to the #cell r-else and renders its empty value.
2528
3116
  cellIsGrouped = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
2529
3117
 
2530
3118
  cellIsAggregated = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
2531
3119
 
3120
+ // cellIsPlaceholder: a PLACEHOLDER cell on a group-header row — a non-grouped, non-aggregated
3121
+ // cell that table-core fills with the FIRST leaf row's value (cell.getValue() leaks e.g.
3122
+ // "Services"/"Edsger Dijkstra" onto the group line). Renders BLANK via a dedicated empty
3123
+ // template branch so the leaked leaf value never paints. Tick-gated exactly like cellIsGrouped
3124
+ // so the group chrome re-derives on a re-pull on the fine-grained targets (Solid/Lit).
2532
3125
  cellIsPlaceholder = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsPlaceholder && cellCtx.getIsPlaceholder());
2533
3126
 
3127
+ // groupSubRowCount: the number of underlying LEAF RECORDS under a group-header row (the count
3128
+ // shown in the header, e.g. "North (40)"). row.subRows is the IMMEDIATE members — for MULTI-LEVEL
3129
+ // grouping those are sub-GROUPS, not records, so "North" with 2 categories / 40 records would show
3130
+ // "North (2)". getLeafRows() returns all leaf descendants (the actual record count); keep the
3131
+ // subRows fallback for safety. Single-level grouping is unchanged (getLeafRows == subRows when the
3132
+ // children are already leaves).
2534
3133
  groupSubRowCount = (row: any) => row && row.getLeafRows ? row.getLeafRows().length : row && row.subRows ? row.subRows.length : 0;
2535
3134
 
3135
+ // groupingKeys: the live ordered grouping array — slot prop for the headless #groupBar + the
3136
+ // default styled-token reflection. Reads currentState() ($props.grouping ?? $data.groupingDefault),
3137
+ // both reactive sources, so the bar re-renders on a grouping change across all six targets.
2536
3138
  groupingKeys = () => this.currentState().grouping || [];
2537
3139
 
3140
+ // groupableColumns: the data columns OFFERED to the headless #groupBar (those whose Column/config
3141
+ // `groupable` is not false) — `[{ id, label }]`. Excludes the chrome columns (select/expander are
3142
+ // not in columnDefs()). The consumer builds any bar/drag UI from this; the component ships none.
2538
3143
  groupableColumns = () => {
2539
3144
  const out = [];
2540
3145
  const defs = this.columnDefs();
@@ -2548,10 +3153,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2548
3153
  return out;
2549
3154
  };
2550
3155
 
3156
+ // Plain stop-propagation handler (used in place of the `@click.stop` bare modifier —
3157
+ // a bare `.stop` with no handler hoists to `_guardedUndefined` → `this.undefined($event)`
3158
+ // on Angular inside an `@for`, F5). Calling an explicit handler is uniform on all six.
2551
3159
  stopEvent = (evt: any) => {
2552
3160
  if (evt && evt.stopPropagation) evt.stopPropagation();
2553
3161
  };
2554
3162
 
3163
+ // select-all header state (D-06: scopes to all filtered rows = TanStack default).
3164
+ // `!!`-coerced booleans (the listbox aria lesson — never a bound rozieAttr string).
2555
3165
  isAllRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsAllRowsSelected());
2556
3166
 
2557
3167
  isSomeRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsSomeRowsSelected());
@@ -2561,6 +3171,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2561
3171
  this.table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
2562
3172
  };
2563
3173
 
3174
+ // per-row checkbox state + toggle (checkbox-only, D-05 — row body does NOT select).
3175
+ // Read selection from the LIVE controlled state (currentState().rowSelection keyed by
3176
+ // row.id) — NOT row.getIsSelected(). The latter reads table-core's row model, which
3177
+ // only reflects a selection AFTER the re-feed watch pushes the new `state` + re-pulls
3178
+ // (two reactive cycles on React). The controlled-state read updates in the SAME cycle
3179
+ // as the write funnel, so the controlled <input :checked> reflects the toggle without
3180
+ // the row-model-re-pull latency — the React controlled-checkbox revert that left
3181
+ // `.check()` seeing no state change (F6). row.getIsSelected() is the fallback.
2564
3182
  rowIsSelected = (row: any) => {
2565
3183
  if (!row) return false;
2566
3184
  const id = row.id;
@@ -2574,6 +3192,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2574
3192
  row.toggleSelected(!!(evt && evt.target && evt.target.checked));
2575
3193
  };
2576
3194
 
3195
+ // ── Header ⋯ menu chrome (phase 72, D-06) ──────────────────────────────────────────
3196
+ // onHideColumn: the ⋯ menu's "Hide column" item. Reuses the SAME columnVisibility write
3197
+ // funnel as the existing colvis toggle (onToggleVisibility in columnChrome.rzts) — just
3198
+ // forced to `false` rather than toggled, since hide is a one-directional action from the
3199
+ // menu (the colvis panel is the re-show path). Event stopped HERE (not a `.stop`
3200
+ // modifier) — same Angular @for-hoist hazard as onPinColumn/onResizeStart (F5).
2577
3201
  onHideColumn = (colId: any, evt: any) => {
2578
3202
  if (evt && evt.stopPropagation) evt.stopPropagation();
2579
3203
  if (!this.table) return;
@@ -2581,6 +3205,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2581
3205
  if (col && col.toggleVisibility) col.toggleVisibility(false);
2582
3206
  };
2583
3207
 
3208
+ // hasAnyFilterableColumn: gates the dedicated filter row (72-05) — true when at least one
3209
+ // leaf column (excluding the select/expander chrome columns, already excluded by
3210
+ // allLeafColumns) is filterable. Reactive via allLeafColumns()'s own tick() gate.
2584
3211
  hasAnyFilterableColumn = () => {
2585
3212
  const cols = this.allLeafColumns();
2586
3213
  for (const c of cols as any) {
@@ -2589,6 +3216,18 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2589
3216
  return false;
2590
3217
  };
2591
3218
 
3219
+ // `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
3220
+ // binding only takes effect on Vue (which binds known DOM props); on
3221
+ // React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
3222
+ // stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
3223
+ // root ($el — post-mount safe) and assign the property. Called from refreshRowModel
3224
+ // (every selection change re-pulls the row model) so it stays in lockstep with the
3225
+ // table-core selection state. The select-all box is NOT re-created by a selection
3226
+ // change (only its checked attr flips), so the live element persists.
3227
+ // `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
3228
+ // strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
3229
+ // which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
3230
+ // as Column's `let reg = null; reg = $inject(...)`.
2592
3231
  selectAllBox: any = null;
2593
3232
 
2594
3233
  syncIndeterminate = () => {
@@ -2597,6 +3236,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2597
3236
  if (this.selectAllBox) this.selectAllBox.indeterminate = this.isSomeRowsSelected() && !this.isAllRowsSelected();
2598
3237
  };
2599
3238
 
3239
+ // The registry API handed to <Column> children (whole-object-replace — T-48-PP guard).
3240
+ // Imperative handle (consumer-callable). Each verb is a PRE-DECLARED top-level
3241
+ // `const` (the canonical $expose contract — `$expose({ name })` references a
3242
+ // binding ALREADY in scope; an INLINE-defined verb `$expose({ name: () => {} })`
3243
+ // is dropped on ALL SIX targets, only the by-reference key survives → a
3244
+ // runtime ReferenceError at `defineExpose`/`useImperativeHandle`). Sorting verbs +
3245
+ // a fresh column-def readout, selection, pagination, and column-management verbs.
2600
3246
  sortColumn = (colId: any, desc: any) => {
2601
3247
  if (this.table) this.table.getColumn(colId) && this.table.getColumn(colId).toggleSorting(desc, false);
2602
3248
  };
@@ -2607,6 +3253,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2607
3253
 
2608
3254
  getColumnDefs = () => this.columnDefs();
2609
3255
 
3256
+ // selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
3257
+ // emits the fresh state + selection-change.
2610
3258
  toggleAllRows = (value: any) => {
2611
3259
  if (this.table) this.table.toggleAllRowsSelected(value);
2612
3260
  };
@@ -2617,6 +3265,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2617
3265
 
2618
3266
  getSelectedRows = () => this.table ? this.table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
2619
3267
 
3268
+ // pagination verbs.
2620
3269
  setPage = (idx: any) => {
2621
3270
  if (this.table) this.table.setPageIndex(idx);
2622
3271
  };
@@ -2625,6 +3274,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2625
3274
  if (this.table) this.table.setPageSize(size);
2626
3275
  };
2627
3276
 
3277
+ // column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
2628
3278
  toggleColumnVisibility = (colId: any) => {
2629
3279
  if (this.table) {
2630
3280
  const c = this.table.getColumn(colId);
@@ -2632,6 +3282,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2632
3282
  }
2633
3283
  };
2634
3284
 
3285
+ // NOT `setColumnOrder`: a verb named `set<ModelProp>` collides with React's
3286
+ // auto-generated `setColumnOrder` useState setter for the `columnOrder` model
3287
+ // prop, and an $expose verb is PUBLIC-CONTRACT-PROTECTED from the React
3288
+ // deconfliction rename (ROZ524 — the rename target is the verb, which is
3289
+ // off-limits). So the public verb is `applyColumnOrder` (semantically: apply a
3290
+ // new column order). The other set* verbs (setPage/setRowsPerPage) do NOT match
3291
+ // any model prop's setter, so they are collision-free.
2635
3292
  applyColumnOrder = (order: any) => {
2636
3293
  if (this.table) this.table.setColumnOrder(order);
2637
3294
  };
@@ -2640,6 +3297,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2640
3297
  if (this.table) this.table.resetColumnSizing(true);
2641
3298
  };
2642
3299
 
3300
+ // pinColumn: the verb that drives column.pin; distinct from the template handler
3301
+ // onPinColumn (no shadow — the deferred-items finding #4 collision check).
2643
3302
  pinColumn = (colId: any, side: any) => {
2644
3303
  if (this.table) {
2645
3304
  const c = this.table.getColumn(colId);
@@ -2647,39 +3306,101 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2647
3306
  }
2648
3307
  };
2649
3308
 
3309
+ // getRowIndexRelativeToPage(absRow?) — C1 (phase 63 wave-6) converter: an ABSOLUTE display-order
3310
+ // index (the focusCell/getActiveCell/activecell-change space) → the PAGE-RELATIVE index. Mirrors
3311
+ // MUI getRowIndexRelativeToVisibleRows. With NO argument it converts the CURRENT active cell
3312
+ // (toAbsRow($data.activeRow) - pageRowOffset() collapses to $data.activeRow). In virtual mode
3313
+ // there is no page (windowing replaces pagination) → the windowed model IS the full model, so it
3314
+ // returns the absolute index unchanged. Collision-safe: no *-change event, prop, React auto-setter,
3315
+ // or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
2650
3316
  getRowIndexRelativeToPage = (absRow: any) => {
2651
3317
  const abs = absRow == null ? this.toAbsRow(this._activeRow.value) : Math.trunc(Number(absRow)) || 0;
2652
3318
  if (this.virtual) return abs;
2653
3319
  return abs - this.pageRowOffset();
2654
3320
  };
2655
3321
 
3322
+ // C3 (phase 63 wave-9) — the PUBLIC Cut verb: copy the current cell range to the clipboard then
3323
+ // clear the source cells through the write-funnel (one writeData), delegating to cutRange (the
3324
+ // clipboardFill funnel that also backs the Ctrl+X shortcut). Reads the persisted $data range /
3325
+ // active cell, so it cuts the current selection even when the call arrives off a control that
3326
+ // moved DOM focus off the grid. Collision-safe: no `cut` event / model prop / React auto-setter /
3327
+ // inherited Lit DOM method named `cut` (ROZ121/124/137 clear) — `cut` is not on HTMLElement.
2656
3328
  cut = () => this.cutRange();
2657
3329
 
3330
+ // 260709-8ct (grid-wide undo/redo): NO pass-through wrapper lands here for
3331
+ // undo/redo/canUndo/canRedo/clearHistory. Unlike `cut` above (which delegates to a
3332
+ // differently-named clipboardFill export, `cutRange`), the undoHistory.rzts exports already
3333
+ // use the exact public verb names and are already component-scope (imported directly into
3334
+ // DataTable.rozie) — so DataTable.rozie's $expose references them BY NAME with zero
3335
+ // indirection. This file stays the seam for verbs that need a rename/adapter, not a mandatory
3336
+ // 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
+ // interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
3343
+ // identical to phase 48 (roles fall back to the literals, tabindex drops).
2658
3344
  isGrid = () => this.interactionMode === 'grid';
2659
3345
 
3346
+ // Role computeds (RESEARCH Pattern 4). The 'table' branch returns the EXACT phase-48
3347
+ // literal so 'table'-mode DOM is unchanged. Header cells keep 'columnheader' and rows keep
3348
+ // 'row'/'rowgroup' in BOTH modes (APG grid) — those stay static literals in the template.
2660
3349
  tableRole = () => this.isGrid() ? 'grid' : 'table';
2661
3350
 
2662
3351
  cellRole = () => this.isGrid() ? 'gridcell' : 'cell';
2663
3352
 
3353
+ // ── Cell addressing helpers (plain fns — no $computed alias trap; safe in template) ────
3354
+ // rowIndexOf: a body row's index over the visible model ($data.rows). tick() puts the read
3355
+ // in the fine-grained reactive scope (Solid/Lit) so the data-row marker re-derives on a
3356
+ // re-pull (reorder/filter) — matching visibleCellsFor's discipline.
2664
3357
  rowIndexOf = (row: any) => this.tick() >= 0 ? (this._rows.value || []).indexOf(row) : -1;
2665
3358
 
3359
+ // colIndexOf: a body cell's position in its row's visible cell list.
2666
3360
  colIndexOf = (row: any, cellCtx: any) => this.tick() >= 0 ? this.visibleCellsFor(row).indexOf(cellCtx) : -1;
2667
3361
 
3362
+ // headerColIndexOf: a header cell's position in its header group's leaf headers.
2668
3363
  headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);
2669
3364
 
3365
+ // ── C1 (phase 63 wave-6) absolute-index bridge ─────────────────────────────────────────
3366
+ // The PUBLIC active-cell rowIndex (focusCell/getActiveCell/activecell-change) is the ABSOLUTE
3367
+ // display-order position in getPrePaginationRowModel().rows (filter+sort+expand applied, BEFORE
3368
+ // pagination/windowing), in BOTH paginated and virtual modes — reversing the old page-relative
3369
+ // paginated meaning. INTERNALLY $data.activeRow stays PAGE-RELATIVE in the non-virtual paginated
3370
+ // body (the data-row markers + the nav math index the page slice) and FULL-MODEL in virtual mode
3371
+ // (the wr.vi.index space). pageRowOffset() bridges the two so the API speaks one absolute language.
3372
+ // - virtual mode: activeRow is already the full pre-pagination index → offset 0.
3373
+ // - non-virtual: activeRow is page-relative → offset = pageIndex * pageSize.
3374
+ // isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
3375
+ // state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
2670
3376
  pageRowOffset = () => {
2671
3377
  if (!this.isGrid() || this.virtual) return 0;
2672
3378
  return this.pageIndex() * this.pageSize();
2673
3379
  };
2674
3380
 
3381
+ // page-relative active row → absolute (display-order) index.
2675
3382
  toAbsRow = (localRow: any) => localRow + this.pageRowOffset();
2676
3383
 
3384
+ // A body row's ABSOLUTE display-order index = its page-relative index + the page offset. Drives
3385
+ // aria-rowindex on the non-virtual paginated body (B27); the virtual path uses wr.vi.index
3386
+ // directly (already absolute). Reactive via rowIndexOf's tick().
3387
+ // Total filtered+sorted PRE-pagination row count — the clamp bound for an absolute focusCell.
3388
+ // In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
3389
+ // non-virtual paginated body $data.rows is only the page slice, so read the live model.
2677
3390
  prePaginationRowCount = () => {
2678
3391
  if (!this.table || this.virtual) return this.bodyRowCount();
2679
3392
  const pm = this.table.getPrePaginationRowModel();
2680
3393
  return pm && pm.rows ? pm.rows.length : this.bodyRowCount();
2681
3394
  };
2682
3395
 
3396
+ // Roving tabindex (RESEARCH Code Examples). Reads ONLY reactive $data (ROZ123-safe,
3397
+ // fine-grained-reactive). Returns null in 'table' mode → the bound numeric attribute
3398
+ // DROPS entirely (IN-01: on React via the `cellTabindex(...) ?? undefined` numeric-attr
3399
+ // emitter path landed in 4bec3b8e — NOT rozieAttr, which would string-widen tabIndex and
3400
+ // TS2322; the other five targets drop it via their own nullish-attr handling), keeping
3401
+ // 'table'-mode DOM clean. rowKey is the literal
3402
+ // '__header' for header cells or the String(bodyRowIndex) for body cells, so the active
3403
+ // header state (activeIsHeader) is addressable through the same computed.
2683
3404
  cellTabindex = (rowKey: any, colIndex: any, level = null) => {
2684
3405
  if (!this.isGrid()) return null;
2685
3406
  // B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
@@ -2700,6 +3421,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2700
3421
  return isActive ? 0 : -1;
2701
3422
  };
2702
3423
 
3424
+ // ── Active-cell ring predicate (grid pointer §1, 260708-ni6) ───────────────────────────
3425
+ // isActiveCell mirrors cellTabindex's ACTIVE branch (the same (rowKey, colIndex, level)
3426
+ // address tuples the roving tabindex uses) but returns a BOOLEAN for the `.rdt-cell-active`
3427
+ // :class binding, and is STATE-DRIVEN — so the ring shows identically on click AND keyboard
3428
+ // (independent of :focus-visible, which browsers gate off for a mouse-focused non-text <td>).
3429
+ // It DELIBERATELY omits cellTabindex's B6 empty-grid / header-fallback branch: the ring must
3430
+ // NOT light on an empty grid's synthetic tab-stop (there is no real active cell there). Reads
3431
+ // ONLY reactive $data (ROZ123-safe, fine-grained). Returns false in 'table' mode so table-mode
3432
+ // markup is byte-behaviorally unchanged. Header cells are active only while activeIsHeader is
3433
+ // true (addressed by BOTH colIndex and level — a grouped multi-level header carries exactly one
3434
+ // ring); body cells only while activeIsHeader is false.
2703
3435
  isActiveCell = (rowKey: any, colIndex: any, level = null) => {
2704
3436
  if (!this.isGrid()) return false;
2705
3437
  if (this._activeIsHeader.value) {
@@ -2710,6 +3442,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2710
3442
  return rowKey === String(this._activeRow.value) && colIndex === this._activeColIndex.value;
2711
3443
  };
2712
3444
 
3445
+ // ── The focus SEAM (RESEARCH Pattern 1 + 3, req-6) ─────────────────────────────────────
3446
+ // resolveCellEl: index pair → DOM element, via a data-* attribute query off the stable
3447
+ // post-mount root. Uniform on all six, shadow-safe (the query runs from inside the
3448
+ // component's own scope). rowKey is the literal '__header' or a String(integer index) and
3449
+ // colIndex is an integer — NO consumer string is interpolated into the selector (T-49-01).
2713
3450
  resolveCellEl = (rowKey: any, colIndex: any, level = null) => {
2714
3451
  if (!this.gridRoot) return null;
2715
3452
  // B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
@@ -2723,6 +3460,19 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2723
3460
  return this.gridRoot.querySelector(sel);
2724
3461
  };
2725
3462
 
3463
+ // focusActiveCell: THE single DOM-focus-resolution path (req-6). Every focus change —
3464
+ // the D-04 entry cell here, and (plan 03) arrow nav / focusCell() / the data-change clamp —
3465
+ // routes through this one function, so a verifier can point to it and phase 53 windowing
3466
+ // hooks it without a rewrite. Accepts OPTIONAL explicit (nextRow,nextCol) so callers can
3467
+ // pass FRESH post-write locals (React ROZ138 / Angular signal async — pinned by plan 01);
3468
+ // falls back to $data when none passed. NEVER stores a DOM node (index-only state).
3469
+ // 260618-ao9 — params carry explicit `= null` defaults so the cross-target
3470
+ // emitters type them OPTIONAL (untyped params lower to REQUIRED `any`, making the
3471
+ // 2-arg `focusActiveCell(r, c)` call sites a TS2554 on React/Solid/Lit — a
3472
+ // pre-existing regression from the d7166c5e header-crossing `nextIsHeader` add).
3473
+ // The `= null` default reproduces the documented "falls back to $data when
3474
+ // omitted" contract: an omitted arg arrives as `null`, and the body's `== null`
3475
+ // checks already route those to the live `$data` value — behavior-identical.
2726
3476
  focusActiveCell = (nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) => {
2727
3477
  if (!this.isGrid() || !this.gridRoot) return;
2728
3478
  // #9 focus-intent epoch: focusActiveCell is THE single seam every keyboard nav re-asserts
@@ -2788,20 +3538,59 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2788
3538
  if (el) el.focus();
2789
3539
  };
2790
3540
 
3541
+ // ══ Grid keyboard navigation (phase 49 plan 03 — RESEARCH Pattern 5 + the delegated handler) ═══
3542
+ // The nav model is plain ARRAY-INDEX MATH over the VISIBLE model. table-core has already
3543
+ // done the hard part: $data.rows (body) and $data.headerGroups (header) hold the visible,
3544
+ // reordered, pinned cell set (row.getVisibleCells() / getHeaderGroups()) — hidden columns
3545
+ // are ALREADY ABSENT, reorder/pinning is ALREADY REFLECTED (REQ-7). There is NO separate
3546
+ // "compute visible order" step. Every index is clamped to [0,max] so an out-of-range key
3547
+ // never throws or builds an injection-shaped selector (Security V5 / T-49-03).
3548
+ // IN-01: aria-rowcount for the NON-VIRTUAL table. The virtual table binds $data.rows.length
3549
+ // (the full pre-pagination model). For the non-virtual path $data.rows is the PAGINATED slice,
3550
+ // so report the FILTERED (pre-pagination) total instead — the count AT users need to know "row N
3551
+ // of TOTAL". Falls back to $data.rows.length pre-mount (table is null until $onMount).
3552
+ // NB the helper is named `totalRowCount`, NOT `ariaRowCount`: `ariaRowCount` is an inherited
3553
+ // HTMLElement ARIA-reflected property (`Element.ariaRowCount: string`), so a same-named method
3554
+ // becomes a class field that shadows it on Lit → TS2416 cascades to EVERY @property decorator
3555
+ // (the `valueOf`/`nodeType` inherited-DOM-member collision class, authoring playbook §6).
2791
3556
  totalRowCount = () => {
2792
3557
  if (!this.table) return (this._rows.value || []).length;
2793
3558
  const fm = this.table.getFilteredRowModel();
2794
3559
  return fm && fm.rows ? fm.rows.length : (this._rows.value || []).length;
2795
3560
  };
2796
3561
 
3562
+ // ── A11y row bookkeeping (#13): consistent aria-rowindex / aria-rowcount ──────────────
3563
+ // WAI-ARIA: when aria-rowcount is set on the grid/table, EVERY row (header rows + body rows)
3564
+ // must carry an aria-rowindex, and aria-rowcount must equal the total number of rows INCLUDING
3565
+ // the header rows. Before this fix aria-rowcount was set unconditionally to totalRowCount() but
3566
+ // aria-rowindex was grid-only — so a paginated 'table'-mode grid advertised e.g. rowcount=100
3567
+ // while its 10 visible rows carried NO index (SR announced "row 1..10 of 100" on the LAST page).
3568
+ // headerRowCount = the columnheader rows ($data.headerGroups — a grouped/multi-level header is
3569
+ // >1; the role="presentation" filter row is NOT a row and is excluded).
3570
+ // gridAriaRowCount = header rows + the FILTERED pre-pagination data total → equals the largest
3571
+ // aria-rowindex any body row carries, so count and indices are always mutually consistent.
3572
+ // NB the helpers are gridAriaRowCount / bodyAriaRowIndex, NOT ariaRowCount / ariaRowIndex: the
3573
+ // latter collide with the inherited HTMLElement.ariaRowCount / .ariaRowIndex reflected properties
3574
+ // on Lit (TS2416 — the same inherited-DOM-member collision class as totalRowCount's rename note).
2797
3575
  headerRowCount = () => (this._headerGroups.value || []).length;
2798
3576
 
2799
3577
  gridAriaRowCount = () => this.headerRowCount() + this.totalRowCount();
2800
3578
 
3579
+ // Page offset that is MODE-INDEPENDENT (works in BOTH 'table' and 'grid' mode), unlike
3580
+ // pageRowOffset() which is isGrid()-gated for the active-cell API. In the non-virtual body
3581
+ // $data.rows is only the page slice, so a data row's ABSOLUTE index = its page-relative
3582
+ // rowIndexOf + this offset. Virtual mode never reaches here (that body uses wr.vi.index).
2801
3583
  ariaPageOffset = () => this.table ? this.pageIndex() * this.pageSize() : 0;
2802
3584
 
3585
+ // A non-virtual body row's 1-based aria-rowindex: the header rows come first (headerRowCount),
3586
+ // then the absolute (page-aware) 0-based data index, +1 to 1-base it. Present in BOTH modes so
3587
+ // it is always consistent with gridAriaRowCount. The virtual body binds
3588
+ // `headerRowCount() + wr.vi.index + 1` inline (wr.vi.index is already the absolute full-model index).
2803
3589
  bodyAriaRowIndex = (row: any) => this.headerRowCount() + this.rowIndexOf(row) + this.ariaPageOffset() + 1;
2804
3590
 
3591
+ // Column count = the visible cell list length (uniform header+body in a flat grid). Reads
3592
+ // $data.rows (reactive) so it is fine-grained-correct on Solid/Lit; falls back to the
3593
+ // header leaf count when there are no body rows.
2805
3594
  visibleColCount = () => {
2806
3595
  // NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
2807
3596
  // state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
@@ -2816,11 +3605,24 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2816
3605
 
2817
3606
  clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
2818
3607
 
3608
+ // ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
3609
+ // $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
3610
+ // body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
3611
+ // group index) alongside activeColIndex (the index within THAT level's headers) so the
3612
+ // single-tab-stop invariant + ArrowUp parent-resolution span every header level — a flat
3613
+ // grid has one level (leafLevel 0), so the table-mode/flat path is unchanged.
2819
3614
  headerLeafLevel = () => {
2820
3615
  const hg = this._headerGroups.value || [];
2821
3616
  return hg.length ? hg.length - 1 : 0;
2822
3617
  };
2823
3618
 
3619
+ // #10: the number of header cells AT a given level. A grouped PARENT level may have FEWER
3620
+ // headers than there are leaf columns (one parent spans several leaves), so horizontal nav on a
3621
+ // non-leaf header must clamp against THIS count — not visibleColCount() (the leaf-column count),
3622
+ // which would let ArrowRight/End overrun into a phantom (null) cell → focus dropped to <body>.
3623
+ // Degenerate cases (no headerGroups, level out of range) fall back to visibleColCount() so the
3624
+ // clamp is never negative or NaN. The LEAF level's count equals visibleColCount() (one header per
3625
+ // visible leaf column), so leaf-header + body horizontal nav is byte-behaviorally unchanged.
2824
3626
  headerCountAtLevel = (level: any) => {
2825
3627
  const hg = this._headerGroups.value || [];
2826
3628
  if (!hg.length) return this.visibleColCount();
@@ -2836,6 +3638,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2836
3638
  return grp.headers[colIndex] || null;
2837
3639
  };
2838
3640
 
3641
+ // ArrowUp from a (level, colIndex) leaf/child header → the index of its PARENT header in the
3642
+ // level above (the parent column that spans it, via table-core header.column.parent). -1 when
3643
+ // there is no real parent (already at the top, or a placeholder with no group) → the caller
3644
+ // keeps the active header where it is.
2839
3645
  parentHeaderColIndex = (level: any, colIndex: any) => {
2840
3646
  if (level <= 0) return -1;
2841
3647
  const h = this.headerAt(level, colIndex);
@@ -2851,6 +3657,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2851
3657
  return -1;
2852
3658
  };
2853
3659
 
3660
+ // ArrowDown from a (level, colIndex) GROUP header → the index of its FIRST child header in the
3661
+ // level below (via table-core column.columns). -1 when the header has no child columns (a leaf)
3662
+ // → the caller drops into the body instead.
2854
3663
  firstChildHeaderColIndex = (level: any, colIndex: any) => {
2855
3664
  const h = this.headerAt(level, colIndex);
2856
3665
  if (!h || !h.column) return -1;
@@ -2867,6 +3676,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2867
3676
  return -1;
2868
3677
  };
2869
3678
 
3679
+ // ── Nav helpers: compute the NEXT indices into LOCAL consts, write $data from them, and
3680
+ // RETURN the fresh locals so the caller threads the SAME values into BOTH focusActiveCell
3681
+ // AND the activecell-change emit. NEVER re-read $data.activeRow/activeColIndex after the
3682
+ // write (React setState is async — ROZ138 — the re-read binds the PRE-write value; Angular
3683
+ // signal writes are async too — both proven live by plan 01's probe). ──────────────────────
3684
+ // ArrowRight/Left — clamp colIndex over [0, visibleColCount()-1] (no wrap; hidden cols
3685
+ // already excluded from the visible list per REQ-7).
2870
3686
  moveCol = (delta: any) => {
2871
3687
  // #10: when a grouped PARENT header is active, clamp against the header count AT THE ACTIVE
2872
3688
  // LEVEL (which may be fewer than the leaf-column count) so ArrowRight never overruns onto a
@@ -2878,6 +3694,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2878
3694
  return nextCol;
2879
3695
  };
2880
3696
 
3697
+ // ArrowUp/Down + PageUp/Down — cross the header boundary and clamp at body edges (no
3698
+ // page-cross per D-06/REQ-7). Returns { row, isHeader } fresh locals.
3699
+ // - From the header, ArrowDown (delta>0) drops into body row 0 (activeIsHeader=false).
3700
+ // - From body row 0, ArrowUp (delta<0) crosses into the header (activeIsHeader=true).
3701
+ // - PageUp/Down jump by ±GRID_PAGE_STEP, clamped to the current page bounds (no cross).
2881
3702
  moveRow = (delta: any) => {
2882
3703
  const lastRow = this.bodyRowCount() - 1;
2883
3704
  const maxRow = lastRow < 0 ? 0 : lastRow;
@@ -2969,6 +3790,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2969
3790
  };
2970
3791
  };
2971
3792
 
3793
+ // Home/End within the current row → col 0 / max. Returns the fresh colIndex.
2972
3794
  gotoColEdge = (toEnd: any) => {
2973
3795
  // #10: End on a grouped PARENT header lands on that level's LAST header (headerCountAtLevel-1),
2974
3796
  // not the leaf-column max — otherwise the ring strands on a phantom cell past the level's
@@ -2980,6 +3802,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2980
3802
  return nextCol;
2981
3803
  };
2982
3804
 
3805
+ // gotoRowEdge(toEnd): the §8 (260709-3qt) Ctrl+ArrowUp/Down vertical region-edge jump — move the
3806
+ // active cell to the data-region row edge (row 0 / last body row) in the CURRENT column, mirroring
3807
+ // gotoColEdge's horizontal edge jump. Body cells only (the caller gates on !activeIsHeader); always
3808
+ // lands in the body (activeIsHeader=false). Returns the fresh row index for the shared focus seam.
2983
3809
  gotoRowEdge = (toEnd: any) => {
2984
3810
  const lastRow = this.bodyRowCount() - 1;
2985
3811
  const nextRow = toEnd ? lastRow < 0 ? 0 : lastRow : 0;
@@ -2988,6 +3814,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2988
3814
  return nextRow;
2989
3815
  };
2990
3816
 
3817
+ // Ctrl+Home → first body cell (0,0); Ctrl+End → last body cell (lastRow,max). Returns the
3818
+ // fresh { row, col } locals. Both land in the body (activeIsHeader=false).
2991
3819
  gotoStart = () => {
2992
3820
  this._activeIsHeader.value = false;
2993
3821
  this._activeRow.value = 0;
@@ -3012,17 +3840,23 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3012
3840
  };
3013
3841
  };
3014
3842
 
3843
+ // Resolve the active cell element (for the in-cell trap) — uses the same data-* query as
3844
+ // the focus seam. rowKey is the literal '__header' or String(integer) — no consumer string.
3015
3845
  currentCellEl = () => {
3016
3846
  const rowKey = this._activeIsHeader.value ? '__header' : String(this._activeRow.value);
3017
3847
  return this.resolveCellEl(rowKey, this._activeColIndex.value, this._activeIsHeader.value ? this._activeHeaderLevel.value : null);
3018
3848
  };
3019
3849
 
3850
+ // The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
3020
3851
  focusables = (cellEl: any) => {
3021
3852
  if (!cellEl || !cellEl.querySelectorAll) return [];
3022
3853
  const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
3023
3854
  return list.filter((n: any) => !n.disabled);
3024
3855
  };
3025
3856
 
3857
+ // Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
3858
+ // (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
3859
+ // No-op (stay in navigation mode) if the cell has no focusable control.
3026
3860
  enterControl = () => {
3027
3861
  const cellEl = this.currentCellEl();
3028
3862
  const list = this.focusables(cellEl);
@@ -3031,6 +3865,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3031
3865
  list[0].focus();
3032
3866
  };
3033
3867
 
3868
+ // Cycle focus among the controls WITHIN the active cell (D-08 focus containment) — Tab
3869
+ // forward / Shift+Tab backward, wrapping at the ends. Uses the plan-01-PROVEN per-target
3870
+ // activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
3871
+ // ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
3034
3872
  cycleWithinCell = (cellEl: any, forward: any) => {
3035
3873
  const list = this.focusables(cellEl);
3036
3874
  if (!list.length) return;
@@ -3042,6 +3880,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3042
3880
  list[i].focus();
3043
3881
  };
3044
3882
 
3883
+ // THE single delegated keydown handler (RESEARCH "Single delegated keydown handler"). Wired
3884
+ // as ONE keydown listener on the <table> root — NOT per-cell, NOT with .stop/.prevent modifiers (the
3885
+ // Angular .stop-in-@for hoist bug, F5/ROZ723). e.preventDefault() is called IMPERATIVELY for
3886
+ // handled keys. Each nav helper writes $data and RETURNS the fresh post-write locals; those
3887
+ // SAME locals feed BOTH focusActiveCell AND the activecell-change emit (no $data re-read).
3045
3888
  onGridKeyDown = (e: any) => {
3046
3889
  if (!this.isGrid() || !e) return;
3047
3890
  const key = e.key;
@@ -3379,6 +4222,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3379
4222
  }
3380
4223
  };
3381
4224
 
4225
+ // WR-03: integrate mouse-click + programmatic focus with the roving model. A click on a
4226
+ // tabindex="-1" cell (or focus arriving any way other than the keyboard nav path) moves
4227
+ // DOM focus there but does NOT run onGridKeyDown — so activeRow/activeColIndex would stay
4228
+ // on the OLD cell and the NEXT arrow key would jump from the stale active cell. Wired as
4229
+ // ONE @focusin on the <table> root (focusin bubbles): resolve the focused element's owning
4230
+ // [data-grid-cell], parse its data-row/data-col-index, and write them into the active-cell
4231
+ // state (mirroring the keyboard path). Clears activeInControl ONLY when the cell ITSELF
4232
+ // (not an inner control) received focus — focusing a control via Enter keeps the in-control
4233
+ // flag. NEVER emits activecell-change (a focus sync is not a keyboard navigation event).
3382
4234
  syncActiveFromEvent = (e: any) => {
3383
4235
  if (!this.isGrid() || !e) return;
3384
4236
  const tgt = e.target;
@@ -3442,6 +4294,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3442
4294
  if (tgt === cellEl) this._activeInControl.value = false;
3443
4295
  };
3444
4296
 
4297
+ // onGridMouseDown: the pointer range seam (phase 51 req-7 / D-07 Shift+Click; §6 260709-3qt
4298
+ // plain drag-to-select). A focusin event carries no reliable `shiftKey`, so the modifier MUST
4299
+ // be read off the pointer event — @mousedown fires BEFORE the cell's focusin and DOES carry
4300
+ // shiftKey. A shift-held mousedown on a BODY cell sets the range's moving corner to that cell
4301
+ // (keeping the anchor), then flags rangeClickPending so the follow-up focusin does not collapse
4302
+ // the range. A PLAIN (non-shift) mousedown BEGINS a drag-select anchored at that cell (§6): the
4303
+ // document pointermove/up listeners paint the range as the pointer moves. The fill handle owns
4304
+ // its own @pointerdown drag (it stops propagation), so a plain mousedown originating inside it is
4305
+ // skipped. Do NOT preventDefault — native focus must still land (focusin sync + roving tabindex).
3445
4306
  onGridMouseDown = (e: any) => {
3446
4307
  if (!this.isGrid() || !e) return;
3447
4308
  const tgt = e.target;
@@ -3474,6 +4335,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3474
4335
  this.beginRangeDrag(row, col);
3475
4336
  };
3476
4337
 
4338
+ // onGridDblClick: the double-click-into-edit seam (grid pointer §3+§5, 260708-ni6). Wired as
4339
+ // ONE @dblclick on the <table> root (mirroring the already-delegated @mousedown/@focusin). A
4340
+ // double-click on a BODY cell either toggles a group (group-header cell) or opens the editor
4341
+ // (editable cell); a non-editable body cell is a no-op (the cell stays active — its focusin
4342
+ // already set the active state + the §1 ring). Header cells return early so they keep their
4343
+ // native sort/menu/resize semantics. Reuses the SAME closest/parse/finite guards as
4344
+ // syncActiveFromEvent and the SAME beginEdit / onToggleExpand funnels the keyboard path uses —
4345
+ // no new edit or expand machinery. isGrid()-gated so 'table' mode never runs it.
3477
4346
  onGridDblClick = (e: any) => {
3478
4347
  if (!this.isGrid() || !e) return;
3479
4348
  const tgt = e.target;
@@ -3512,6 +4381,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3512
4381
  }
3513
4382
  };
3514
4383
 
4384
+ // onGridClick: the opt-in single-click-to-edit seam (grid pointer §4, 260708-ni6). Only active
4385
+ // when the `singleClickEdit` prop is true (default false, negative-opt-out). Wired as ONE @click
4386
+ // on the <table> root — @click fires on a genuine mouseup-no-drag click (NOT @mousedown), which
4387
+ // honors the deferred §6 drag guard (a mousedown that begins a drag-select must not open an
4388
+ // editor). A plain click on an EDITABLE body cell opens its editor via the SAME beginEdit funnel;
4389
+ // shift+click (range extend) and non-editable cells are unaffected. Same closest/parse/header-skip
4390
+ // /finite guards as onGridDblClick. isGrid()-gated so 'table' mode never runs it.
3515
4391
  onGridClick = (e: any) => {
3516
4392
  if (!this.isGrid() || !e) return;
3517
4393
  if (!this.singleClickEdit) return;
@@ -3539,6 +4415,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3539
4415
  if (colId != null && this.columnEditable(colId)) this.beginEdit(row, col, null);
3540
4416
  };
3541
4417
 
4418
+ // WR-02: reset the interaction-mode flag when focus leaves the active cell's subtree.
4419
+ // Without this, activeInControl could stick `true` — a mouse click OUTSIDE the cell, or
4420
+ // the focused inner control being removed from the DOM — leaving onGridKeyDown wedged in
4421
+ // the in-cell-trap branch so arrow nav is dead until Escape. Wired as ONE @focusout on
4422
+ // the <table> root (focusout bubbles, unlike blur). relatedTarget is the element RECEIVING
4423
+ // focus (null when focus leaves the document / is retargeted across a shadow boundary). If
4424
+ // focus is NOT moving to a descendant of the active cell, drop the flag. A Tab-cycle WITHIN
4425
+ // the cell (interaction mode) keeps relatedTarget inside cellEl → no reset.
3542
4426
  onGridFocusOut = (e: any) => {
3543
4427
  if (!this.isGrid() || !this._activeInControl.value) return;
3544
4428
  const next = e ? e.relatedTarget : null;
@@ -3546,6 +4430,20 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3546
4430
  if (!cellEl || !next || !cellEl.contains(next)) this._activeInControl.value = false;
3547
4431
  };
3548
4432
 
4433
+ // B25: re-focus a resolved valid cell AFTER a programmatic shrink re-renders. The clamp
4434
+ // runs synchronously BEFORE the framework commits the new tbody, so a deferred rAF-poll
4435
+ // resolves the [data-row][data-col-index] cell off gridRoot once it has rendered (the fast
4436
+ // targets land on attempt 1; React/Solid retry across the async commit). Mirrors
4437
+ // focusCellWhenReady (B23) — DOM-only (reads gridRoot), so it is React-stale-safe.
4438
+ // guardMoved (default false): when true, the poll does NOT stomp focus that a later nav has
4439
+ // already moved to a DIFFERENT, STILL-VALID row — used only by the group-collapse re-seat (the
4440
+ // target group-header row is unchanged, so a stale late rAF must not steal focus back after the
4441
+ // user ArrowDown'd away → the non-deterministic treegrid collapsed-nav focus-theft). It is left
4442
+ // OFF for the B25 shrink-recovery site, whose target is a CLAMPED index of a now-REMOVED cell:
4443
+ // there focus legitimately sits on the doomed old cell (a different row) mid-async-render on
4444
+ // React and MUST be recovered onto the clamped survivor, not preserved. Compare data-row (NOT
4445
+ // node identity) so a stale SAME-row cell on Solid's node-replacing re-render still resolves as
4446
+ // the target — a genuinely dropped focus is always recovered on both sites.
3549
4447
  recoverGridFocus = (rowKey: any, col: any, level: any, guardMoved = false) => {
3550
4448
  if (!this.gridRoot) return;
3551
4449
  let attempts = 0;
@@ -3570,6 +4468,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3570
4468
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
3571
4469
  };
3572
4470
 
4471
+ // D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
4472
+ // pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
4473
+ // row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
4474
+ // mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
3573
4475
  clampActiveCell = (rowCount: any, colCount: any) => {
3574
4476
  if (!this.isGrid()) return;
3575
4477
  // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
@@ -3665,12 +4567,50 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3665
4567
  }
3666
4568
  };
3667
4569
 
4570
+ // B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
4571
+ // flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
4572
+ // plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
4573
+ // is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
4574
+ // there binds the async-stale mount-time value on React (setState is async — the rangeActive /
4575
+ // pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
4576
+ // CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
4577
+ // header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
4578
+ // synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
4579
+ // re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
4580
+ // already read reactive $data synchronously). A top-level reassigned `let` referenced from the
4581
+ // refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
3668
4582
  gridEmptyFallback = false;
3669
4583
 
4584
+ // ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
4585
+ // A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
4586
+ // (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
4587
+ // highlight reattaches to the correct cells across virtualization recycling (the
4588
+ // activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
4589
+ // range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
4590
+ // the row-selection slice (the two never touch each other's state).
4591
+ // inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
4592
+ // rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
4593
+ // the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
4594
+ // rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
4595
+ // range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
4596
+ // (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
4597
+ // range we just set. The flag suppresses that collapse for the in-flight focus settle (the
4598
+ // editTransition blur-guard precedent). A top-level let → React hoists to useRef.
3670
4599
  rangeTransition = false;
3671
4600
 
4601
+ // rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
4602
+ // pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
4603
+ // to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
3672
4604
  rangeClickPending = false;
3673
4605
 
4606
+ // B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
4607
+ // true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
4608
+ // arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
4609
+ // on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
4610
+ // `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
4611
+ // range-change. This module-let is written synchronously (no setState async), so the second
4612
+ // clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
4613
+ // across all six targets. A top-level let → React hoists to useRef.
3674
4614
  rangeActive = false;
3675
4615
 
3676
4616
  inRange = (rIdx: any, cIdx: any) => {
@@ -3684,6 +4624,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3684
4624
  return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
3685
4625
  };
3686
4626
 
4627
+ // getSelectedRange(): the current range as plain integers — { anchor, focus } each a
4628
+ // { rowIndex, colIndex } pair (or null when no range). T-49-02: positions only, no row
4629
+ // data, no DOM node. Used by the getSelectedRange $expose verb AND every range-change emit
4630
+ // (the single payload source) AND copyRange/fillRange (the rectangle they operate over).
3687
4631
  getSelectedRange = () => {
3688
4632
  // B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
3689
4633
  // payload) never reports a corner past a shrunken model — React-stale-safe (the eager
@@ -3710,6 +4654,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3710
4654
  };
3711
4655
  };
3712
4656
 
4657
+ // isFillHandleCell(rIdx, cIdx): is this cell the BOTTOM-RIGHT corner of the current range?
4658
+ // That corner hosts the fill-handle affordance (req-8 / D-04). False without a range — the
4659
+ // byte-identical-off guard for the handle markup (no range → no handle).
3713
4660
  isFillHandleCell = (rIdx: any, cIdx: any) => {
3714
4661
  const a = this._rangeAnchor.value;
3715
4662
  const f = this._rangeFocus.value;
@@ -3719,6 +4666,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3719
4666
  return rIdx === r1 && cIdx === c1;
3720
4667
  };
3721
4668
 
4669
+ // emitRangeChange(anchor, focus): fire range-change with the FRESH range corners passed by
4670
+ // the caller — NOT a re-read of $data.rangeAnchor/rangeFocus. The range corners are <data>
4671
+ // (useState on React), so re-reading right after the same-tick setState returns the STALE
4672
+ // pre-write value (ROZ138). extendRange/setRangeFocus thread the just-computed locals through
4673
+ // here so the emitted payload matches the write. The single call site keeps the count
4674
+ // predictable (React multi-emit dedup, D-07). One-way notification.
3722
4675
  emitRangeChange = (anchor: any, focus: any) => {
3723
4676
  this.dispatchEvent(new CustomEvent("range-change", {
3724
4677
  detail: {
@@ -3730,6 +4683,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3730
4683
  }));
3731
4684
  };
3732
4685
 
4686
+ // extendRange(dRow, dCol): move rangeFocus by the (row,col) delta, clamped to the grid
4687
+ // bounds, seeding rangeAnchor from the active cell when no range exists yet (Shift+Arrow
4688
+ // from a bare active cell starts a 1×N / N×1 rectangle anchored at that cell). Body cells
4689
+ // only (header rows are not range-selectable). Emits range-change from this single site.
3733
4690
  extendRange = (dRow: any, dCol: any) => {
3734
4691
  if (this._activeIsHeader.value) return;
3735
4692
  const maxRow = this.bodyRowCount() - 1;
@@ -3775,6 +4732,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3775
4732
  }
3776
4733
  };
3777
4734
 
4735
+ // setRangeFocus(rIdx, cIdx): set the moving corner to an explicit cell (Shift+Click),
4736
+ // seeding the anchor from the active cell when no range exists yet. Clamped to bounds.
4737
+ // Emits range-change from this single site.
3778
4738
  setRangeFocus = (rIdx: any, cIdx: any) => {
3779
4739
  const maxRow = this.bodyRowCount() - 1;
3780
4740
  const maxCol = this.visibleColCount() - 1;
@@ -3796,6 +4756,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3796
4756
  this.emitRangeChange(anchor, nextFocus);
3797
4757
  };
3798
4758
 
4759
+ // selectAllBody(): the §8 (260709-3qt) Ctrl+A whole-body select — set the range to span EVERY
4760
+ // body cell (anchor at the first body cell (0,0), moving corner at the last (maxRow, maxCol)),
4761
+ // driving the SAME range corners shift+arrow / setRangeFocus use. Emits range-change from a single
4762
+ // site (the emitRangeChange contract — pass the FRESH corners, never a $data re-read). No-op on an
4763
+ // empty grid. Body cells only — a header-active Ctrl+A is gated OUT by the caller (never builds a
4764
+ // range from a header). rangeActive is set synchronously so a follow-up clearRange collapses it.
3799
4765
  selectAllBody = () => {
3800
4766
  const maxRow = this.bodyRowCount() - 1;
3801
4767
  const maxCol = this.visibleColCount() - 1;
@@ -3814,6 +4780,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3814
4780
  this.emitRangeChange(anchor, focus);
3815
4781
  };
3816
4782
 
4783
+ // clearRange(): drop the rectangle (a non-shift navigation / edit-entry collapses any
4784
+ // range back to a single active cell). Cheap no-op when no range is set (the guard keeps a
4785
+ // plain navigation with no active range from emitting). B19: when a range DID exist, emit
4786
+ // range-change with null corners so a consumer mirroring the selection through the event sees
4787
+ // the drop — without this they hold a STALE rectangle after every non-shift navigation /
4788
+ // edit-entry collapse (getSelectedRange already reports null, but the event never fired).
3817
4789
  clearRange = () => {
3818
4790
  // B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
3819
4791
  // in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
@@ -3827,6 +4799,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3827
4799
  this.emitRangeChange(null, null);
3828
4800
  };
3829
4801
 
4802
+ // B8: clamp the range corners to the current grid bounds after an underlying-data change
4803
+ // (sort/filter/paginate/page-size all re-derive the row model). A range whose rows now exceed
4804
+ // the shrunken model would otherwise leave STALE/phantom corners → a copy serializes empty
4805
+ // rows past the model's end (and getSelectedRange reports out-of-bounds corners). We CLAMP each
4806
+ // corner into [0,maxRow]×[0,maxCol] (preserving a valid rectangle — a corner that clamps onto
4807
+ // another keeps the range non-empty); when no selectable body cell remains the rectangle is
4808
+ // dropped. Does NOT emit range-change here — the clamp is a reconcile, not a user selection
4809
+ // move (the emit-on-change work, B18/B19, lands in plan 63-05). Called from clampActiveCell.
3830
4810
  clampRange = (maxRowArg: any, maxColArg: any) => {
3831
4811
  const a = this._rangeAnchor.value;
3832
4812
  const f = this._rangeFocus.value;
@@ -3859,17 +4839,42 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3859
4839
  }
3860
4840
  };
3861
4841
 
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
+ // announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
4851
+ // pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
3862
4852
  announce = (msg: any) => {
3863
4853
  this._pasteAnnounce.value = msg != null ? msg : '';
3864
4854
  };
3865
4855
 
4856
+ // B11: copy / paste (and the Cut verb plan 63-09 adds) are NO-OPS while a HEADER cell is
4857
+ // active. A header has no body value to copy, and a paste anchored at a header would silently
4858
+ // write body row 0 at the header's column (a silent body mutation, borderline P0). This is the
4859
+ // SINGLE reusable guard every clipboard entry path checks — copyRange/pasteRange self-guard
4860
+ // with it AND the onGridKeyDown Ctrl+C/Ctrl+V branches gate on it (so the native shortcut is
4861
+ // left untouched on a header). Plan 63-09's Cut reuses this exact predicate.
3866
4862
  clipboardActiveAllowed = () => !this._activeIsHeader.value;
3867
4863
 
4864
+ // fieldOfColId: the row-object key (accessorKey) to write for a column id — the same
4865
+ // accessorKey-or-id rule the edit funnels use. Used by paste/fill to apply values by field.
3868
4866
  fieldOfColId = (colId: any) => {
3869
4867
  const d = this.defFor(colId);
3870
4868
  return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
3871
4869
  };
3872
4870
 
4871
+ // normalizedRange(): the current rectangle as { r0, r1, c0, c1 } (min/max of anchor+focus),
4872
+ // or null when no range. The shared rectangle source for copy/paste/fill. B8: the corners are
4873
+ // CLAMPED to the CURRENT grid bounds ON READ (read at call time → React-stale-safe), so a copy
4874
+ // after a filter-to-fewer can never serialize phantom rows past the shrunken model even when
4875
+ // the stored corners were not eagerly re-clamped (refreshRowModel's clamp is async-defeated on
4876
+ // React; this read-time clamp is the cross-target guarantee). Returns null when no body cell
4877
+ // remains.
3873
4878
  normalizedRange = () => {
3874
4879
  const a = this._rangeAnchor.value;
3875
4880
  const f = this._rangeFocus.value;
@@ -3889,6 +4894,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3889
4894
  };
3890
4895
  };
3891
4896
 
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).
3892
4901
  escapeTsvField = (s: any) => {
3893
4902
  if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
3894
4903
  return '"' + s.replace(/"/g, '""') + '"';
@@ -3896,6 +4905,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3896
4905
  return s;
3897
4906
  };
3898
4907
 
4908
+ // rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
4909
+ // reading each cell's value off the visible model by index (cellValueAt). A single active
4910
+ // cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
3899
4911
  rangeToTsv = () => {
3900
4912
  const box = this.normalizedRange();
3901
4913
  const r0 = box ? box.r0 : this._activeRow.value;
@@ -3914,6 +4926,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3914
4926
  return lines.join('\n');
3915
4927
  };
3916
4928
 
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).
3917
4932
  parseTsv = (text: any) => {
3918
4933
  const str = text != null ? String(text) : '';
3919
4934
  // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
@@ -3989,6 +5004,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3989
5004
  return rows;
3990
5005
  };
3991
5006
 
5007
+ // copyRange(): write the current range as TSV to the clipboard (async). No-op when the
5008
+ // async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
3992
5009
  copyRange = () => {
3993
5010
  // B11: never copy from a header-active state (the reusable clipboard guard).
3994
5011
  if (!this.clipboardActiveAllowed()) return;
@@ -3999,6 +5016,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3999
5016
  } catch (err: any) {/* best-effort copy */}
4000
5017
  };
4001
5018
 
5019
+ // applyGridToRange(grid, originRow, originCol): the SHARED write path for paste + fill. Walks
5020
+ // the grid (string[][]) anchored at (originRow, originCol), CLAMPED to the grid bounds (no
5021
+ // unbounded loop — T-51-02). For each target cell: count it (total); SKIP if the column is
5022
+ // non-editable (D-03) or the per-column validator rejects the value (D-03, T-51-01 — the
5023
+ // value passes runValidator as plain string DATA before any write); else stage it into ONE
5024
+ // running fresh array (replaceRowValue) and record the committed cell. After the walk: ONE
5025
+ // writeData (the single r-model:data write), ONE cell-edit-commit per COMMITTED cell, and the
5026
+ // N-of-M aria-live announce. Returns { wrote, total }.
4002
5027
  applyGridToRange = (grid: any, originRow: any, originCol: any) => {
4003
5028
  const maxRow = this.bodyRowCount() - 1;
4004
5029
  const maxCol = this.visibleColCount() - 1;
@@ -4063,6 +5088,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4063
5088
  };
4064
5089
  };
4065
5090
 
5091
+ // rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
4066
5092
  rowOriginalAt = (rowIndex: any) => {
4067
5093
  const rowList = this._rows.value || [];
4068
5094
  const row = rowList[rowIndex];
@@ -4075,6 +5101,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4075
5101
  return row ? row.id : null;
4076
5102
  };
4077
5103
 
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).
4078
5112
  tileGridToBox = (grid: any, box: any) => {
4079
5113
  const srcRows = grid.length;
4080
5114
  // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
@@ -4104,6 +5138,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4104
5138
  return out;
4105
5139
  };
4106
5140
 
5141
+ // pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
5142
+ // (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
5143
+ // clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
4107
5144
  pasteRange = () => {
4108
5145
  // B11: never paste into a header-active state (the reusable clipboard guard) — a header
4109
5146
  // anchor would silently write body row 0 at the header's column.
@@ -4141,6 +5178,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4141
5178
  }).catch(() => {});
4142
5179
  };
4143
5180
 
5181
+ // cutRange(): C3 Cut — copy the current range to the clipboard (rangeToTsv — the SAME escaped
5182
+ // serialization copyRange uses) THEN CLEAR the source cells through the SAME write-funnel as
5183
+ // paste/fill: applyGridToRange of an empty-string grid sized to the range → coerceCellValue('')
5184
+ // per column (null on a numeric column, '' on text) + the D-03 editable/validator skip rule +
5185
+ // ONE writeData + one cell-edit-commit per cleared cell + the N-of-M announce. A read-only /
5186
+ // required cell is left intact (the funnel skips it). B11: a no-op while a header cell is active
5187
+ // (reuses clipboardActiveAllowed — Cut can never silently clear a body cell from a header anchor).
5188
+ // The clear is SYNCHRONOUS and runs AFTER rangeToTsv has already serialized, so the copy reads the
5189
+ // pre-clear values; the clipboard write is best-effort/async and never blocks the clear.
4144
5190
  cutRange = () => {
4145
5191
  if (!this.clipboardActiveAllowed()) return;
4146
5192
  // Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
@@ -4166,6 +5212,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4166
5212
  this.applyGridToRange(grid, r0, c0);
4167
5213
  };
4168
5214
 
5215
+ // clearActiveRange(): the §7 (260709-3qt) Delete/Backspace clear — cutRange() MINUS the clipboard
5216
+ // copy. Clears the active cell / selected range through the SAME write-funnel as Cut/paste/fill:
5217
+ // applyGridToRange of an empty-string grid sized to the range → coerceCellValue('') per column
5218
+ // (null on a numeric column, '' on text) + the D-03 editable/validator/read-only skip rule + ONE
5219
+ // writeData + one cell-edit-commit per cleared cell + the N-of-M announce. B11: a no-op while a
5220
+ // header cell is active (reuses clipboardActiveAllowed — Delete can never silently clear a body
5221
+ // cell from a header anchor). NO undo — the grid is controlled (writeData → $model.data; every
5222
+ // clear fires cell-edit-commit), so undo is the consumer's responsibility, the SAME contract
5223
+ // Cut/Paste/Fill already carry (design §7, approved 2026-07-09).
4169
5224
  clearActiveRange = () => {
4170
5225
  if (!this.clipboardActiveAllowed()) return;
4171
5226
  // Snapshot the source rectangle synchronously (the ROZ138 concern cutRange/pasteRange share).
@@ -4183,6 +5238,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4183
5238
  this.applyGridToRange(grid, r0, c0);
4184
5239
  };
4185
5240
 
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.
4186
5245
  tileIndex = (i: any, lo: any, hi: any) => {
4187
5246
  const span = hi - lo + 1;
4188
5247
  if (span <= 1) return lo;
@@ -4191,6 +5250,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4191
5250
  return lo + k;
4192
5251
  };
4193
5252
 
5253
+ // fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
5254
+ // SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
5255
+ // the range); each target cell copies the source cell in its OWN column (and row, when the
5256
+ // source spans rows), TILED across the source dimensions. This fixes two data-loss bugs: (1) a
5257
+ // single-scalar broadcast clobbered the other columns' data, and (2) reading box.r0/box.c0
5258
+ // flipped to the WRONG corner on an up/left drag (the box top-left is a TARGET cell there, not
5259
+ // the source). `sourceBox` falls back to the box's top-left 1×1 for a no-source fill. Honors the
5260
+ // SAME editable + validation + type-coercion skip rule as paste (via applyGridToRange): one
5261
+ // writeData + one cell-edit-commit per committed cell + the N-of-M announce. No-op without a range.
4194
5262
  fillRange = (sourceBox: any, endCell: any) => {
4195
5263
  // B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
4196
5264
  // the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
@@ -4237,8 +5305,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4237
5305
  this.applyGridToRange(grid, box.r0, box.c0);
4238
5306
  };
4239
5307
 
5308
+ // onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
5309
+ // range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
5310
+ // cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
5311
+ // pointermove extends the range to the cell under the pointer; pointerup commits the fill.
4240
5312
  fillDragging = false;
4241
5313
 
5314
+ // CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
5315
+ // them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
5316
+ // but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
4242
5317
  fillDragMove: any = null;
4243
5318
 
4244
5319
  fillDragUp: any = null;
@@ -4336,6 +5411,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4336
5411
  }
4337
5412
  };
4338
5413
 
5414
+ // §6 (260709-3qt) drag-to-select — mirror the fill-drag listener discipline. rangeDragging gates
5415
+ // the live gesture; rangeDragMove/rangeDragUp track the document pointermove/pointerup handlers so
5416
+ // a mid-drag unmount ($onUnmount → teardownRangeDrag) can remove them (CR-04). rangeDragMoved flips
5417
+ // true once the drag enters a DIFFERENT cell than its mousedown anchor; onGridClick reads it to
5418
+ // suppress a singleClickEdit editor-open after a drag (reset per-gesture in beginRangeDrag). Each
5419
+ // top-level let → React hoists to useRef.
4339
5420
  rangeDragging = false;
4340
5421
 
4341
5422
  rangeDragMove: any = null;
@@ -4344,6 +5425,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4344
5425
 
4345
5426
  rangeDragMoved = false;
4346
5427
 
5428
+ // ══ Mouse drag-to-select (grid cell-interaction §6, 260709-3qt) ═════════════════════════
5429
+ // A plain (non-shift) mousedown on a body cell begins a document-level drag: the FIRST
5430
+ // pointermove that reaches a DIFFERENT body cell paints the range moving corner via the
5431
+ // SHARED-scope setRangeFocus (the SAME range model shift+click / shift+arrow drive), pointerup
5432
+ // ends it. Mirrors fillDrag.rzts's listener discipline VERBATIM (document pointermove/pointerup
5433
+ // tracked in module-lets so a mid-drag unmount can remove them — CR-04), and REUSES fillDrag's
5434
+ // shadow-piercing cellIndexFromPoint (shared scope) so the Lit shadow target is covered uniformly.
5435
+ // teardownRangeDrag(): remove the live drag listeners, null them, clear the dragging flag. The
5436
+ // `up` handler calls it on a normal release; $onUnmount calls it if we unmount MID-DRAG (mirrors
5437
+ // teardownFillDrag). rangeDragMoved is NOT reset here — it is read by onGridClick AFTER pointerup
5438
+ // (to suppress a singleClickEdit editor-open) and reset per-gesture in beginRangeDrag.
4347
5439
  teardownRangeDrag = () => {
4348
5440
  if (typeof document !== 'undefined') {
4349
5441
  if (this.rangeDragMove) document.removeEventListener('pointermove', this.rangeDragMove);
@@ -4354,6 +5446,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4354
5446
  this.rangeDragging = false;
4355
5447
  };
4356
5448
 
5449
+ // beginRangeDrag(anchorR, anchorC): start a drag-select anchored at the mousedown cell. The
5450
+ // mousedown's native focus/focusin already committed the ACTIVE cell to (anchorR, anchorC), so
5451
+ // setRangeFocus (which seeds the anchor from the ACTIVE cell) spans mousedown-cell→pointer-cell —
5452
+ // we NEVER write $data.rangeAnchor directly (it is React-stale, ROZ138). rangeDragMoved starts
5453
+ // false and flips true only once the pointer reaches a DIFFERENT cell, so a mousedown-with-no-move
5454
+ // leaves a single active cell + no range (a normal click). lastCell dedups the many pointermove
5455
+ // events per cell (setRangeFocus emits range-change — only extend on a NEW cell, mirroring fillDrag's
5456
+ // B20 dedup). Captured per-gesture in the closure (no module-let needed for lastCell).
4357
5457
  beginRangeDrag = (anchorR: any, anchorC: any) => {
4358
5458
  // #leak: tear down any orphaned PRIOR range gesture BEFORE reassigning the module-let handlers.
4359
5459
  // A missed pointerup (off-window release, context menu, alt-tab) leaves the prior drag's document
@@ -4390,6 +5490,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4390
5490
  }
4391
5491
  };
4392
5492
 
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
+ // The column id at the active cell (the active row's visible cell list @ activeColIndex).
5500
+ // Null when out of range (no body rows, or active cell is a header / select column).
4393
5501
  activeCellColumnId = () => {
4394
5502
  if (this._activeIsHeader.value) return null;
4395
5503
  const rowList = this._rows.value || [];
@@ -4400,11 +5508,25 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4400
5508
  return cell && cell.column ? cell.column.id : null;
4401
5509
  };
4402
5510
 
5511
+ // isActiveCellEditable: the active cell sits in an editable column AND is a body cell
5512
+ // (req-1). Gates the F2/Enter/printable edit-entry branches in onGridKeyDown; a
5513
+ // non-editable active cell falls through to the reserved enterControl path.
4403
5514
  isActiveCellEditable = () => {
4404
5515
  const colId = this.activeCellColumnId();
4405
5516
  return colId != null && this.columnEditable(colId);
4406
5517
  };
4407
5518
 
5519
+ // isEditing: is the cell at (rowIndex, colIndex) over the visible model in edit? ONE
5520
+ // predicate covers BOTH modes (RESEARCH Pattern 6):
5521
+ // - row mode (req-6): editingRowIndex === rowIndex AND the column at colIndex is editable —
5522
+ // so EVERY editable cell in the row enters edit simultaneously (the editor template branch
5523
+ // re-uses this gate verbatim, no template fork);
5524
+ // - single-cell mode (req-1/3): the editingRow/editingCol pair matches exactly.
5525
+ // Pure index compare (editingRowIndex null + editingRow -1 = none) → the byte-identical-off
5526
+ // guard for the editor template branch. $data.editVer is read first so the per-cell branch
5527
+ // re-derives on Svelte/Solid when editing state mutates from a foreign slot-callback scope.
5528
+ // Called per-cell in both <td> bodies with the body-specific row index (rowIndexOf(row)
5529
+ // non-virtual, wr.vi.index virtual).
4408
5530
  isEditing = (rowIndex: any, colIndex: any) => {
4409
5531
  if (this._editVer.value < 0) return false;
4410
5532
  if (this._editingRowIndex.value != null && this._editingRowIndex.value === rowIndex) {
@@ -4414,8 +5536,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4414
5536
  return this._editingRow.value === rowIndex && this._editingCol.value === colIndex;
4415
5537
  };
4416
5538
 
5539
+ // cellAriaInvalid (req-5/D-01): the STRING 'true' ONLY for the editing cell while it holds
5540
+ // an invalid value — drives :aria-invalid on the <td>. Returns null otherwise so the bound
5541
+ // attribute DROPS (the rozieAttr nullish-attr path), keeping non-editing cells byte-clean.
5542
+ // Returns the literal 'true' (NOT boolean true) so rozieAttr's string-literal-union preserve
5543
+ // keeps React's aria-invalid (Booleanish incl. 'true') happy instead of widening to string.
4417
5544
  cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => this.isEditing(rowIndex, colIndex) && !!this._invalidMsg.value ? 'true' : null;
4418
5545
 
5546
+ // runValidator: the sync per-column validator (req-5). Reads col.meta.validate; not a
5547
+ // function → valid (true). Calls it (defensively wrapped — a thrown/non-true/non-string
5548
+ // return coerces to a generic message so a misbehaving validator can never wedge the
5549
+ // keymap, Security V5 DoS). A string return is the error message (commit rejected, D-01).
4419
5550
  runValidator = (colId: any, value: any, row: any) => {
4420
5551
  const m = this.editMetaOf(colId);
4421
5552
  const v = m ? m.validate : null;
@@ -4431,10 +5562,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4431
5562
  return 'Invalid value';
4432
5563
  };
4433
5564
 
5565
+ // setInvalid: record the current validation error (drives the aria-live region +
5566
+ // :aria-invalid wired in Task 3). Empty string clears it.
4434
5567
  setInvalid = (msg: any) => {
4435
5568
  this._invalidMsg.value = msg != null ? msg : '';
4436
5569
  };
4437
5570
 
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).
4438
5576
  replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
4439
5577
  const src = rows || [];
4440
5578
  const out = [];
@@ -4453,6 +5591,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4453
5591
  return out;
4454
5592
  };
4455
5593
 
5594
+ // Map a visible-model body-row index ($data.rows index) to its underlying currentData()
5595
+ // index via the row's original object identity (sorting/filtering/pagination may reorder
5596
+ // the visible model away from the source array order). Falls back to the same index.
4456
5597
  sourceIndexOfRow = (visibleRowIndex: any) => {
4457
5598
  const rowList = this._rows.value || [];
4458
5599
  const row = rowList[visibleRowIndex];
@@ -4463,6 +5604,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4463
5604
  return idx >= 0 ? idx : visibleRowIndex;
4464
5605
  };
4465
5606
 
5607
+ // The column id / field (accessorKey) / current value / row object / row id for the cell
5608
+ // in EDIT — keyed off the authoritative editing pair ($data.editingRow/editingCol), NOT
5609
+ // the active-cell indices (which can drift from the editing cell on a Tab-advance, and are
5610
+ // async-stale right after a setState on React — ROZ138). Called only from commitEdit.
4466
5611
  editingColumnId = () => {
4467
5612
  const rowList = this._rows.value || [];
4468
5613
  const row = rowList[this._editingRow.value];
@@ -4500,6 +5645,24 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4500
5645
  return row ? row.id : null;
4501
5646
  };
4502
5647
 
5648
+ // Focus the freshly-mounted editor (Pitfall 1, ROZ123): after beginEdit flips the editing
5649
+ // state, the editor <input> does not exist until the framework commits the r-if branch
5650
+ // (React setState async; Solid/Lit/Svelte next reactive tick). Poll for the
5651
+ // [data-editing-cell] element off gridRoot for ~30 frames — the five fast targets resolve
5652
+ // on attempt 1, React retries across its async commit. NEVER read $refs eagerly.
5653
+ // B2: selectAll gates the post-focus el.select(). Select-all is right when entering
5654
+ // edit IN PLACE (F2/Enter/click/row-edit/validation-reject — no seeded char, the user
5655
+ // retypes), but WRONG on a type-to-edit entry where a printable key already seeded the
5656
+ // draft (selecting the seeded char makes the next keystroke replace it: Zeta → eta).
5657
+ // beginEdit threads `seed == null` so a seeded entry skips the select and the caret sits
5658
+ // AFTER the seeded char; every other caller keeps the default select-all.
5659
+ // Editor-owns-focus contract (quick 260711-i5m): REVERTS the g52 shadow-piercing helper
5660
+ // (commit 5fa30045) that recursed into descendant shadow roots. Built-in editors are
5661
+ // host-DOM — the plain direct query resolves them on all 6 targets (no shadow to cross). A
5662
+ // #editor DROP-IN now owns its OWN focus via the reactive `autofocus` prop (EditorText's
5663
+ // $onMount + lazy $watch), so the host never needs to reach across a Lit drop-in's nested
5664
+ // shadow root at all — see the !hasEditorSlot gate below, which skips the host focus call
5665
+ // entirely for a drop-in target.
4503
5666
  focusEditorWhenReady = (selectAll = true) => {
4504
5667
  if (!this.gridRoot) return;
4505
5668
  // Editor-owns-focus contract: when the CURRENT focus target is a #editor drop-in, the host
@@ -4539,6 +5702,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4539
5702
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
4540
5703
  };
4541
5704
 
5705
+ // Column id + current value at an EXPLICIT (rowIndex, colIndex) over the visible model —
5706
+ // used by beginEdit so it never re-reads $data.activeRow/activeColIndex (which are async-
5707
+ // stale right after a Tab-advance sets them on React — ROZ138).
4542
5708
  columnIdAt = (rowIndex: any, colIndex: any) => {
4543
5709
  const rowList = this._rows.value || [];
4544
5710
  const row = rowList[rowIndex];
@@ -4557,6 +5723,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4557
5723
  return cell ? cell.getValue() : null;
4558
5724
  };
4559
5725
 
5726
+ // beginEdit: open the editor on the (rowIndex, colIndex) cell (req-1/3, D-05). seed===null
5727
+ // → seed the EXISTING value (F2/Enter in-place edit); a printable char → REPLACE (the
5728
+ // editor opens holding just that char). Resolves the column from the PASSED indices (not
5729
+ // $data) so a Tab-advance that just setState'd activeRow/Col works on React. Clears any
5730
+ // prior invalid state. Focus moves into the editor.
4560
5731
  beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
4561
5732
  const colId = this.columnIdAt(rowIndex, colIndex);
4562
5733
  if (colId == null || !this.columnEditable(colId)) return;
@@ -4582,6 +5753,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4582
5753
  this.focusEditorWhenReady(seed == null);
4583
5754
  };
4584
5755
 
5756
+ // Return focus to a body cell AFTER the editor unmounts (commit/cancel). The display↔
5757
+ // editor re-render must commit before the <td> is focusable with its roving tabindex —
5758
+ // on React/Solid/Lit that commit is async, so a synchronous focusActiveCell can run while
5759
+ // the cell is still the editor (or mid-swap) and focus is lost. Bounded rAF-poll resolves
5760
+ // the [data-row][data-col-index] cell off gridRoot for ~30 frames (the fast targets land
5761
+ // on attempt 1; React/Solid retry across the async commit). Mirrors focusEditorWhenReady.
4585
5762
  focusCellWhenReady = (row: any, col: any) => {
4586
5763
  if (!this.gridRoot) return;
4587
5764
  let attempts = 0;
@@ -4598,6 +5775,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4598
5775
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
4599
5776
  };
4600
5777
 
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).
4601
5784
  indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
4602
5785
  const list = rows || [];
4603
5786
  for (let i = 0; i < list.length; i++) {
@@ -4609,6 +5792,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4609
5792
  return -1;
4610
5793
  };
4611
5794
 
5795
+ // endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
5796
+ // draft + invalid state and returns to navigation mode. Does NOT move focus (callers
5797
+ // decide where focus lands — commit/cancel return it to the owning cell).
4612
5798
  endEdit = () => {
4613
5799
  this._editingRow.value = -1;
4614
5800
  this._editingCol.value = -1;
@@ -4619,6 +5805,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4619
5805
  this._editFocusColId.value = null;
4620
5806
  };
4621
5807
 
5808
+ // endRowEdit: tear down full-row edit (shared by commitRow/cancelRow). Clears the row
5809
+ // index + the per-cell drafts + invalid state and returns to navigation mode. Does NOT
5810
+ // move focus (callers return it to the active cell). Mirrors endEdit for the row mode.
4622
5811
  endRowEdit = () => {
4623
5812
  this._editingRowIndex.value = null;
4624
5813
  this._rowDraft.value = {};
@@ -4628,6 +5817,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4628
5817
  this._editFocusColId.value = null;
4629
5818
  };
4630
5819
 
5820
+ // editorAutofocusFor (quick 260711-i5m, editor-owns-focus contract): the reactive `autofocus`
5821
+ // #editor scope prop for a given (colId, rowIndex) — true for EXACTLY the current focus-
5822
+ // target cell, re-deriving on every editVer bump (mirrors isEditing's reactive gate so
5823
+ // Svelte/Solid re-run this per-cell on a foreign-slot-callback state mutation). Works for
5824
+ // BOTH single-cell ($data.editingRow) and row mode ($data.editingRowIndex) since
5825
+ // $data.editFocusColId is set by both beginEdit and beginRowEdit/commitRow/rowEditTab.
4631
5826
  editorAutofocusFor = (colId: any, rowIndex: any) => {
4632
5827
  if (this._editVer.value < 0) return false;
4633
5828
  if (this._editingRowIndex.value != null) {
@@ -4638,6 +5833,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4638
5833
  return this._editFocusColId.value != null && this._editFocusColId.value === colId;
4639
5834
  };
4640
5835
 
5836
+ // B3: coerce the committed value by the column's built-in editor type at the single
5837
+ // commit funnel. A 'number' editor commits a real Number; an empty/whitespace/non-numeric
5838
+ // draft commits null (never '' / never NaN — Number('') === 0 is a silent footgun). Every
5839
+ // other editor type commits the value verbatim. Idempotent for the #editor drop-in path
5840
+ // (an already-numeric override passes through; an explicit null stays null).
4641
5841
  coerceCellValue = (colId: any, raw: any) => {
4642
5842
  if (this.editorTypeOf(colId) !== 'number') return raw;
4643
5843
  if (raw == null) return null;
@@ -4648,6 +5848,16 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4648
5848
  return Number.isNaN(n) ? null : n;
4649
5849
  };
4650
5850
 
5851
+ // commitEdit: validate the draft (req-5); on success replace one row in a fresh array,
5852
+ // funnel it through writeData (the controlled r-model:data write, req-4), emit EXACTLY
5853
+ // ONE cell-edit-commit from THIS single call site (React multi-emit dedup, D-07), then
5854
+ // return focus to the cell. On a validation FAILURE keep the editor OPEN (D-01) — set
5855
+ // invalid, re-trap focus, never write the model. Captures the optional override value
5856
+ // (the #editor slot's commit(v) call) else the live draft.
5857
+ // Returns true when the commit succeeded (model written, editor closed); false when a
5858
+ // validation failure kept the editor OPEN (D-01). Callers MUST use this return value, not
5859
+ // a synchronous re-read of $data.editingRow — React's endEdit setState is async, so an
5860
+ // immediate re-read of editingRow still shows the OLD value (the ROZ138 stale-read class).
4651
5861
  commitEdit = (overrideValue = undefined, skipFocusReturn = false) => {
4652
5862
  if (this._editingRow.value < 0) return false;
4653
5863
  // Sync idempotency latch (drop-in double cell-edit-commit fix): a second commitEdit call
@@ -4738,6 +5948,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4738
5948
  return true;
4739
5949
  };
4740
5950
 
5951
+ // toggleActiveBooleanCell (design doc 2026-07-05, Change 1): the spreadsheet-standard
5952
+ // single-keystroke boolean toggle. Flips the ACTIVE cell's value and commits it through the
5953
+ // EXACT SAME write funnel commitEdit uses (replaceRowValue → writeData → single $emit) but
5954
+ // WITHOUT opening an editor — there is no editingRow/editingCol involvement at all, so this
5955
+ // operates entirely off $data.activeRow/activeColIndex. Gated in onGridKeyDown to
5956
+ // editor:'checkbox' columns only (Space/Enter/F2), full-row edit mode is unaffected (the
5957
+ // editingRowIndex early return in onGridKeyDown already excludes it).
4741
5958
  toggleActiveBooleanCell = () => {
4742
5959
  const colId = this.columnIdAt(this._activeRow.value, this._activeColIndex.value);
4743
5960
  if (colId == null || !this.columnEditable(colId)) return;
@@ -4785,6 +6002,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4785
6002
  };
4786
6003
  };
4787
6004
 
6005
+ // cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
6006
+ // return focus to the owning cell.
4788
6007
  cancelEdit = () => {
4789
6008
  if (this._editingRow.value < 0) return;
4790
6009
  // CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
@@ -4799,6 +6018,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4799
6018
  this.focusCellWhenReady(focusRow, focusCol);
4800
6019
  };
4801
6020
 
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
+ // The editable [columnId, field] pairs for a body row at the given visible-model index,
6028
+ // in visible-cell order. field is the column's accessorKey (the row-object key to write).
4802
6029
  editableColumnsForRow = (rowIndex: any) => {
4803
6030
  const rowList = this._rows.value || [];
4804
6031
  const row = rowList[rowIndex];
@@ -4823,6 +6050,16 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4823
6050
  return out;
4824
6051
  };
4825
6052
 
6053
+ // B21/B22: focus the row-mode editor at a given VISIBLE col index. In full-row edit every
6054
+ // editable cell is already mounted as an editor, so this resolves the cell off gridRoot and
6055
+ // focuses its [data-editing-cell] control. Bounded rAF-poll (mirrors focusEditorWhenReady)
6056
+ // so a React re-render that recreates the input across the focus call still lands it. select-
6057
+ // all on text/number editors (a no-op try/catch on select/checkbox).
6058
+ // Editor-owns-focus contract (quick 260711-i5m): when the TARGET column is a #editor
6059
+ // drop-in, the host does NOT reach into its DOM (early return, before starting the rAF poll
6060
+ // at all) — the drop-in self-focuses via its own reactive `autofocus` prop, which the caller
6061
+ // (commitRow's B22 reject path / rowEditTab) already flips via $data.editFocusColId. Built-in
6062
+ // columns are unaffected (hasEditorSlot is false for them) — unchanged host direct-focus.
4826
6063
  focusRowEditorAt = (rowIndex: any, colIndex: any) => {
4827
6064
  if (!this.gridRoot) return;
4828
6065
  const colId = this.columnIdAt(rowIndex, colIndex);
@@ -4847,6 +6084,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4847
6084
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
4848
6085
  };
4849
6086
 
6087
+ // beginRowEdit(row): enter full-row edit on a body row (req-6). Seeds rowDraft from each
6088
+ // editable column's CURRENT value (so an immediate save is a no-op), clears any single-cell
6089
+ // edit (mutual exclusivity), and focuses the first editable cell's editor (the bounded
6090
+ // rAF-poll resolves the first [data-editing-cell] off gridRoot — same mechanism as
6091
+ // focusEditorWhenReady). Accepts the row OBJECT (the template/Shift+F2 path) — index-resolved
6092
+ // internally via rowIndexOf so it stays in the editingRow/activeRow index space.
4850
6093
  beginRowEdit = (row: any) => {
4851
6094
  const rowIndex = this.rowIndexOf(row);
4852
6095
  if (rowIndex < 0) return;
@@ -4880,6 +6123,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4880
6123
  this.focusEditorWhenReady();
4881
6124
  };
4882
6125
 
6126
+ // commitRow(): validate EVERY edited column (D-01 — keep the row open if ANY fails: set
6127
+ // invalid + announce, NEVER write the model); on all-valid build ONE fresh array replacing
6128
+ // the single row object with all rowDraft values applied at once, call writeData ONCE, then
6129
+ // emit ONE row-edit-commit from THIS single call site, clear the row state, return focus.
6130
+ // Returns true on a written commit, false when a validation failure kept the row open.
4883
6131
  commitRow = () => {
4884
6132
  if (this._editingRowIndex.value == null) return false;
4885
6133
  const rowIndex = this._editingRowIndex.value;
@@ -4990,6 +6238,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4990
6238
  return true;
4991
6239
  };
4992
6240
 
6241
+ // cancelRow(): revert the whole row as a unit (D-06 — drop every draft, NO model write) and
6242
+ // return focus to the active cell.
4993
6243
  cancelRow = () => {
4994
6244
  if (this._editingRowIndex.value == null) return;
4995
6245
  const focusRow = this._activeRow.value;
@@ -5000,6 +6250,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5000
6250
  this.focusCellWhenReady(focusRow, focusCol);
5001
6251
  };
5002
6252
 
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).
5003
6255
  replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
5004
6256
  const src = rows || [];
5005
6257
  const fv = fieldValues || {};
@@ -5019,6 +6271,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5019
6271
  return out;
5020
6272
  };
5021
6273
 
6274
+ // Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
6275
+ // rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
6276
+ // cell at the row's end; stop (return null) at grid end. Pure index math over the visible
6277
+ // model. Returns { row, col } or null.
5022
6278
  nextEditableCell = (fromRow: any, fromCol: any) => {
5023
6279
  const rowList = this._rows.value || [];
5024
6280
  const rowCount = rowList.length;
@@ -5043,6 +6299,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5043
6299
  return null;
5044
6300
  };
5045
6301
 
6302
+ // B4: the mirror of nextEditableCell — the PREVIOUS editable cell for a Shift+Tab
6303
+ // backward move. Skips non-editable columns leftward within the row; wraps to the END
6304
+ // of the prior row; stops (returns null) at grid start. Pure index math over the visible
6305
+ // model. Returns { row, col } or null.
5046
6306
  prevEditableCell = (fromRow: any, fromCol: any) => {
5047
6307
  const rowList = this._rows.value || [];
5048
6308
  const rowCount = rowList.length;
@@ -5071,18 +6331,50 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5071
6331
  return null;
5072
6332
  };
5073
6333
 
6334
+ // Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
6335
+ // editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
6336
+ // guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
6337
+ // cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
5074
6338
  editTransition = false;
5075
6339
 
6340
+ // B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
6341
+ // commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
6342
+ // refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
6343
+ // committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
6344
+ // { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
5076
6345
  pendingEditFollow: any = null;
5077
6346
 
6347
+ // Sync idempotency latch for a cell commit (drop-in double cell-edit-commit fix, 260705):
6348
+ // commitEdit's `$data.editingRow < 0` re-entry guard is ASYNC-STALE on React — a deferred
6349
+ // drop-in editor's unmount-blur (onBlur → $props.commit → commitEdit) fires AFTER commitEdit
6350
+ // has already returned (editTransition is a SYNC latch, cleared before the async blur), while
6351
+ // `$data.editingRow` in that stale closure still reads the OLD (pre-endEdit) value, so the
6352
+ // second commit slips through and re-emits `cell-edit-commit`. A top-level `let` is written/read
6353
+ // synchronously by plain assignment (unaffected by React's setState batching — that's the point)
6354
+ // so it stays correct across the async window editTransition/editingRow cannot cover. Set true on
6355
+ // a SUCCESSFUL commitEdit/toggleActiveBooleanCell; reset to false wherever a NEW edit session
6356
+ // begins (beginEdit/beginRowEdit/editCell) so the next legitimate commit fires exactly once.
6357
+ // A top-level `let` (React hoists to useRef → persists).
5078
6358
  committedThisSession = false;
5079
6359
 
6360
+ // ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
6361
+ // In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
6362
+ // (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
6363
+ // rowDraft — so the four editors open simultaneously never clobber one shared value. These
6364
+ // helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
6365
+ // the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
6366
+ // onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
5080
6367
  inRowEdit = () => this._editingRowIndex.value != null;
5081
6368
 
5082
6369
  editorValueFor = (colId: any) => this.inRowEdit() ? this._rowDraft.value ? this._rowDraft.value[colId] : null : this._draftValue.value;
5083
6370
 
5084
6371
  editorCheckedFor = (colId: any) => !!(this.inRowEdit() ? this._rowDraft.value ? this._rowDraft.value[colId] : null : this._draftValue.value);
5085
6372
 
6373
+ // #editor custom-slot callbacks (req-2/6): the consumer's slot calls commit(value)/cancel().
6374
+ // In SINGLE-CELL mode commit(v) commits that cell (commitEdit override); in ROW mode commit(v)
6375
+ // only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
6376
+ // reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
6377
+ // row-mode commit targets the right draft key.
5086
6378
  editorCommitFor = (colId: any) => (value: any) => {
5087
6379
  if (this.inRowEdit()) {
5088
6380
  this.setRowDraft(colId, value);
@@ -5099,6 +6391,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5099
6391
  this.cancelEdit();
5100
6392
  };
5101
6393
 
6394
+ // Editor input handlers (the global-filter `evt.target.value` idiom — an untyped param
6395
+ // neutralizes to `any`, so reading .value/.checked typechecks ×6; an inline
6396
+ // `$data.x = $event.target.value` binding does NOT neutralize and breaks Lit/React JSX).
6397
+ // Column-aware: in row mode they write rowDraft[colId] (a FRESH object so Solid/Svelte/React
6398
+ // re-derive); single-cell they write the shared draftValue.
5102
6399
  onCellEditorInput = (colId: any, evt: any) => {
5103
6400
  const v = evt && evt.target ? evt.target.value : '';
5104
6401
  if (this.inRowEdit()) {
@@ -5117,6 +6414,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5117
6414
  this._draftValue.value = v;
5118
6415
  };
5119
6416
 
6417
+ // setRowDraft: write ONE key into a FRESH rowDraft object (whole-object replace — an
6418
+ // in-place mutation is silently dropped on React/Solid; the family immutable rule).
5120
6419
  setRowDraft = (colId: any, value: any) => {
5121
6420
  const src = this._rowDraft.value || {};
5122
6421
  const next = {};
@@ -5125,6 +6424,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5125
6424
  this._rowDraft.value = next;
5126
6425
  };
5127
6426
 
6427
+ // B21: contain a Tab WITHIN the editing row (editMode='row'). Resolve the editable cells'
6428
+ // visible col indices for the editing row, find the current editor's col (off the blurring
6429
+ // editor's owning [data-grid-cell]), then move to the next/prev editable col WITH WRAP so
6430
+ // focus never leaves the row. A no-op when no row is editing / the row has no editable cells.
5128
6431
  rowEditTab = (target: any, backward: any) => {
5129
6432
  const rowIndex = this._editingRowIndex.value;
5130
6433
  if (rowIndex == null) return;
@@ -5146,6 +6449,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5146
6449
  this.focusRowEditorAt(rowIndex, cols[nextPos]);
5147
6450
  };
5148
6451
 
6452
+ // onEditorKeyDown: the editor-LOCAL keymap (req-3). Enter → commit + stay (focus returns
6453
+ // to the cell); Tab → commit + advance to the next editable cell; Escape → cancel +
6454
+ // revert. preventDefault on handled keys so the grid keymap / native Tab don't double-act.
5149
6455
  onEditorKeyDown = (e: any) => {
5150
6456
  if (!e) return;
5151
6457
  const key = e.key;
@@ -5204,6 +6510,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5204
6510
  }
5205
6511
  };
5206
6512
 
6513
+ // onEditorBlur: commit on a genuine click/focus-away (D-01 — an invalid value keeps the
6514
+ // editor open via commitEdit's reject path). SKIP when:
6515
+ // - editTransition is set (a synchronous commit/cancel teardown is unmounting the editor), or
6516
+ // - the blur is part of a controlled keyboard transition: focus is moving to a grid cell
6517
+ // or another editor inside our gridRoot (Tab-advance, Enter/Escape focus-return). On the
6518
+ // async-render targets the unmount-blur can fire AFTER the synchronous flag cleared, so
6519
+ // the relatedTarget/containment check is the load-bearing guard, not the flag alone.
5207
6520
  onEditorBlur = (e: any) => {
5208
6521
  // Full-row mode (req-6): a blur that stays WITHIN the row editor — Tab/click between the
5209
6522
  // row's OWN fields — is a normal focus move and must NOT commit (a per-cell blur-commit
@@ -5282,6 +6595,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5282
6595
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
5283
6596
  };
5284
6597
 
6598
+ // editCell(rowIndex, colIndex) — programmatic edit-entry ($expose, req-3). Coerces +
6599
+ // clamps indices, moves the active cell, and opens the editor (no-op on a non-editable
6600
+ // cell). Collision-clean (RESEARCH name-check): not a verb/event/prop/ROZ137 member.
5285
6601
  editCell = (rowIndex: any, colIndex: any) => {
5286
6602
  const lastRow = this.bodyRowCount() - 1;
5287
6603
  const maxRow = lastRow < 0 ? 0 : lastRow;
@@ -5296,6 +6612,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5296
6612
  this.beginEdit(r, c, null);
5297
6613
  };
5298
6614
 
6615
+ // commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
6616
+ // nothing is editing. Collision-clean (not `commit`). Handles BOTH edit modes: a full-row
6617
+ // edit (editRow()/Shift+F2) drives editingRowIndex and leaves editingRow at -1, so the
6618
+ // single-cell commitEdit guard (editingRow >= 0) is false during a row edit — route to
6619
+ // commitRow() first so a programmatic commit of a row editor is not a silent no-op.
5299
6620
  commitEditing = () => {
5300
6621
  if (this.inRowEdit()) {
5301
6622
  this.commitRow();
@@ -5304,6 +6625,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5304
6625
  if (this._editingRow.value >= 0) this.commitEdit(undefined);
5305
6626
  };
5306
6627
 
6628
+ // editRow(rowIndex) — programmatically enter full-row edit on a body row ($expose, req-6 /
6629
+ // D-06), the API twin of the Shift+F2 shortcut. Addressed BY INDEX over the visible model
6630
+ // (coerced + clamped); no-op on a row with no editable columns. Collision-clean (RESEARCH
6631
+ // name-check): `editRow` is not in the 15 existing verbs, not a prop, not a *-change/commit
6632
+ // event, not a Lit ROZ137-reserved host member. Moves the active cell to the row first so the
6633
+ // commit/cancel focus-return lands in the right row.
5307
6634
  editRow = (rowIndex: any) => {
5308
6635
  const lastRow = this.bodyRowCount() - 1;
5309
6636
  const maxRow = lastRow < 0 ? 0 : lastRow;
@@ -5316,6 +6643,23 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5316
6643
  this.beginRowEdit(row);
5317
6644
  };
5318
6645
 
6646
+ // ── Grid active-cell $expose verbs (phase 49 plan 03, D-01) — exactly THREE, joining the
6647
+ // existing 12 (→ 15). Collision-safe names (Pitfall 1): focusCell NOT `focus` (would shadow
6648
+ // HTMLElement.focus on Lit — ROZ137); clearActiveCell NOT `clear` (listbox already exposes
6649
+ // `clear`); getActiveCell is a read-style getter. None collide with the 9 *-change events,
6650
+ // any prop, or a React auto-setter (ROZ121/137/524 clear). ──────────────────────────────────
6651
+ // focusAbsCellWhenReady — paginated page-switch focus poll (C1). After a programmatic page
6652
+ // switch the in-page (localRow, col) cell is ambiguous: EVERY page renders a row at the same
6653
+ // page-relative index, so a plain resolveCellEl(localRow, col) poll would grab the OLD page's
6654
+ // cell on frame 1 (before the switch commits) and focus it — only for the page switch to then
6655
+ // REMOVE it, dropping focus to <body>. Disambiguate by the ABSOLUTE aria-rowindex: poll until
6656
+ // the cell at (localRow, col) carries the TARGET page's body aria-rowindex (i.e. the TARGET
6657
+ // page has actually rendered), THEN focus. DOM-only (reads gridRoot), so React-stale-safe; works
6658
+ // for both controlled (round-trips through page-change) and uncontrolled pagination. ~60 frames
6659
+ // (~1s) to cover the controlled-state parent round-trip on React/Solid/Lit.
6660
+ // #13: the body aria-rowindex is now header-offset (bodyAriaRowIndex = headerRowCount + absRow
6661
+ // + 1) so header rows + body rows form one consistent aria-rowindex/aria-rowcount space — so
6662
+ // the poll target must add headerRowCount() too, else it never matches and focus drops.
5319
6663
  focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
5320
6664
  if (!this.gridRoot) return;
5321
6665
  let attempts = 0;
@@ -5343,6 +6687,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5343
6687
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
5344
6688
  };
5345
6689
 
6690
+ // focusCell(rowIndex, colIndex) — move + focus the active cell. C1 (phase 63 wave-6): rowIndex
6691
+ // is the ABSOLUTE display-order position in getPrePaginationRowModel().rows (filter+sort+expand
6692
+ // applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes — REVERSING the old
6693
+ // page-relative-when-paginated meaning. Args are COERCED to integers and CLAMPED before the
6694
+ // data-* selector is built (T-49-01/T-63-06-01: never interpolate a raw consumer string; clamp
6695
+ // the abs index into getPrePaginationRowModel bounds). The activecell-change payload + getActiveCell
6696
+ // speak the SAME absolute language (toAbsRow).
5346
6697
  focusCell = (rowIndex: any, colIndex: any) => {
5347
6698
  // B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
5348
6699
  // is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
@@ -5410,6 +6761,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5410
6761
  }
5411
6762
  };
5412
6763
 
6764
+ // getActiveCell() — return the current active-cell position. Integers only — no row data,
6765
+ // no DOM node (T-49-02 Information-Disclosure: return the screen position, nothing else).
6766
+ // B15: reflect the HEADER-active state. When a header cell is active the roving position is
6767
+ // NOT a body row — return the header sentinel (rowIndex null + isHeader true, colIndex the
6768
+ // header column) so a consumer never mistakes a header focus for body 'row 0'. A body cell
6769
+ // returns the integer rowIndex + isHeader false (back-compatible: the rowIndex/colIndex pair
6770
+ // is unchanged for the body case).
6771
+ // C1: a body cell returns the ABSOLUTE display-order rowIndex (toAbsRow) — matching focusCell's
6772
+ // addressing + the activecell-change payload — in BOTH paginated and virtual modes.
5413
6773
  getActiveCell = () => this._activeIsHeader.value ? {
5414
6774
  rowIndex: null,
5415
6775
  colIndex: this._activeColIndex.value,
@@ -5420,6 +6780,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5420
6780
  isHeader: false
5421
6781
  };
5422
6782
 
6783
+ // clearActiveCell() — reset the roving position to the D-04 entry cell (row 0, col 0) and
6784
+ // exit interaction mode; the next Tab-in re-enters at the entry cell (D-01). Does NOT emit
6785
+ // (no move to a new addressable cell — a reset, not a navigation). B16: isGrid()-gated — a
6786
+ // table-mode instance has no roving active cell, so the verb is a no-op there.
5423
6787
  clearActiveCell = () => {
5424
6788
  if (!this.isGrid()) return;
5425
6789
  this._activeIsHeader.value = false;
@@ -5428,6 +6792,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5428
6792
  this._activeColIndex.value = 0;
5429
6793
  };
5430
6794
 
6795
+ // ── Expand $expose verbs (phase 50 req-3, D-06) — joining the existing 19 (→ 23).
6796
+ // Collision-safe names (ROZ121/137/524): toggleRowExpanded / expandAll / collapseAll are
6797
+ // not inherited HTMLElement members, Lit lifecycle names, React auto-setters, prop names,
6798
+ // or *-change events; getExpandedRows is a read-style getter (twin of getSelectedRows).
6799
+ // Each drives @tanstack/table-core so the onExpandedChange → writeExpanded funnel fires
6800
+ // one expanded-change. ──────────────────────────────────────────────────────────────────
6801
+ // toggleRowExpanded(rowId) — toggle ONE row's expanded state, addressed by the consumer's
6802
+ // row id (the data `id` field) OR the table-core row id. Scans the core flat-row set (all
6803
+ // rows regardless of current expansion) so a collapsed parent is still resolvable.
5431
6804
  toggleRowExpanded = (rowId: any) => {
5432
6805
  if (!this.table) return;
5433
6806
  const target = String(rowId);
@@ -5440,16 +6813,23 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5440
6813
  }
5441
6814
  };
5442
6815
 
6816
+ // expandAll() — open every expandable row (table-core sets ExpandedState to the `true`
6817
+ // literal under the hood → Pitfall 2: writeExpanded passes it through verbatim).
5443
6818
  expandAll = () => {
5444
6819
  if (!this.table) return;
5445
6820
  this.table.toggleAllRowsExpanded(true);
5446
6821
  };
5447
6822
 
6823
+ // collapseAll() — reset to a blank expanded state ({}). resetExpanded(true) forces the
6824
+ // blank reset (NOT the initialState) and fires onExpandedChange → one expanded-change.
5448
6825
  collapseAll = () => {
5449
6826
  if (!this.table) return;
5450
6827
  this.table.resetExpanded(true);
5451
6828
  };
5452
6829
 
6830
+ // getExpandedRows() — return the original row data for every currently-expanded row
6831
+ // (read-verb twin of expanded-change). Integers/data only — scans the core flat rows and
6832
+ // filters by getIsExpanded(). Empty when nothing is expanded.
5453
6833
  getExpandedRows = () => {
5454
6834
  if (!this.table) return [];
5455
6835
  const out = [];
@@ -5458,6 +6838,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5458
6838
  return out;
5459
6839
  };
5460
6840
 
6841
+ // ── Grouping $expose verbs (phase 50 reqs 4-7, D-06 name-check) ────────────────────────────
6842
+ // applyGrouping (RENAMED from setGrouping — ROZ524: a bare `set<ModelProp>` verb shadows
6843
+ // React's auto-generated `setGrouping` useState setter for the `grouping` model slice, and an
6844
+ // $expose verb is PUBLIC-CONTRACT-PROTECTED from the deconfliction rename; same precedent as
6845
+ // setColumnOrder→applyColumnOrder) + clearGrouping. Both drive @tanstack/table-core's
6846
+ // table.setGrouping so the onGroupingChange → writeGrouping funnel fires one group-change with
6847
+ // the fresh ordered key list. Also handed to the headless #groupBar slot as apply/clear helpers.
5461
6848
  applyGrouping = (cols: any) => {
5462
6849
  if (this.table) this.table.setGrouping(cols);
5463
6850
  };
@@ -5466,6 +6853,20 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5466
6853
  if (this.table) this.table.setGrouping([]);
5467
6854
  };
5468
6855
 
6856
+ // ── Faceted filtering read helpers (phase 50 reqs 8-9, D-03) ────────────────────────────────
6857
+ // Shared by BOTH the getFaceted* $expose verbs AND the #filter slot props. They resolve a
6858
+ // column via table.getColumn(colId) (a table-core lookup — NEVER a string-built querySelector,
6859
+ // T-50-06 / the T-49-01 index-only discipline) and read table-core's CROSS-FILTERED faceted
6860
+ // values (default impl — reflects rows passing all OTHER active column filters, D-03). They
6861
+ // touch the reactive tick (`tick() < 0` guard) so the #filter slot props re-derive when an
6862
+ // upstream filter changes on the fine-grained targets (Solid/Lit) — the visibleCellsFor idiom.
6863
+ //
6864
+ // getFacetedUniqueValues: the column's distinct values, KEYS ONLY — occurrence counts are
6865
+ // deliberately NOT exposed (D-03; the column's getFacetedUniqueValues() returns Map<any,number>,
6866
+ // we return Array.from(map.keys()) — no .entries()/count surface). Empty array on missing
6867
+ // column/table. NAMED to match the $expose verb exactly (the ExposedMethod.name shorthand
6868
+ // contract: an exposed verb lowers to `{ getFacetedUniqueValues }`, which must resolve to THIS
6869
+ // helper — the table-core factory was aliased to makeFacetedUniqueValues to free this name).
5469
6870
  getFacetedUniqueValues = (colId: any) => {
5470
6871
  if (this.tick() < 0 || !this.table) return [];
5471
6872
  const col = this.table.getColumn(colId);
@@ -5474,6 +6875,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5474
6875
  return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
5475
6876
  };
5476
6877
 
6878
+ // getFacetedMinMaxValues: the column's [min, max] numeric range, or null when unavailable.
6879
+ // Named to match the $expose verb (same shorthand contract as getFacetedUniqueValues above).
5477
6880
  getFacetedMinMaxValues = (colId: any) => {
5478
6881
  if (this.tick() < 0 || !this.table) return null;
5479
6882
  const col = this.table.getColumn(colId);