@rozie-ui/data-table-lit 0.2.5 → 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 });
@@ -1352,22 +1352,86 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1352
1352
 
1353
1353
  table: any = null;
1354
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.
1355
1360
  virtualizer: any = null;
1356
1361
 
1357
1362
  virtualizerCleanup: any = null;
1358
1363
 
1359
1364
  gridScrollEl: any = null;
1360
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.
1361
1375
  remeasurePending = false;
1362
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.
1363
1381
  GRID_PAGE_STEP = 10;
1364
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).
1365
1388
  gridRoot: any = null;
1366
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.
1367
1392
  programmatic = 0;
1368
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.
1369
1403
  focusIntentEpoch = 0;
1370
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.
1371
1435
  DATA_WRITE_TOKEN_KEY = '__rozieDataWriteToken';
1372
1436
 
1373
1437
  undoStack: unknown[] = [];
@@ -1376,10 +1440,37 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1376
1440
 
1377
1441
  restoringHistory: boolean = false;
1378
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).
1379
1450
  expandedTouched = false;
1380
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.
1381
1455
  groupingActiveDefault = () => ((this.grouping != null ? this.grouping : this._groupingDefault.value) || []).length > 0;
1382
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).
1383
1474
  effectiveColumnPinning = (): any => {
1384
1475
  const base = this.columnPinning != null ? this.columnPinning : this._columnPinningDefault.value;
1385
1476
  const rail: string[] = [];
@@ -1394,6 +1485,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1394
1485
  };
1395
1486
  };
1396
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).
1397
1495
  currentState = (): any => ({
1398
1496
  sorting: this.sorting != null ? this.sorting : this._sortingDefault.value,
1399
1497
  globalFilter: this.globalFilter != null ? this.globalFilter : this._globalFilterDefault.value,
@@ -1429,10 +1527,28 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1429
1527
  columnSizingInfo: this._columnSizingInfo.value
1430
1528
  });
1431
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.
1432
1534
  currentData = (): any => this.data != null ? this.data : this._dataDefault.value;
1433
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.)
1434
1542
  isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
1435
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).
1436
1552
  wrapAggregationFn = (fn: any) => {
1437
1553
  if (typeof fn === 'string') return fn;
1438
1554
  if (typeof fn !== 'function') return undefined;
@@ -1445,6 +1561,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1445
1561
  };
1446
1562
  };
1447
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).
1448
1569
  buildConfigDef = (c: any) => {
1449
1570
  if (!c) return null;
1450
1571
  // Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
@@ -1556,10 +1677,24 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1556
1677
  return out;
1557
1678
  };
1558
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 "__").
1559
1682
  SELECT_COL_ID = '__rdt_select';
1560
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]).
1561
1687
  EXPANDER_COL_ID = '__rdt_expander';
1562
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.
1563
1698
  selectionEnabled = () => this.selectionMode === 'single' || this.selectionMode === 'multiple';
1564
1699
 
1565
1700
  tableColumns = () => {
@@ -1607,6 +1742,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1607
1742
  return withExpander;
1608
1743
  };
1609
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.
1610
1752
  writeSorting = (next: any) => {
1611
1753
  if (this.programmatic) return;
1612
1754
  this.programmatic++;
@@ -1622,6 +1764,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1622
1764
 
1623
1765
  applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
1624
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).
1625
1774
  writeExpanded = (next: any) => {
1626
1775
  if (this.programmatic) return;
1627
1776
  this.programmatic++;
@@ -1645,6 +1794,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1645
1794
  this.programmatic--;
1646
1795
  };
1647
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.
1648
1808
  writeGrouping = (next: any) => {
1649
1809
  if (this.programmatic) return;
1650
1810
  this.programmatic++;
@@ -1658,6 +1818,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1658
1818
  this.programmatic--;
1659
1819
  };
1660
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.
1661
1824
  writeGlobalFilter = (next: any) => {
1662
1825
  if (this.programmatic) return;
1663
1826
  this.programmatic++;
@@ -1673,6 +1836,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1673
1836
  this.programmatic--;
1674
1837
  };
1675
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).
1676
1843
  writeColumnFilters = (next: any) => {
1677
1844
  if (this.programmatic) return;
1678
1845
  this.programmatic++;
@@ -1688,6 +1855,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1688
1855
  this.programmatic--;
1689
1856
  };
1690
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`.
1691
1860
  writePagination = (next: any) => {
1692
1861
  if (this.programmatic) return;
1693
1862
  this.programmatic++;
@@ -1701,6 +1870,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1701
1870
  this.programmatic--;
1702
1871
  };
1703
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.
1704
1876
  writeRowSelection = (next: any) => {
1705
1877
  if (this.programmatic) return;
1706
1878
  this.programmatic++;
@@ -1714,6 +1886,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1714
1886
  this.programmatic--;
1715
1887
  };
1716
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.
1717
1892
  writeColumnVisibility = (next: any) => {
1718
1893
  if (this.programmatic) return;
1719
1894
  this.programmatic++;
@@ -1727,6 +1902,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1727
1902
  this.programmatic--;
1728
1903
  };
1729
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.
1730
1908
  writeColumnSizing = (next: any) => {
1731
1909
  if (this.programmatic) return;
1732
1910
  this.programmatic++;
@@ -1740,6 +1918,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1740
1918
  this.programmatic--;
1741
1919
  };
1742
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.
1743
1924
  writeColumnOrder = (next: any) => {
1744
1925
  if (this.programmatic) return;
1745
1926
  this.programmatic++;
@@ -1753,6 +1934,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1753
1934
  this.programmatic--;
1754
1935
  };
1755
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.
1756
1941
  writeColumnPinning = (next: any) => {
1757
1942
  if (this.programmatic) return;
1758
1943
  // effectiveColumnPinning() forces the auto-injected chrome ids (select/expander) into the
@@ -1776,6 +1961,38 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1776
1961
  this.programmatic--;
1777
1962
  };
1778
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.
1779
1996
  writeData = (next: any) => {
1780
1997
  if (this.programmatic) return;
1781
1998
  if (this.undoable && !this.restoringHistory) {
@@ -1799,12 +2016,18 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1799
2016
  this.programmatic--;
1800
2017
  };
1801
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).
1802
2022
  columnFilterValue = (colId: any) => {
1803
2023
  const cf = this.currentState().columnFilters || [];
1804
2024
  for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
1805
2025
  return '';
1806
2026
  };
1807
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).
1808
2031
  setColumnFilter = (colId: any, value: any) => {
1809
2032
  const prev = this.currentState().columnFilters || [];
1810
2033
  const next = [];
@@ -1816,6 +2039,25 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1816
2039
  this.writeColumnFilters(next);
1817
2040
  };
1818
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).
1819
2061
  recordSnapshot = (current: any) => {
1820
2062
  this.undoStack.push(current);
1821
2063
  const limit = this.undoLimit != null ? this.undoLimit : 100;
@@ -1827,11 +2069,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1827
2069
 
1828
2070
  canRedo = () => this.redoStack.length > 0;
1829
2071
 
2072
+ // Both stacks empty — the external-swap latch (DataTable.rozie reFeed) and the
2073
+ // clearHistory() $expose verb share this single implementation.
1830
2074
  clearHistory = () => {
1831
2075
  this.undoStack = [];
1832
2076
  this.redoStack = [];
1833
2077
  };
1834
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).
1835
2083
  emitHistoryChange = () => {
1836
2084
  this.dispatchEvent(new CustomEvent("history-change", {
1837
2085
  detail: {
@@ -1843,12 +2091,22 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1843
2091
  }));
1844
2092
  };
1845
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.
1846
2098
  emitHistoryChangeIfEdged = (prevU: any, prevR: any) => {
1847
2099
  const nextU = this.canUndo();
1848
2100
  const nextR = this.canRedo();
1849
2101
  if (nextU !== prevU || nextR !== prevR) this.emitHistoryChange();
1850
2102
  };
1851
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.
1852
2110
  undo = () => {
1853
2111
  if (!this.canUndo()) return;
1854
2112
  const prev = this.undoStack.pop();
@@ -1859,6 +2117,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1859
2117
  this.emitHistoryChange();
1860
2118
  };
1861
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.
1862
2122
  redo = () => {
1863
2123
  if (!this.canRedo()) return;
1864
2124
  const next = this.redoStack.pop();
@@ -1869,8 +2129,21 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1869
2129
  this.emitHistoryChange();
1870
2130
  };
1871
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`.
1872
2136
  refreshRowModel: any = null;
1873
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.
1874
2147
  onSortingChangeCb = (updater: any) => {
1875
2148
  this.writeSorting(this.applyUpdater(updater, this.currentState().sorting));
1876
2149
  };
@@ -1920,12 +2193,50 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1920
2193
  this._columnSizingInfo.value = next != null ? next : this._columnSizingInfo.value;
1921
2194
  };
1922
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.
1923
2216
  windowSource = () => {
1924
2217
  if (!this.table) return [];
1925
2218
  if (this.virtual) return this.table.getPrePaginationRowModel().rows;
1926
2219
  return this.table.getRowModel().rows;
1927
2220
  };
1928
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.
1929
2240
  scheduleRemeasure = () => {
1930
2241
  if (this.remeasurePending) return;
1931
2242
  this.remeasurePending = true;
@@ -1944,18 +2255,38 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1944
2255
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
1945
2256
  };
1946
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).
1947
2265
  pinnedEditIndex = () => {
1948
2266
  if (this._editingRow.value >= 0) return this._editingRow.value;
1949
2267
  if (this._editingRowIndex.value != null) return this._editingRowIndex.value;
1950
2268
  return -1;
1951
2269
  };
1952
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.
1953
2275
  pinnedMeasurement = (pin: any) => {
1954
2276
  if (!this.virtualizer || pin < 0) return null;
1955
2277
  const ms = this.virtualizer.getMeasurements();
1956
2278
  return ms && ms[pin] ? ms[pin] : null;
1957
2279
  };
1958
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.
1959
2290
  remeasureWindow = () => {
1960
2291
  if (!this.virtualizer || !this.gridRoot) return;
1961
2292
  // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
@@ -1970,11 +2301,48 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1970
2301
  for (const tr of trs as any) this.virtualizer.measureElement(tr);
1971
2302
  };
1972
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).
1973
2335
  virtualItemKey = (i: any) => {
1974
2336
  const src = this.windowSource();
1975
2337
  return src && src[i] ? src[i].id : undefined;
1976
2338
  };
1977
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.
1978
2346
  virtualizerOptions = (): any => ({
1979
2347
  count: this.windowSource().length,
1980
2348
  getScrollElement: () => this.gridScrollEl,
@@ -2002,6 +2370,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2002
2370
  }
2003
2371
  });
2004
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.
2005
2382
  pinMeasurement = (pin: number): {
2006
2383
  start: number;
2007
2384
  size: number;
@@ -2009,6 +2386,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2009
2386
  end: number;
2010
2387
  } | null => this.pinnedMeasurement(pin);
2011
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).
2012
2394
  windowedRows = () => {
2013
2395
  // SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
2014
2396
  // early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
@@ -2079,6 +2461,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2079
2461
  return out;
2080
2462
  };
2081
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.
2082
2467
  padTop = () => {
2083
2468
  // SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
2084
2469
  // spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
@@ -2131,11 +2516,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2131
2516
  return pad < 0 ? 0 : pad;
2132
2517
  };
2133
2518
 
2519
+ // pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
2134
2520
  pmIndexInWindow = (items: any, idx: any) => {
2135
2521
  for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
2136
2522
  return false;
2137
2523
  };
2138
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).
2139
2527
  rowIsOutsideWindow = (r: any) => {
2140
2528
  if (!this.virtual || !this.virtualizer) return false;
2141
2529
  const items = this.virtualizer.getVirtualItems();
@@ -2143,6 +2531,19 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2143
2531
  return true;
2144
2532
  };
2145
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).
2146
2547
  announceState: {
2147
2548
  sorting: unknown;
2148
2549
  columnFilters: unknown;
@@ -2153,12 +2554,18 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2153
2554
  globalFilter: null
2154
2555
  };
2155
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()).
2156
2559
  effectiveSorting = () => this.sorting != null ? this.sorting : this._sortingDefault.value;
2157
2560
 
2158
2561
  effectiveColumnFilters = () => this.columnFilters != null ? this.columnFilters : this._columnFiltersDefault.value;
2159
2562
 
2160
2563
  effectiveGlobalFilter = () => this.globalFilter != null ? this.globalFilter : this._globalFilterDefault.value;
2161
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).
2162
2569
  buildSortFilterAnnounce = () => {
2163
2570
  const nextSorting = this.effectiveSorting();
2164
2571
  const nextColumnFilters = this.effectiveColumnFilters();
@@ -2181,6 +2588,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2181
2588
  return '';
2182
2589
  };
2183
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.
2184
2593
  reFeed = () => {
2185
2594
  if (!this.table) return;
2186
2595
  // NOTE: the external-swap history reset does NOT live here. reFeed() fires on EVERY watched
@@ -2246,6 +2655,28 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2246
2655
  if (this.refreshRowModel) this.refreshRowModel();
2247
2656
  };
2248
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.
2249
2680
  lastPropsData: unknown = null;
2250
2681
 
2251
2682
  maybeClearHistoryOnExternalSwap = () => {
@@ -2261,6 +2692,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2261
2692
 
2262
2693
  lastDataLen = -1;
2263
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.
2264
2697
  onHeaderSort = (colId: any, evt: any) => {
2265
2698
  if (!this.table) return;
2266
2699
  const col = this.table.getColumn(colId);
@@ -2270,8 +2703,20 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2270
2703
  col.toggleSorting(undefined, multi);
2271
2704
  };
2272
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.
2273
2716
  tick = () => this._rowModelVer.value;
2274
2717
 
2718
+ // the live sort direction off the table-core column (string-safe — never a bound
2719
+ // boolean, the listbox aria lesson).
2275
2720
  ariaSortFor = (colId: any) => {
2276
2721
  if (this.tick() < 0 || !this.table) return 'none';
2277
2722
  const col = this.table.getColumn(colId);
@@ -2282,6 +2727,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2282
2727
  return 'none';
2283
2728
  };
2284
2729
 
2730
+ // A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
2285
2731
  sortIndicator = (colId: any) => {
2286
2732
  if (this.tick() < 0 || !this.table) return '';
2287
2733
  const col = this.table.getColumn(colId);
@@ -2292,36 +2738,63 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2292
2738
  return '';
2293
2739
  };
2294
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).
2295
2743
  defFor = (colId: any) => {
2296
2744
  const defs = this.columnDefs();
2297
2745
  for (const d of defs as any) if (d.id === colId) return d;
2298
2746
  return null;
2299
2747
  };
2300
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.
2301
2758
  visibleCellsFor = (row: any) => this._rowModelVer.value >= 0 ? row.getVisibleCells() : [];
2302
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.
2303
2764
  editMetaOf = (colId: any) => {
2304
2765
  const d = this.defFor(colId);
2305
2766
  return d && d.meta ? d.meta : null;
2306
2767
  };
2307
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).
2308
2771
  columnEditable = (colId: any) => {
2309
2772
  const m = this.editMetaOf(colId);
2310
2773
  return !!(m && m.editable === true);
2311
2774
  };
2312
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'.
2313
2778
  editorTypeOf = (colId: any) => {
2314
2779
  const m = this.editMetaOf(colId);
2315
2780
  return m && m.editor != null ? m.editor : 'text';
2316
2781
  };
2317
2782
 
2783
+ // editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
2318
2784
  editorOptionsOf = (colId: any) => {
2319
2785
  const m = this.editMetaOf(colId);
2320
2786
  return m && m.editorOptions != null ? m.editorOptions : [];
2321
2787
  };
2322
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).
2323
2793
  hasEditorSlot = (colId: any) => this.editorTypeOf(colId) === 'custom' && !!(this._hasSlotEditor || this.editor !== undefined);
2324
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).
2325
2798
  hasFilterSlot = () => !!(this._hasSlotFilter || this.filter !== undefined);
2326
2799
 
2327
2800
  columnIsFilterable = (colId: any) => {
@@ -2334,6 +2807,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2334
2807
  return d ? d.header : colId;
2335
2808
  };
2336
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).
2337
2813
  headerWidth = (colId: any) => {
2338
2814
  if (this.tick() < 0 || !this.table) return null;
2339
2815
  const col = this.table.getColumn(colId);
@@ -2342,6 +2818,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2342
2818
  return w != null && w > 0 ? w + 'px' : null;
2343
2819
  };
2344
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).
2345
2826
  onResizeStart = (colId: any, evt: any) => {
2346
2827
  // stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
2347
2828
  if (evt && evt.stopPropagation) evt.stopPropagation();
@@ -2352,6 +2833,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2352
2833
  if (handler) handler(evt);
2353
2834
  };
2354
2835
 
2836
+ // Find the live header object for a column id across the rendered header groups.
2355
2837
  findHeader = (colId: any) => {
2356
2838
  const groups = this._headerGroups.value || [];
2357
2839
  for (const hg of groups as any) {
@@ -2367,6 +2849,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2367
2849
  return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
2368
2850
  };
2369
2851
 
2852
+ // Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
2853
+ // onColumnVisibilityChange funnel emits the fresh state.
2370
2854
  columnIsVisible = (colId: any) => {
2371
2855
  if (this.tick() < 0 || !this.table) return true;
2372
2856
  const col = this.table.getColumn(colId);
@@ -2379,6 +2863,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2379
2863
  if (col && col.toggleVisibility) col.toggleVisibility();
2380
2864
  };
2381
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.
2382
2871
  allLeafColumns = () => {
2383
2872
  if (this.tick() < 0 || !this.table) return [];
2384
2873
  const cols = this.table.getAllLeafColumns ? this.table.getAllLeafColumns() : [];
@@ -2394,6 +2883,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2394
2883
  return out;
2395
2884
  };
2396
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).
2397
2889
  columnPinSide = (colId: any) => {
2398
2890
  if (this.tick() < 0 || !this.table) return false;
2399
2891
  const col = this.table.getColumn(colId);
@@ -2401,6 +2893,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2401
2893
  return col.getIsPinned();
2402
2894
  };
2403
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.
2404
2902
  onPinColumn = (colId: any, side: any, evt: any) => {
2405
2903
  if (evt && evt.stopPropagation) evt.stopPropagation();
2406
2904
  if (!this.table) return;
@@ -2408,6 +2906,22 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2408
2906
  if (col && col.pin) col.pin(side);
2409
2907
  };
2410
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.
2411
2925
  pinStyle = (colId: any, zIndex = 1) => {
2412
2926
  if (this.tick() < 0 || !this.table) return '';
2413
2927
  const col = this.table.getColumn(colId);
@@ -2424,6 +2938,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2424
2938
  return '';
2425
2939
  };
2426
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).
2427
2945
  thStyle = (colId: any) => {
2428
2946
  let s = '';
2429
2947
  const w = this.headerWidth(colId);
@@ -2432,6 +2950,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2432
2950
  return s;
2433
2951
  };
2434
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).
2435
2957
  onGlobalFilterInput = (evt: any) => {
2436
2958
  const value = evt && evt.target ? evt.target.value : '';
2437
2959
  if (this.table) {
@@ -2441,16 +2963,21 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2441
2963
  this.writeGlobalFilter(value);
2442
2964
  };
2443
2965
 
2966
+ // Per-column filter input → setColumnFilter (fresh-array funnel).
2444
2967
  onColumnFilterInput = (colId: any, evt: any) => {
2445
2968
  const value = evt && evt.target ? evt.target.value : '';
2446
2969
  this.setColumnFilter(colId, value);
2447
2970
  };
2448
2971
 
2972
+ // The live global filter value (bound to the search <input>, value-driven NOT eval'd).
2449
2973
  globalFilterValue = () => {
2450
2974
  const v = this.currentState().globalFilter;
2451
2975
  return v != null ? v : '';
2452
2976
  };
2453
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).
2454
2981
  pageIndex = () => {
2455
2982
  if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageIndex;
2456
2983
  const p = this.currentState().pagination;
@@ -2463,6 +2990,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2463
2990
  return p && p.pageSize != null ? p.pageSize : 10;
2464
2991
  };
2465
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.
2466
2998
  displayPageCount = () => {
2467
2999
  if (this.tick() < 0 || !this.table) return 1;
2468
3000
  const c = this.table.getPageCount();
@@ -2488,16 +3020,44 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2488
3020
  this.table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
2489
3021
  };
2490
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).
2491
3026
  isSelectColumn = (colId: any) => colId === this.SELECT_COL_ID;
2492
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).
2493
3035
  isExpanderColumn = (colId: any) => colId === this.EXPANDER_COL_ID;
2494
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.
2495
3043
  rowCanExpand = (row: any) => !!(this.tick() >= 0 && row && row.getCanExpand && row.getCanExpand() && !(row.getIsGrouped && row.getIsGrouped()));
2496
3044
 
2497
3045
  rowIsExpanded = (row: any) => !!(this.tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
2498
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).
2499
3055
  rowShowsDetail = (row: any) => this.getSubRows == null && !this.rowIsGrouped(row) && this.rowIsExpanded(row);
2500
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).
2501
3061
  onToggleExpand = (row: any, evt: any) => {
2502
3062
  if (!row || !row.toggleExpanded) return;
2503
3063
  // Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
@@ -2521,6 +3081,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2521
3081
  }
2522
3082
  };
2523
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).
2524
3088
  bodyCellStyle = (row: any, colId: any) => {
2525
3089
  const base = this.pinStyle(colId);
2526
3090
  if (this.isExpanderColumn(colId) && row && row.depth) {
@@ -2531,20 +3095,51 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2531
3095
  return base;
2532
3096
  };
2533
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.
2534
3106
  rowIsGrouped = (row: any) => !!(this.tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
2535
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).
2536
3110
  groupingActive = () => this.tick() >= 0 && (this.currentState().grouping || []).length > 0;
2537
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.
2538
3116
  cellIsGrouped = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
2539
3117
 
2540
3118
  cellIsAggregated = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
2541
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).
2542
3125
  cellIsPlaceholder = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsPlaceholder && cellCtx.getIsPlaceholder());
2543
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).
2544
3133
  groupSubRowCount = (row: any) => row && row.getLeafRows ? row.getLeafRows().length : row && row.subRows ? row.subRows.length : 0;
2545
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.
2546
3138
  groupingKeys = () => this.currentState().grouping || [];
2547
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.
2548
3143
  groupableColumns = () => {
2549
3144
  const out = [];
2550
3145
  const defs = this.columnDefs();
@@ -2558,10 +3153,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2558
3153
  return out;
2559
3154
  };
2560
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.
2561
3159
  stopEvent = (evt: any) => {
2562
3160
  if (evt && evt.stopPropagation) evt.stopPropagation();
2563
3161
  };
2564
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).
2565
3165
  isAllRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsAllRowsSelected());
2566
3166
 
2567
3167
  isSomeRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsSomeRowsSelected());
@@ -2571,6 +3171,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2571
3171
  this.table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
2572
3172
  };
2573
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.
2574
3182
  rowIsSelected = (row: any) => {
2575
3183
  if (!row) return false;
2576
3184
  const id = row.id;
@@ -2584,6 +3192,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2584
3192
  row.toggleSelected(!!(evt && evt.target && evt.target.checked));
2585
3193
  };
2586
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).
2587
3201
  onHideColumn = (colId: any, evt: any) => {
2588
3202
  if (evt && evt.stopPropagation) evt.stopPropagation();
2589
3203
  if (!this.table) return;
@@ -2591,6 +3205,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2591
3205
  if (col && col.toggleVisibility) col.toggleVisibility(false);
2592
3206
  };
2593
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.
2594
3211
  hasAnyFilterableColumn = () => {
2595
3212
  const cols = this.allLeafColumns();
2596
3213
  for (const c of cols as any) {
@@ -2599,6 +3216,18 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2599
3216
  return false;
2600
3217
  };
2601
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(...)`.
2602
3231
  selectAllBox: any = null;
2603
3232
 
2604
3233
  syncIndeterminate = () => {
@@ -2607,6 +3236,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2607
3236
  if (this.selectAllBox) this.selectAllBox.indeterminate = this.isSomeRowsSelected() && !this.isAllRowsSelected();
2608
3237
  };
2609
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.
2610
3246
  sortColumn = (colId: any, desc: any) => {
2611
3247
  if (this.table) this.table.getColumn(colId) && this.table.getColumn(colId).toggleSorting(desc, false);
2612
3248
  };
@@ -2617,6 +3253,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2617
3253
 
2618
3254
  getColumnDefs = () => this.columnDefs();
2619
3255
 
3256
+ // selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
3257
+ // emits the fresh state + selection-change.
2620
3258
  toggleAllRows = (value: any) => {
2621
3259
  if (this.table) this.table.toggleAllRowsSelected(value);
2622
3260
  };
@@ -2627,6 +3265,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2627
3265
 
2628
3266
  getSelectedRows = () => this.table ? this.table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
2629
3267
 
3268
+ // pagination verbs.
2630
3269
  setPage = (idx: any) => {
2631
3270
  if (this.table) this.table.setPageIndex(idx);
2632
3271
  };
@@ -2635,6 +3274,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2635
3274
  if (this.table) this.table.setPageSize(size);
2636
3275
  };
2637
3276
 
3277
+ // column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
2638
3278
  toggleColumnVisibility = (colId: any) => {
2639
3279
  if (this.table) {
2640
3280
  const c = this.table.getColumn(colId);
@@ -2642,6 +3282,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2642
3282
  }
2643
3283
  };
2644
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.
2645
3292
  applyColumnOrder = (order: any) => {
2646
3293
  if (this.table) this.table.setColumnOrder(order);
2647
3294
  };
@@ -2650,6 +3297,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2650
3297
  if (this.table) this.table.resetColumnSizing(true);
2651
3298
  };
2652
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).
2653
3302
  pinColumn = (colId: any, side: any) => {
2654
3303
  if (this.table) {
2655
3304
  const c = this.table.getColumn(colId);
@@ -2657,39 +3306,101 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2657
3306
  }
2658
3307
  };
2659
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).
2660
3316
  getRowIndexRelativeToPage = (absRow: any) => {
2661
3317
  const abs = absRow == null ? this.toAbsRow(this._activeRow.value) : Math.trunc(Number(absRow)) || 0;
2662
3318
  if (this.virtual) return abs;
2663
3319
  return abs - this.pageRowOffset();
2664
3320
  };
2665
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.
2666
3328
  cut = () => this.cutRange();
2667
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).
2668
3344
  isGrid = () => this.interactionMode === 'grid';
2669
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.
2670
3349
  tableRole = () => this.isGrid() ? 'grid' : 'table';
2671
3350
 
2672
3351
  cellRole = () => this.isGrid() ? 'gridcell' : 'cell';
2673
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.
2674
3357
  rowIndexOf = (row: any) => this.tick() >= 0 ? (this._rows.value || []).indexOf(row) : -1;
2675
3358
 
3359
+ // colIndexOf: a body cell's position in its row's visible cell list.
2676
3360
  colIndexOf = (row: any, cellCtx: any) => this.tick() >= 0 ? this.visibleCellsFor(row).indexOf(cellCtx) : -1;
2677
3361
 
3362
+ // headerColIndexOf: a header cell's position in its header group's leaf headers.
2678
3363
  headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);
2679
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.
2680
3376
  pageRowOffset = () => {
2681
3377
  if (!this.isGrid() || this.virtual) return 0;
2682
3378
  return this.pageIndex() * this.pageSize();
2683
3379
  };
2684
3380
 
3381
+ // page-relative active row → absolute (display-order) index.
2685
3382
  toAbsRow = (localRow: any) => localRow + this.pageRowOffset();
2686
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.
2687
3390
  prePaginationRowCount = () => {
2688
3391
  if (!this.table || this.virtual) return this.bodyRowCount();
2689
3392
  const pm = this.table.getPrePaginationRowModel();
2690
3393
  return pm && pm.rows ? pm.rows.length : this.bodyRowCount();
2691
3394
  };
2692
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.
2693
3404
  cellTabindex = (rowKey: any, colIndex: any, level = null) => {
2694
3405
  if (!this.isGrid()) return null;
2695
3406
  // B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
@@ -2710,6 +3421,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2710
3421
  return isActive ? 0 : -1;
2711
3422
  };
2712
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.
2713
3435
  isActiveCell = (rowKey: any, colIndex: any, level = null) => {
2714
3436
  if (!this.isGrid()) return false;
2715
3437
  if (this._activeIsHeader.value) {
@@ -2720,6 +3442,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2720
3442
  return rowKey === String(this._activeRow.value) && colIndex === this._activeColIndex.value;
2721
3443
  };
2722
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).
2723
3450
  resolveCellEl = (rowKey: any, colIndex: any, level = null) => {
2724
3451
  if (!this.gridRoot) return null;
2725
3452
  // B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
@@ -2733,6 +3460,19 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2733
3460
  return this.gridRoot.querySelector(sel);
2734
3461
  };
2735
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.
2736
3476
  focusActiveCell = (nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) => {
2737
3477
  if (!this.isGrid() || !this.gridRoot) return;
2738
3478
  // #9 focus-intent epoch: focusActiveCell is THE single seam every keyboard nav re-asserts
@@ -2798,20 +3538,59 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2798
3538
  if (el) el.focus();
2799
3539
  };
2800
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).
2801
3556
  totalRowCount = () => {
2802
3557
  if (!this.table) return (this._rows.value || []).length;
2803
3558
  const fm = this.table.getFilteredRowModel();
2804
3559
  return fm && fm.rows ? fm.rows.length : (this._rows.value || []).length;
2805
3560
  };
2806
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).
2807
3575
  headerRowCount = () => (this._headerGroups.value || []).length;
2808
3576
 
2809
3577
  gridAriaRowCount = () => this.headerRowCount() + this.totalRowCount();
2810
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).
2811
3583
  ariaPageOffset = () => this.table ? this.pageIndex() * this.pageSize() : 0;
2812
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).
2813
3589
  bodyAriaRowIndex = (row: any) => this.headerRowCount() + this.rowIndexOf(row) + this.ariaPageOffset() + 1;
2814
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.
2815
3594
  visibleColCount = () => {
2816
3595
  // NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
2817
3596
  // state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
@@ -2826,11 +3605,24 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2826
3605
 
2827
3606
  clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
2828
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.
2829
3614
  headerLeafLevel = () => {
2830
3615
  const hg = this._headerGroups.value || [];
2831
3616
  return hg.length ? hg.length - 1 : 0;
2832
3617
  };
2833
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.
2834
3626
  headerCountAtLevel = (level: any) => {
2835
3627
  const hg = this._headerGroups.value || [];
2836
3628
  if (!hg.length) return this.visibleColCount();
@@ -2846,6 +3638,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2846
3638
  return grp.headers[colIndex] || null;
2847
3639
  };
2848
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.
2849
3645
  parentHeaderColIndex = (level: any, colIndex: any) => {
2850
3646
  if (level <= 0) return -1;
2851
3647
  const h = this.headerAt(level, colIndex);
@@ -2861,6 +3657,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2861
3657
  return -1;
2862
3658
  };
2863
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.
2864
3663
  firstChildHeaderColIndex = (level: any, colIndex: any) => {
2865
3664
  const h = this.headerAt(level, colIndex);
2866
3665
  if (!h || !h.column) return -1;
@@ -2877,6 +3676,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2877
3676
  return -1;
2878
3677
  };
2879
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).
2880
3686
  moveCol = (delta: any) => {
2881
3687
  // #10: when a grouped PARENT header is active, clamp against the header count AT THE ACTIVE
2882
3688
  // LEVEL (which may be fewer than the leaf-column count) so ArrowRight never overruns onto a
@@ -2888,6 +3694,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2888
3694
  return nextCol;
2889
3695
  };
2890
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).
2891
3702
  moveRow = (delta: any) => {
2892
3703
  const lastRow = this.bodyRowCount() - 1;
2893
3704
  const maxRow = lastRow < 0 ? 0 : lastRow;
@@ -2979,6 +3790,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2979
3790
  };
2980
3791
  };
2981
3792
 
3793
+ // Home/End within the current row → col 0 / max. Returns the fresh colIndex.
2982
3794
  gotoColEdge = (toEnd: any) => {
2983
3795
  // #10: End on a grouped PARENT header lands on that level's LAST header (headerCountAtLevel-1),
2984
3796
  // not the leaf-column max — otherwise the ring strands on a phantom cell past the level's
@@ -2990,6 +3802,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2990
3802
  return nextCol;
2991
3803
  };
2992
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.
2993
3809
  gotoRowEdge = (toEnd: any) => {
2994
3810
  const lastRow = this.bodyRowCount() - 1;
2995
3811
  const nextRow = toEnd ? lastRow < 0 ? 0 : lastRow : 0;
@@ -2998,6 +3814,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
2998
3814
  return nextRow;
2999
3815
  };
3000
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).
3001
3819
  gotoStart = () => {
3002
3820
  this._activeIsHeader.value = false;
3003
3821
  this._activeRow.value = 0;
@@ -3022,17 +3840,23 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3022
3840
  };
3023
3841
  };
3024
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.
3025
3845
  currentCellEl = () => {
3026
3846
  const rowKey = this._activeIsHeader.value ? '__header' : String(this._activeRow.value);
3027
3847
  return this.resolveCellEl(rowKey, this._activeColIndex.value, this._activeIsHeader.value ? this._activeHeaderLevel.value : null);
3028
3848
  };
3029
3849
 
3850
+ // The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
3030
3851
  focusables = (cellEl: any) => {
3031
3852
  if (!cellEl || !cellEl.querySelectorAll) return [];
3032
3853
  const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
3033
3854
  return list.filter((n: any) => !n.disabled);
3034
3855
  };
3035
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.
3036
3860
  enterControl = () => {
3037
3861
  const cellEl = this.currentCellEl();
3038
3862
  const list = this.focusables(cellEl);
@@ -3041,6 +3865,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3041
3865
  list[0].focus();
3042
3866
  };
3043
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.
3044
3872
  cycleWithinCell = (cellEl: any, forward: any) => {
3045
3873
  const list = this.focusables(cellEl);
3046
3874
  if (!list.length) return;
@@ -3052,6 +3880,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3052
3880
  list[i].focus();
3053
3881
  };
3054
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).
3055
3888
  onGridKeyDown = (e: any) => {
3056
3889
  if (!this.isGrid() || !e) return;
3057
3890
  const key = e.key;
@@ -3389,6 +4222,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3389
4222
  }
3390
4223
  };
3391
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).
3392
4234
  syncActiveFromEvent = (e: any) => {
3393
4235
  if (!this.isGrid() || !e) return;
3394
4236
  const tgt = e.target;
@@ -3452,6 +4294,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3452
4294
  if (tgt === cellEl) this._activeInControl.value = false;
3453
4295
  };
3454
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).
3455
4306
  onGridMouseDown = (e: any) => {
3456
4307
  if (!this.isGrid() || !e) return;
3457
4308
  const tgt = e.target;
@@ -3484,6 +4335,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3484
4335
  this.beginRangeDrag(row, col);
3485
4336
  };
3486
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.
3487
4346
  onGridDblClick = (e: any) => {
3488
4347
  if (!this.isGrid() || !e) return;
3489
4348
  const tgt = e.target;
@@ -3522,6 +4381,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3522
4381
  }
3523
4382
  };
3524
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.
3525
4391
  onGridClick = (e: any) => {
3526
4392
  if (!this.isGrid() || !e) return;
3527
4393
  if (!this.singleClickEdit) return;
@@ -3549,6 +4415,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3549
4415
  if (colId != null && this.columnEditable(colId)) this.beginEdit(row, col, null);
3550
4416
  };
3551
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.
3552
4426
  onGridFocusOut = (e: any) => {
3553
4427
  if (!this.isGrid() || !this._activeInControl.value) return;
3554
4428
  const next = e ? e.relatedTarget : null;
@@ -3556,6 +4430,20 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3556
4430
  if (!cellEl || !next || !cellEl.contains(next)) this._activeInControl.value = false;
3557
4431
  };
3558
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.
3559
4447
  recoverGridFocus = (rowKey: any, col: any, level: any, guardMoved = false) => {
3560
4448
  if (!this.gridRoot) return;
3561
4449
  let attempts = 0;
@@ -3580,6 +4468,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3580
4468
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
3581
4469
  };
3582
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).
3583
4475
  clampActiveCell = (rowCount: any, colCount: any) => {
3584
4476
  if (!this.isGrid()) return;
3585
4477
  // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
@@ -3675,12 +4567,50 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3675
4567
  }
3676
4568
  };
3677
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.
3678
4582
  gridEmptyFallback = false;
3679
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.
3680
4599
  rangeTransition = false;
3681
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.
3682
4604
  rangeClickPending = false;
3683
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.
3684
4614
  rangeActive = false;
3685
4615
 
3686
4616
  inRange = (rIdx: any, cIdx: any) => {
@@ -3694,6 +4624,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3694
4624
  return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
3695
4625
  };
3696
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).
3697
4631
  getSelectedRange = () => {
3698
4632
  // B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
3699
4633
  // payload) never reports a corner past a shrunken model — React-stale-safe (the eager
@@ -3720,6 +4654,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3720
4654
  };
3721
4655
  };
3722
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).
3723
4660
  isFillHandleCell = (rIdx: any, cIdx: any) => {
3724
4661
  const a = this._rangeAnchor.value;
3725
4662
  const f = this._rangeFocus.value;
@@ -3729,6 +4666,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3729
4666
  return rIdx === r1 && cIdx === c1;
3730
4667
  };
3731
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.
3732
4675
  emitRangeChange = (anchor: any, focus: any) => {
3733
4676
  this.dispatchEvent(new CustomEvent("range-change", {
3734
4677
  detail: {
@@ -3740,6 +4683,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3740
4683
  }));
3741
4684
  };
3742
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.
3743
4690
  extendRange = (dRow: any, dCol: any) => {
3744
4691
  if (this._activeIsHeader.value) return;
3745
4692
  const maxRow = this.bodyRowCount() - 1;
@@ -3785,6 +4732,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3785
4732
  }
3786
4733
  };
3787
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.
3788
4738
  setRangeFocus = (rIdx: any, cIdx: any) => {
3789
4739
  const maxRow = this.bodyRowCount() - 1;
3790
4740
  const maxCol = this.visibleColCount() - 1;
@@ -3806,6 +4756,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3806
4756
  this.emitRangeChange(anchor, nextFocus);
3807
4757
  };
3808
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.
3809
4765
  selectAllBody = () => {
3810
4766
  const maxRow = this.bodyRowCount() - 1;
3811
4767
  const maxCol = this.visibleColCount() - 1;
@@ -3824,6 +4780,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3824
4780
  this.emitRangeChange(anchor, focus);
3825
4781
  };
3826
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).
3827
4789
  clearRange = () => {
3828
4790
  // B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
3829
4791
  // in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
@@ -3837,6 +4799,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3837
4799
  this.emitRangeChange(null, null);
3838
4800
  };
3839
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.
3840
4810
  clampRange = (maxRowArg: any, maxColArg: any) => {
3841
4811
  const a = this._rangeAnchor.value;
3842
4812
  const f = this._rangeFocus.value;
@@ -3869,17 +4839,42 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3869
4839
  }
3870
4840
  };
3871
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.
3872
4852
  announce = (msg: any) => {
3873
4853
  this._pasteAnnounce.value = msg != null ? msg : '';
3874
4854
  };
3875
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.
3876
4862
  clipboardActiveAllowed = () => !this._activeIsHeader.value;
3877
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.
3878
4866
  fieldOfColId = (colId: any) => {
3879
4867
  const d = this.defFor(colId);
3880
4868
  return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
3881
4869
  };
3882
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.
3883
4878
  normalizedRange = () => {
3884
4879
  const a = this._rangeAnchor.value;
3885
4880
  const f = this._rangeFocus.value;
@@ -3899,6 +4894,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3899
4894
  };
3900
4895
  };
3901
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).
3902
4901
  escapeTsvField = (s: any) => {
3903
4902
  if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
3904
4903
  return '"' + s.replace(/"/g, '""') + '"';
@@ -3906,6 +4905,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3906
4905
  return s;
3907
4906
  };
3908
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.
3909
4911
  rangeToTsv = () => {
3910
4912
  const box = this.normalizedRange();
3911
4913
  const r0 = box ? box.r0 : this._activeRow.value;
@@ -3924,6 +4926,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3924
4926
  return lines.join('\n');
3925
4927
  };
3926
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).
3927
4932
  parseTsv = (text: any) => {
3928
4933
  const str = text != null ? String(text) : '';
3929
4934
  // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
@@ -3999,6 +5004,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
3999
5004
  return rows;
4000
5005
  };
4001
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.
4002
5009
  copyRange = () => {
4003
5010
  // B11: never copy from a header-active state (the reusable clipboard guard).
4004
5011
  if (!this.clipboardActiveAllowed()) return;
@@ -4009,6 +5016,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4009
5016
  } catch (err: any) {/* best-effort copy */}
4010
5017
  };
4011
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 }.
4012
5027
  applyGridToRange = (grid: any, originRow: any, originCol: any) => {
4013
5028
  const maxRow = this.bodyRowCount() - 1;
4014
5029
  const maxCol = this.visibleColCount() - 1;
@@ -4073,6 +5088,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4073
5088
  };
4074
5089
  };
4075
5090
 
5091
+ // rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
4076
5092
  rowOriginalAt = (rowIndex: any) => {
4077
5093
  const rowList = this._rows.value || [];
4078
5094
  const row = rowList[rowIndex];
@@ -4085,6 +5101,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4085
5101
  return row ? row.id : null;
4086
5102
  };
4087
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).
4088
5112
  tileGridToBox = (grid: any, box: any) => {
4089
5113
  const srcRows = grid.length;
4090
5114
  // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
@@ -4114,6 +5138,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4114
5138
  return out;
4115
5139
  };
4116
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.
4117
5144
  pasteRange = () => {
4118
5145
  // B11: never paste into a header-active state (the reusable clipboard guard) — a header
4119
5146
  // anchor would silently write body row 0 at the header's column.
@@ -4151,6 +5178,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4151
5178
  }).catch(() => {});
4152
5179
  };
4153
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.
4154
5190
  cutRange = () => {
4155
5191
  if (!this.clipboardActiveAllowed()) return;
4156
5192
  // Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
@@ -4176,6 +5212,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4176
5212
  this.applyGridToRange(grid, r0, c0);
4177
5213
  };
4178
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).
4179
5224
  clearActiveRange = () => {
4180
5225
  if (!this.clipboardActiveAllowed()) return;
4181
5226
  // Snapshot the source rectangle synchronously (the ROZ138 concern cutRange/pasteRange share).
@@ -4193,6 +5238,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4193
5238
  this.applyGridToRange(grid, r0, c0);
4194
5239
  };
4195
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.
4196
5245
  tileIndex = (i: any, lo: any, hi: any) => {
4197
5246
  const span = hi - lo + 1;
4198
5247
  if (span <= 1) return lo;
@@ -4201,6 +5250,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4201
5250
  return lo + k;
4202
5251
  };
4203
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.
4204
5262
  fillRange = (sourceBox: any, endCell: any) => {
4205
5263
  // B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
4206
5264
  // the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
@@ -4247,8 +5305,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4247
5305
  this.applyGridToRange(grid, box.r0, box.c0);
4248
5306
  };
4249
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.
4250
5312
  fillDragging = false;
4251
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).
4252
5317
  fillDragMove: any = null;
4253
5318
 
4254
5319
  fillDragUp: any = null;
@@ -4346,6 +5411,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4346
5411
  }
4347
5412
  };
4348
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.
4349
5420
  rangeDragging = false;
4350
5421
 
4351
5422
  rangeDragMove: any = null;
@@ -4354,6 +5425,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4354
5425
 
4355
5426
  rangeDragMoved = false;
4356
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.
4357
5439
  teardownRangeDrag = () => {
4358
5440
  if (typeof document !== 'undefined') {
4359
5441
  if (this.rangeDragMove) document.removeEventListener('pointermove', this.rangeDragMove);
@@ -4364,6 +5446,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4364
5446
  this.rangeDragging = false;
4365
5447
  };
4366
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).
4367
5457
  beginRangeDrag = (anchorR: any, anchorC: any) => {
4368
5458
  // #leak: tear down any orphaned PRIOR range gesture BEFORE reassigning the module-let handlers.
4369
5459
  // A missed pointerup (off-window release, context menu, alt-tab) leaves the prior drag's document
@@ -4400,6 +5490,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4400
5490
  }
4401
5491
  };
4402
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).
4403
5501
  activeCellColumnId = () => {
4404
5502
  if (this._activeIsHeader.value) return null;
4405
5503
  const rowList = this._rows.value || [];
@@ -4410,11 +5508,25 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4410
5508
  return cell && cell.column ? cell.column.id : null;
4411
5509
  };
4412
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.
4413
5514
  isActiveCellEditable = () => {
4414
5515
  const colId = this.activeCellColumnId();
4415
5516
  return colId != null && this.columnEditable(colId);
4416
5517
  };
4417
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).
4418
5530
  isEditing = (rowIndex: any, colIndex: any) => {
4419
5531
  if (this._editVer.value < 0) return false;
4420
5532
  if (this._editingRowIndex.value != null && this._editingRowIndex.value === rowIndex) {
@@ -4424,8 +5536,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4424
5536
  return this._editingRow.value === rowIndex && this._editingCol.value === colIndex;
4425
5537
  };
4426
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.
4427
5544
  cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => this.isEditing(rowIndex, colIndex) && !!this._invalidMsg.value ? 'true' : null;
4428
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).
4429
5550
  runValidator = (colId: any, value: any, row: any) => {
4430
5551
  const m = this.editMetaOf(colId);
4431
5552
  const v = m ? m.validate : null;
@@ -4441,10 +5562,17 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4441
5562
  return 'Invalid value';
4442
5563
  };
4443
5564
 
5565
+ // setInvalid: record the current validation error (drives the aria-live region +
5566
+ // :aria-invalid wired in Task 3). Empty string clears it.
4444
5567
  setInvalid = (msg: any) => {
4445
5568
  this._invalidMsg.value = msg != null ? msg : '';
4446
5569
  };
4447
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).
4448
5576
  replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
4449
5577
  const src = rows || [];
4450
5578
  const out = [];
@@ -4463,6 +5591,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4463
5591
  return out;
4464
5592
  };
4465
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.
4466
5597
  sourceIndexOfRow = (visibleRowIndex: any) => {
4467
5598
  const rowList = this._rows.value || [];
4468
5599
  const row = rowList[visibleRowIndex];
@@ -4473,6 +5604,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4473
5604
  return idx >= 0 ? idx : visibleRowIndex;
4474
5605
  };
4475
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.
4476
5611
  editingColumnId = () => {
4477
5612
  const rowList = this._rows.value || [];
4478
5613
  const row = rowList[this._editingRow.value];
@@ -4510,6 +5645,24 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4510
5645
  return row ? row.id : null;
4511
5646
  };
4512
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.
4513
5666
  focusEditorWhenReady = (selectAll = true) => {
4514
5667
  if (!this.gridRoot) return;
4515
5668
  // Editor-owns-focus contract: when the CURRENT focus target is a #editor drop-in, the host
@@ -4549,6 +5702,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4549
5702
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
4550
5703
  };
4551
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).
4552
5708
  columnIdAt = (rowIndex: any, colIndex: any) => {
4553
5709
  const rowList = this._rows.value || [];
4554
5710
  const row = rowList[rowIndex];
@@ -4567,6 +5723,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4567
5723
  return cell ? cell.getValue() : null;
4568
5724
  };
4569
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.
4570
5731
  beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
4571
5732
  const colId = this.columnIdAt(rowIndex, colIndex);
4572
5733
  if (colId == null || !this.columnEditable(colId)) return;
@@ -4592,6 +5753,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4592
5753
  this.focusEditorWhenReady(seed == null);
4593
5754
  };
4594
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.
4595
5762
  focusCellWhenReady = (row: any, col: any) => {
4596
5763
  if (!this.gridRoot) return;
4597
5764
  let attempts = 0;
@@ -4608,6 +5775,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4608
5775
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
4609
5776
  };
4610
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).
4611
5784
  indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
4612
5785
  const list = rows || [];
4613
5786
  for (let i = 0; i < list.length; i++) {
@@ -4619,6 +5792,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4619
5792
  return -1;
4620
5793
  };
4621
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).
4622
5798
  endEdit = () => {
4623
5799
  this._editingRow.value = -1;
4624
5800
  this._editingCol.value = -1;
@@ -4629,6 +5805,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4629
5805
  this._editFocusColId.value = null;
4630
5806
  };
4631
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.
4632
5811
  endRowEdit = () => {
4633
5812
  this._editingRowIndex.value = null;
4634
5813
  this._rowDraft.value = {};
@@ -4638,6 +5817,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4638
5817
  this._editFocusColId.value = null;
4639
5818
  };
4640
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.
4641
5826
  editorAutofocusFor = (colId: any, rowIndex: any) => {
4642
5827
  if (this._editVer.value < 0) return false;
4643
5828
  if (this._editingRowIndex.value != null) {
@@ -4648,6 +5833,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4648
5833
  return this._editFocusColId.value != null && this._editFocusColId.value === colId;
4649
5834
  };
4650
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).
4651
5841
  coerceCellValue = (colId: any, raw: any) => {
4652
5842
  if (this.editorTypeOf(colId) !== 'number') return raw;
4653
5843
  if (raw == null) return null;
@@ -4658,6 +5848,16 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4658
5848
  return Number.isNaN(n) ? null : n;
4659
5849
  };
4660
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).
4661
5861
  commitEdit = (overrideValue = undefined, skipFocusReturn = false) => {
4662
5862
  if (this._editingRow.value < 0) return false;
4663
5863
  // Sync idempotency latch (drop-in double cell-edit-commit fix): a second commitEdit call
@@ -4748,6 +5948,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4748
5948
  return true;
4749
5949
  };
4750
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).
4751
5958
  toggleActiveBooleanCell = () => {
4752
5959
  const colId = this.columnIdAt(this._activeRow.value, this._activeColIndex.value);
4753
5960
  if (colId == null || !this.columnEditable(colId)) return;
@@ -4795,6 +6002,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4795
6002
  };
4796
6003
  };
4797
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.
4798
6007
  cancelEdit = () => {
4799
6008
  if (this._editingRow.value < 0) return;
4800
6009
  // CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
@@ -4809,6 +6018,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4809
6018
  this.focusCellWhenReady(focusRow, focusCol);
4810
6019
  };
4811
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).
4812
6029
  editableColumnsForRow = (rowIndex: any) => {
4813
6030
  const rowList = this._rows.value || [];
4814
6031
  const row = rowList[rowIndex];
@@ -4833,6 +6050,16 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4833
6050
  return out;
4834
6051
  };
4835
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.
4836
6063
  focusRowEditorAt = (rowIndex: any, colIndex: any) => {
4837
6064
  if (!this.gridRoot) return;
4838
6065
  const colId = this.columnIdAt(rowIndex, colIndex);
@@ -4857,6 +6084,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4857
6084
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
4858
6085
  };
4859
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.
4860
6093
  beginRowEdit = (row: any) => {
4861
6094
  const rowIndex = this.rowIndexOf(row);
4862
6095
  if (rowIndex < 0) return;
@@ -4890,6 +6123,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
4890
6123
  this.focusEditorWhenReady();
4891
6124
  };
4892
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.
4893
6131
  commitRow = () => {
4894
6132
  if (this._editingRowIndex.value == null) return false;
4895
6133
  const rowIndex = this._editingRowIndex.value;
@@ -5000,6 +6238,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5000
6238
  return true;
5001
6239
  };
5002
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.
5003
6243
  cancelRow = () => {
5004
6244
  if (this._editingRowIndex.value == null) return;
5005
6245
  const focusRow = this._activeRow.value;
@@ -5010,6 +6250,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5010
6250
  this.focusCellWhenReady(focusRow, focusCol);
5011
6251
  };
5012
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).
5013
6255
  replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
5014
6256
  const src = rows || [];
5015
6257
  const fv = fieldValues || {};
@@ -5029,6 +6271,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5029
6271
  return out;
5030
6272
  };
5031
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.
5032
6278
  nextEditableCell = (fromRow: any, fromCol: any) => {
5033
6279
  const rowList = this._rows.value || [];
5034
6280
  const rowCount = rowList.length;
@@ -5053,6 +6299,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5053
6299
  return null;
5054
6300
  };
5055
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.
5056
6306
  prevEditableCell = (fromRow: any, fromCol: any) => {
5057
6307
  const rowList = this._rows.value || [];
5058
6308
  const rowCount = rowList.length;
@@ -5081,18 +6331,50 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5081
6331
  return null;
5082
6332
  };
5083
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).
5084
6338
  editTransition = false;
5085
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).
5086
6345
  pendingEditFollow: any = null;
5087
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).
5088
6358
  committedThisSession = false;
5089
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).
5090
6367
  inRowEdit = () => this._editingRowIndex.value != null;
5091
6368
 
5092
6369
  editorValueFor = (colId: any) => this.inRowEdit() ? this._rowDraft.value ? this._rowDraft.value[colId] : null : this._draftValue.value;
5093
6370
 
5094
6371
  editorCheckedFor = (colId: any) => !!(this.inRowEdit() ? this._rowDraft.value ? this._rowDraft.value[colId] : null : this._draftValue.value);
5095
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.
5096
6378
  editorCommitFor = (colId: any) => (value: any) => {
5097
6379
  if (this.inRowEdit()) {
5098
6380
  this.setRowDraft(colId, value);
@@ -5109,6 +6391,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5109
6391
  this.cancelEdit();
5110
6392
  };
5111
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.
5112
6399
  onCellEditorInput = (colId: any, evt: any) => {
5113
6400
  const v = evt && evt.target ? evt.target.value : '';
5114
6401
  if (this.inRowEdit()) {
@@ -5127,6 +6414,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5127
6414
  this._draftValue.value = v;
5128
6415
  };
5129
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).
5130
6419
  setRowDraft = (colId: any, value: any) => {
5131
6420
  const src = this._rowDraft.value || {};
5132
6421
  const next = {};
@@ -5135,6 +6424,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5135
6424
  this._rowDraft.value = next;
5136
6425
  };
5137
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.
5138
6431
  rowEditTab = (target: any, backward: any) => {
5139
6432
  const rowIndex = this._editingRowIndex.value;
5140
6433
  if (rowIndex == null) return;
@@ -5156,6 +6449,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5156
6449
  this.focusRowEditorAt(rowIndex, cols[nextPos]);
5157
6450
  };
5158
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.
5159
6455
  onEditorKeyDown = (e: any) => {
5160
6456
  if (!e) return;
5161
6457
  const key = e.key;
@@ -5214,6 +6510,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5214
6510
  }
5215
6511
  };
5216
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.
5217
6520
  onEditorBlur = (e: any) => {
5218
6521
  // Full-row mode (req-6): a blur that stays WITHIN the row editor — Tab/click between the
5219
6522
  // row's OWN fields — is a normal focus move and must NOT commit (a per-cell blur-commit
@@ -5292,6 +6595,9 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5292
6595
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
5293
6596
  };
5294
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.
5295
6601
  editCell = (rowIndex: any, colIndex: any) => {
5296
6602
  const lastRow = this.bodyRowCount() - 1;
5297
6603
  const maxRow = lastRow < 0 ? 0 : lastRow;
@@ -5306,6 +6612,11 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5306
6612
  this.beginEdit(r, c, null);
5307
6613
  };
5308
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.
5309
6620
  commitEditing = () => {
5310
6621
  if (this.inRowEdit()) {
5311
6622
  this.commitRow();
@@ -5314,6 +6625,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5314
6625
  if (this._editingRow.value >= 0) this.commitEdit(undefined);
5315
6626
  };
5316
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.
5317
6634
  editRow = (rowIndex: any) => {
5318
6635
  const lastRow = this.bodyRowCount() - 1;
5319
6636
  const maxRow = lastRow < 0 ? 0 : lastRow;
@@ -5326,6 +6643,23 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5326
6643
  this.beginRowEdit(row);
5327
6644
  };
5328
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.
5329
6663
  focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
5330
6664
  if (!this.gridRoot) return;
5331
6665
  let attempts = 0;
@@ -5353,6 +6687,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5353
6687
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
5354
6688
  };
5355
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).
5356
6697
  focusCell = (rowIndex: any, colIndex: any) => {
5357
6698
  // B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
5358
6699
  // is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
@@ -5420,6 +6761,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5420
6761
  }
5421
6762
  };
5422
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.
5423
6773
  getActiveCell = () => this._activeIsHeader.value ? {
5424
6774
  rowIndex: null,
5425
6775
  colIndex: this._activeColIndex.value,
@@ -5430,6 +6780,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5430
6780
  isHeader: false
5431
6781
  };
5432
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.
5433
6787
  clearActiveCell = () => {
5434
6788
  if (!this.isGrid()) return;
5435
6789
  this._activeIsHeader.value = false;
@@ -5438,6 +6792,15 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5438
6792
  this._activeColIndex.value = 0;
5439
6793
  };
5440
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.
5441
6804
  toggleRowExpanded = (rowId: any) => {
5442
6805
  if (!this.table) return;
5443
6806
  const target = String(rowId);
@@ -5450,16 +6813,23 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5450
6813
  }
5451
6814
  };
5452
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).
5453
6818
  expandAll = () => {
5454
6819
  if (!this.table) return;
5455
6820
  this.table.toggleAllRowsExpanded(true);
5456
6821
  };
5457
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.
5458
6825
  collapseAll = () => {
5459
6826
  if (!this.table) return;
5460
6827
  this.table.resetExpanded(true);
5461
6828
  };
5462
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.
5463
6833
  getExpandedRows = () => {
5464
6834
  if (!this.table) return [];
5465
6835
  const out = [];
@@ -5468,6 +6838,13 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5468
6838
  return out;
5469
6839
  };
5470
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.
5471
6848
  applyGrouping = (cols: any) => {
5472
6849
  if (this.table) this.table.setGrouping(cols);
5473
6850
  };
@@ -5476,6 +6853,20 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5476
6853
  if (this.table) this.table.setGrouping([]);
5477
6854
  };
5478
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).
5479
6870
  getFacetedUniqueValues = (colId: any) => {
5480
6871
  if (this.tick() < 0 || !this.table) return [];
5481
6872
  const col = this.table.getColumn(colId);
@@ -5484,6 +6875,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
5484
6875
  return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
5485
6876
  };
5486
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).
5487
6880
  getFacetedMinMaxValues = (colId: any) => {
5488
6881
  if (this.tick() < 0 || !this.table) return null;
5489
6882
  const col = this.table.getColumn(colId);