flexdesk 0.4.5 → 0.4.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.
@@ -65,8 +65,16 @@ const AUTOSIZE_MAX_PX = 520;
65
65
  * @property {Object} [host] - A Host (see ui/js/host/host.js). Its `dialogs` capability backs `downloadCSV()`'s native save dialog; without it CSV export falls back to a Blob download.
66
66
  * @property {Object} [stateStore] - `{ ready(), get(key), set(key, state) }` — REQUIRED when `persistKey` is set. Built by `createTableStateStore()`.
67
67
  * @property {'container'|'content'} [columnFit='container'] - How the automatic fit pass sizes a column the user has NOT dragged. 'container' squeezes every column into the wrap: a 360px cap, a more generous one for the first column, and water-fill-shrink toward the 40px floor when they do not fit. 'content' sizes each column to its own content through the same `_autoWidth` a grip double-click uses (520px cap) and lets the body scroll sideways rather than squeezing. A pinned column is honoured verbatim under both.
68
+ * @property {boolean|{overscan?: number, rowHeight?: number}} [virtualize=false] - C10. Draw only the rows near the viewport, between a top and a bottom spacer row, and give the body table a `<colgroup>` so its column widths no longer come from whichever row happens to be first. OFF by default: nothing about a table that does not ask changes. `overscan` (rows drawn beyond each edge, default 20) and `rowHeight` (the estimate used until a real row has been measured, default 24) tune it. See `_virtUpdate` for the whole contract.
69
+ * @property {Function} [virtualRowHeight] - `(rowIndex, baseHeight) => number`. Under `virtualize`, the height a row occupies, including anything the embedder injects in front of it (a group header) — and 0 for a row the embedder hides, which is then not drawn at all. Absent: every row is `baseHeight`, which is measured from a real row.
70
+ * @property {Function} [onRowsRendered] - `({ rows, removed }) => void`. Under `virtualize`, called whenever the window changes after `render()` returned: `rows` are the `<tr>`s the virtualiser ADDED — the rows an embedder that decorates rows after a render would otherwise never see — and `removed` the ones it took OUT, already detached (an embedder that injected rows of its own beside them, or holds an editor in one, has to hear that they went). The rows present when `render()` returns are not reported: they are the render's, exactly as without `virtualize`.
68
71
  */
69
72
 
73
+ /** C10. When the scroll box has no layout — mounted hidden, or a DOM with no
74
+ * layout engine — there is no viewport to measure, so the window is sized as if
75
+ * it were this tall. A ResizeObserver corrects it the moment a real size exists. */
76
+ const VIRTUAL_FALLBACK_VIEWPORT_PX = 1000;
77
+
70
78
  export class DataTable {
71
79
  /**
72
80
  * Create a DataTable instance
@@ -122,9 +130,19 @@ export class DataTable {
122
130
  // promise that adding the key changed no existing layout by a
123
131
  // pixel. Only a consumer that asks for 'content' sees anything new.
124
132
  columnFit: 'container',
133
+ // C10. Off unless asked for — see the typedef.
134
+ virtualize: false,
135
+ virtualRowHeight: null,
136
+ onRowsRendered: null,
125
137
  ...config,
126
138
  };
127
139
 
140
+ // C10. The render window, or null when this table draws every row.
141
+ this._virt = null;
142
+ // The measured row height outlives a render, so a re-render does not
143
+ // start again from the estimate and jump once it has re-measured.
144
+ this._virtBase = null;
145
+
128
146
  // A persistKey with nowhere to persist is a wiring bug, not a
129
147
  // degraded mode. Fail loud at construction: a silent no-op here
130
148
  // reads as "my filters randomly stopped sticking" months later.
@@ -418,9 +436,18 @@ export class DataTable {
418
436
  // navigation silently died the first time the store moved.
419
437
  const doc = this.container.ownerDocument;
420
438
  const hadTableFocus = !!this._tableEl && doc?.activeElement === this._tableEl;
439
+ // SCROLL POSITION SURVIVES A RE-RENDER, for the same reason: the scroll
440
+ // box is rebuilt too, and a new one starts at the top. Folding a tree
441
+ // row, or any refresh, threw a reader scrolled halfway down back to row
442
+ // one. Only on the SAME page — turning the page should start at its top.
443
+ const prevWrap = this._tableWrapEl;
444
+ const prevScroll = prevWrap?.isConnected && this._renderedOffset === this._state.offset
445
+ ? { top: prevWrap.scrollTop, left: prevWrap.scrollLeft } : null;
421
446
 
422
447
  this._cleanup();
423
448
  this.container.innerHTML = '';
449
+ // C10. A window belongs to one tbody; `_createTable` builds a new one.
450
+ this._virt = null;
424
451
 
425
452
  const { rows, pagination, emptyMessage } = this.config;
426
453
 
@@ -552,6 +579,24 @@ export class DataTable {
552
579
 
553
580
  this._tableWrapEl = tableWrap;
554
581
  this.container.appendChild(this._wrapperEl);
582
+ if (prevScroll && this._state.offset === this._renderedOffset) {
583
+ tableWrap.scrollTop = prevScroll.top;
584
+ tableWrap.scrollLeft = prevScroll.left;
585
+ }
586
+ this._renderedOffset = this._state.offset;
587
+
588
+ // C10. NOW THERE IS A VIEWPORT. `_createTable` drew a window sized from
589
+ // an estimate, before the scroll box existed; measure a real row, and
590
+ // redraw the window for the real height and the restored scroll offset.
591
+ // Synchronously, and not reported through `onRowsRendered`: a row present
592
+ // when `render()` returns is the render's, as it is without `virtualize`.
593
+ if (this._virt) {
594
+ const onScroll = () => this._virtUpdate();
595
+ tableWrap.addEventListener('scroll', onScroll, { passive: true });
596
+ this._disposers.push(() => tableWrap.removeEventListener('scroll', onScroll));
597
+ this._virtMeasure();
598
+ this._virtUpdate({ force: true, notify: false });
599
+ }
555
600
 
556
601
  // Sync header column widths to body after layout. Two-pass: the
557
602
  // first frame lets the browser settle natural widths from the
@@ -566,8 +611,11 @@ export class DataTable {
566
611
  try { this._resizeObserver.disconnect(); } catch (_) {}
567
612
  }
568
613
  if (typeof ResizeObserver !== 'undefined') {
569
- this._resizeObserver = createRafResizeObserver(
570
- () => this._syncHeaderWidths());
614
+ // C10: a new height is a new window, as well as new widths.
615
+ this._resizeObserver = createRafResizeObserver(() => {
616
+ this._syncHeaderWidths();
617
+ this._virtUpdate();
618
+ });
571
619
  this._resizeObserver.observe(this._wrapperEl);
572
620
  }
573
621
  // The header lives in its own non-scrolling wrap (so it can't
@@ -725,6 +773,8 @@ export class DataTable {
725
773
  this._tableWrapEl = null;
726
774
  this._processedRows = null;
727
775
  this._processedIndexMap = null;
776
+ this._virt = null;
777
+ this._colgroupEl = null;
728
778
  }
729
779
 
730
780
  // ─────────────────────────────────────────────────────────────────
@@ -1470,7 +1520,7 @@ export class DataTable {
1470
1520
 
1471
1521
  _createTable() {
1472
1522
  const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
1473
- const { selected, sortColumn, sortAscending } = this._state;
1523
+ const { sortColumn, sortAscending } = this._state;
1474
1524
 
1475
1525
  // Server-side pagination: rows already represent current page, use config offset for row numbering
1476
1526
  // Client-side pagination: use processed (filtered+sorted) rows, then slice for current page
@@ -1550,46 +1600,486 @@ export class DataTable {
1550
1600
  thead.appendChild(filterRow);
1551
1601
  }
1552
1602
 
1603
+ // C10. THE COLUMN TRACK IS A `<colgroup>`, NOT THE FIRST ROW. Under
1604
+ // `table-layout: fixed` a `<col>` with a width decides its column, and a
1605
+ // first-row cell only decides one whose `<col>` is `auto` — so once the
1606
+ // widths live here, it stops mattering which row is first. A recycling
1607
+ // window changes that row on every scroll; without this the columns
1608
+ // would jitter each time it did. Before the `thead`, where the content
1609
+ // model puts it; the `thead` leaves for the header table in `render()`
1610
+ // and the colgroup stays with the body.
1611
+ const virtual = this._isVirtual();
1612
+ if (virtual) {
1613
+ table.classList.add('twm-dt--virtual');
1614
+ table.appendChild(this._createColgroup());
1615
+ }
1616
+
1553
1617
  table.appendChild(thead);
1554
1618
 
1555
- // Body
1556
- const tbody = document.createElement('tbody');
1557
- pageRows.forEach((row, localIdx) => {
1619
+ // Map back to original config.rows index for selection tracking
1620
+ const globalOf = (localIdx) => {
1558
1621
  const processedIdx = offset + localIdx;
1559
- // Map back to original config.rows index for selection tracking
1560
- const globalIdx = isServerSide
1622
+ return isServerSide
1561
1623
  ? (configOffset || 0) + localIdx
1562
1624
  : (this._processedIndexMap?.[processedIdx] ?? processedIdx);
1563
- const tr = document.createElement('tr');
1564
- tr.__rowIndex = globalIdx;
1565
- tr.className = 'data-preview-row';
1625
+ };
1566
1626
 
1567
- if (selected.has(globalIdx)) {
1568
- tr.classList.add('selected');
1569
- }
1627
+ // Body
1628
+ const tbody = document.createElement('tbody');
1629
+ table.appendChild(tbody);
1630
+ this._tbodyEl = tbody;
1570
1631
 
1571
- this._fillRowCells(tr, row, globalIdx, showRowNumbers);
1632
+ if (virtual) {
1633
+ this._virtInit(tbody, pageRows, globalOf, {
1634
+ serverSide: isServerSide, offset, configOffset: configOffset || 0,
1635
+ });
1636
+ } else {
1637
+ pageRows.forEach((row, localIdx) => {
1638
+ tbody.appendChild(this._buildBodyRow(row, globalOf(localIdx), localIdx));
1639
+ });
1640
+ }
1572
1641
 
1573
- // Row-level click + right-click hooks (P2). Bound after
1574
- // cells so per-cell handlers run first.
1575
- if (this.config.onRowClick) {
1576
- tr.addEventListener('click', (ev) => {
1577
- this.config.onRowClick(globalIdx, row, ev);
1578
- });
1642
+ return table;
1643
+ }
1644
+
1645
+ /** One body row, fully built. The ONE place a `<tr>` for a row is made, so
1646
+ * the full render and the virtual window cannot drift apart: a row drawn
1647
+ * on scroll is the same row a render would have drawn. */
1648
+ _buildBodyRow(row, globalIdx, localIdx) {
1649
+ const { showRowNumbers } = this.config;
1650
+ const tr = document.createElement('tr');
1651
+ tr.__rowIndex = globalIdx;
1652
+ tr.className = 'data-preview-row';
1653
+
1654
+ if (this._state.selected.has(globalIdx)) {
1655
+ tr.classList.add('selected');
1656
+ }
1657
+ // C10. The stripe is `tr:nth-child(even)`, which is a question about a
1658
+ // row's POSITION among its siblings — and a window that drops rows off
1659
+ // its top changes every answer. `_virtSpacers` keeps the position's
1660
+ // parity equal to the row's own, so the stripe needs nothing here; the
1661
+ // class is for an embedder that wants to ask about the row itself.
1662
+ if (this._virt) tr.classList.toggle('twm-dt-row--alt', localIdx % 2 === 1);
1663
+
1664
+ this._fillRowCells(tr, row, globalIdx, showRowNumbers);
1665
+
1666
+ // Row-level click + right-click hooks (P2). Bound after
1667
+ // cells so per-cell handlers run first.
1668
+ if (this.config.onRowClick) {
1669
+ tr.addEventListener('click', (ev) => {
1670
+ this.config.onRowClick(globalIdx, row, ev);
1671
+ });
1672
+ }
1673
+ if (this.config.onRowContextMenu) {
1674
+ tr.addEventListener('contextmenu', (ev) => {
1675
+ this.config.onRowContextMenu(globalIdx, row, ev);
1676
+ });
1677
+ }
1678
+ return tr;
1679
+ }
1680
+
1681
+ // ─────────────────────────────────────────────────────────────────
1682
+ // C10 — the virtual window
1683
+ // ─────────────────────────────────────────────────────────────────
1684
+ //
1685
+ // ══ WHAT IT IS ═══════════════════════════════════════════════════
1686
+ //
1687
+ // Only the rows near the viewport are in the DOM: a TOP SPACER row whose
1688
+ // height is every row above the window, the window's rows, and a BOTTOM
1689
+ // SPACER for everything below it. The scrollbar is therefore sized to the
1690
+ // whole table and every row is reachable, while the DOM stays bounded by
1691
+ // the viewport — a thousand-row page (or fifty of them) costs the same
1692
+ // few dozen `<tr>`s as one screenful.
1693
+ //
1694
+ // ══ WHAT IT PROMISES ═════════════════════════════════════════════
1695
+ //
1696
+ // - A row that stays in the window is NEVER MOVED OR REBUILT when the
1697
+ // window slides. Rows leave from the edges and arrive at the edges; the
1698
+ // ones in the middle are the same nodes. An open editor, a focused
1699
+ // cell, a hover — anything living in a row that is still on screen —
1700
+ // survives a scroll. (Moving a node that holds focus blurs it, which in
1701
+ // an editable grid is a commit nobody asked for.)
1702
+ // - Column widths come from a `<colgroup>`, so the first row changing
1703
+ // moves nothing (see `_createTable`).
1704
+ // - A spacer is in the DOM only while it has a height. A table that fits
1705
+ // in its window has exactly the DOM it had without `virtualize`.
1706
+ // - `onRowsRendered` hears about every row added after `render()`, and
1707
+ // every row taken out.
1708
+ //
1709
+ // ══ WHAT IT ASSUMES ══════════════════════════════════════════════
1710
+ //
1711
+ // That a row's height is knowable without drawing it: `baseHeight`,
1712
+ // measured from a real row, or what `virtualRowHeight` says. A row that
1713
+ // wraps to two lines is drawn correctly and is simply mis-counted in the
1714
+ // spacer arithmetic by the difference — the overscan absorbs that.
1715
+
1716
+ _isVirtual() {
1717
+ return !!this.config.virtualize;
1718
+ }
1719
+
1720
+ _virtOptions() {
1721
+ const v = this.config.virtualize;
1722
+ const o = v && typeof v === 'object' ? v : {};
1723
+ return {
1724
+ overscan: Number.isFinite(o.overscan) && o.overscan >= 0 ? Math.floor(o.overscan) : 20,
1725
+ rowHeight: Number.isFinite(o.rowHeight) && o.rowHeight > 0 ? o.rowHeight : 24,
1726
+ };
1727
+ }
1728
+
1729
+ /** One `<col>` per DOM column (the row-number column included), carrying
1730
+ * any width already pinned for it, so a re-render starts at the widths it
1731
+ * had rather than at a content-sized frame that then snaps. */
1732
+ _createColgroup() {
1733
+ const n = (this.config.headers?.length || 0) + (this.config.showRowNumbers ? 1 : 0);
1734
+ const colgroup = document.createElement('colgroup');
1735
+ for (let i = 0; i < n; i++) {
1736
+ const col = document.createElement('col');
1737
+ const w = this._colWidths?.[i];
1738
+ if (w != null && this._colWidthsSig === this._colSig()) col.style.width = `${w}px`;
1739
+ colgroup.appendChild(col);
1740
+ }
1741
+ this._colgroupEl = colgroup;
1742
+ return colgroup;
1743
+ }
1744
+
1745
+ /** The `<col>`s, when this table has them. */
1746
+ _cols() {
1747
+ return this._virt && this._colgroupEl ? this._colgroupEl.children : null;
1748
+ }
1749
+
1750
+ _virtInit(tbody, rows, globalOf, { serverSide, offset, configOffset }) {
1751
+ const { overscan, rowHeight } = this._virtOptions();
1752
+ const spacer = (edge) => {
1753
+ const tr = document.createElement('tr');
1754
+ tr.className = `twm-dt-spacer twm-dt-spacer--${edge}`;
1755
+ tr.setAttribute('aria-hidden', 'true');
1756
+ const td = document.createElement('td');
1757
+ td.colSpan = Math.max(1, (this.config.headers?.length || 0)
1758
+ + (this.config.showRowNumbers ? 1 : 0));
1759
+ // INLINE, because it must beat every consumer's cell padding and
1760
+ // border at any specificity: a spacer that is 1px taller than its
1761
+ // arithmetic moves every row below it by that pixel.
1762
+ td.style.cssText = 'padding:0;border:0;height:0;min-width:0;max-width:none;';
1763
+ tr.appendChild(td);
1764
+ return tr;
1765
+ };
1766
+ // The parity filler: see `_virtSpacers`.
1767
+ const filler = document.createElement('tr');
1768
+ filler.className = 'twm-dt-spacer twm-dt-spacer--parity';
1769
+ filler.setAttribute('aria-hidden', 'true');
1770
+ filler.hidden = true;
1771
+ this._virt = {
1772
+ tbody, rows, globalOf, serverSide, offset, configOffset,
1773
+ overscan,
1774
+ base: this._virtBase || rowHeight,
1775
+ heights: null, offsets: null,
1776
+ start: 0, end: 0,
1777
+ map: new Map(),
1778
+ top: spacer('top'), bottom: spacer('bottom'), filler,
1779
+ };
1780
+ this._virtComputeOffsets();
1781
+ // A first window from an ESTIMATED viewport: the scroll box does not
1782
+ // exist yet. `render()` redraws it for the real one once it does.
1783
+ const guess = this.container?.clientHeight || 0;
1784
+ this._virtUpdate({ force: true, notify: false, viewport: guess || undefined, top: 0 });
1785
+ }
1786
+
1787
+ /** Heights and their running sum. `offsets[i]` is where row `i` starts;
1788
+ * `offsets[n]` is the whole table. O(n), and rerun only when a height can
1789
+ * have changed — a new measurement, or `refreshVirtualLayout()`. */
1790
+ _virtComputeOffsets() {
1791
+ const v = this._virt;
1792
+ if (!v) return;
1793
+ const n = v.rows.length;
1794
+ const heights = new Float64Array(n);
1795
+ const offsets = new Float64Array(n + 1);
1796
+ const fn = typeof this.config.virtualRowHeight === 'function'
1797
+ ? this.config.virtualRowHeight : null;
1798
+ let y = 0;
1799
+ for (let i = 0; i < n; i++) {
1800
+ let h = v.base;
1801
+ if (fn) {
1802
+ const asked = fn(v.globalOf(i), v.base);
1803
+ h = Number.isFinite(asked) && asked > 0 ? asked : 0;
1579
1804
  }
1580
- if (this.config.onRowContextMenu) {
1581
- tr.addEventListener('contextmenu', (ev) => {
1582
- this.config.onRowContextMenu(globalIdx, row, ev);
1583
- });
1805
+ heights[i] = h;
1806
+ offsets[i] = y;
1807
+ y += h;
1808
+ }
1809
+ offsets[n] = y;
1810
+ v.heights = heights;
1811
+ v.offsets = offsets;
1812
+ }
1813
+
1814
+ /** The row whose span contains `y`: the LAST `i` with `offsets[i] <= y`,
1815
+ * which steps over rows of zero height at the same offset. */
1816
+ _virtIndexAt(y) {
1817
+ const v = this._virt;
1818
+ const n = v.rows.length;
1819
+ if (n === 0) return 0;
1820
+ let lo = 0;
1821
+ let hi = n - 1;
1822
+ while (lo < hi) {
1823
+ const mid = (lo + hi + 1) >> 1;
1824
+ if (v.offsets[mid] <= y) lo = mid; else hi = mid - 1;
1825
+ }
1826
+ return lo;
1827
+ }
1828
+
1829
+ /** Measure one real row. Client pixels, so the zoom is divided back out —
1830
+ * `_measureNaturalWidths` explains why — and NOT `offsetHeight`, which is
1831
+ * rounded: 21 for a 21.19px row is a 1,100px error over 6,000 rows. */
1832
+ _virtMeasure() {
1833
+ const v = this._virt;
1834
+ if (!v) return false;
1835
+ for (const tr of v.map.values()) {
1836
+ if (!tr.isConnected || tr.hidden) continue;
1837
+ const zoom = this._tableEl?.currentCSSZoom || 1;
1838
+ const h = tr.getBoundingClientRect().height / zoom;
1839
+ if (!(h > 0)) return false;
1840
+ if (Math.abs(h - v.base) > 0.01) {
1841
+ v.base = h;
1842
+ this._virtBase = h;
1843
+ this._virtComputeOffsets();
1844
+ return true;
1584
1845
  }
1846
+ return false;
1847
+ }
1848
+ return false;
1849
+ }
1585
1850
 
1586
- tbody.appendChild(tr);
1587
- });
1851
+ /**
1852
+ * Slide the window to wherever the scroll box now is.
1853
+ *
1854
+ * HYSTERESIS, so a scroll of one pixel does not rebuild anything: the
1855
+ * window is redrawn only when the visible rows come within a quarter of the
1856
+ * overscan of either end of what is drawn, and it is then redrawn with the
1857
+ * full overscan on both sides.
1858
+ *
1859
+ * @param {object} [o]
1860
+ * @param {boolean} [o.force] redraw even inside the hysteresis band.
1861
+ * @param {boolean} [o.notify] report added and removed rows to `onRowsRendered`.
1862
+ * @param {number} [o.top] the scroll offset to draw for, instead of
1863
+ * reading one back — `scrollToRow` passes the offset it has just set,
1864
+ * which a DOM with no layout would read back as 0.
1865
+ * @param {number} [o.viewport] the viewport height, likewise.
1866
+ * @returns {HTMLElement[]} the rows added.
1867
+ */
1868
+ _virtUpdate({ force = false, notify = true, top = null, viewport = null } = {}) {
1869
+ const v = this._virt;
1870
+ if (!v || !v.offsets) return [];
1871
+ const n = v.rows.length;
1872
+ const wrap = this._tableWrapEl;
1873
+ const view = viewport || wrap?.clientHeight || VIRTUAL_FALLBACK_VIEWPORT_PX;
1874
+ const scrollTop = top ?? (wrap?.scrollTop || 0);
1875
+ const vs = this._virtIndexAt(scrollTop);
1876
+ const ve = Math.min(n, this._virtIndexAt(scrollTop + view) + 1);
1877
+ if (!force && v.end > v.start) {
1878
+ const margin = Math.max(1, Math.floor(v.overscan / 4));
1879
+ const lowOk = v.start === 0 || vs >= v.start + margin;
1880
+ const highOk = v.end === n || ve <= v.end - margin;
1881
+ if (lowOk && highOk) return [];
1882
+ }
1883
+ const start = Math.max(0, vs - v.overscan);
1884
+ const end = Math.min(n, ve + v.overscan);
1885
+ const removed = [];
1886
+ const added = this._virtRender(start, end, removed);
1887
+ if (added.length || removed.length) {
1888
+ // Only once the columns are fixed: before that the first sync has
1889
+ // not run, and it tooltips every cell itself.
1890
+ if (added[0]?.isConnected && this._tableEl?.style.tableLayout === 'fixed') {
1891
+ this._updateCellTooltips(added);
1892
+ }
1893
+ if (notify) {
1894
+ try { this.config.onRowsRendered?.({ rows: added, removed }); } catch (err) {
1895
+ console.error('[DataTable] onRowsRendered threw', err);
1896
+ }
1897
+ }
1898
+ }
1899
+ return added;
1900
+ }
1901
+
1902
+ /** Make the DOM hold exactly the drawable rows of `[start, end)`, without
1903
+ * moving a row that is already there. See the promises above. */
1904
+ _virtRender(start, end, removed = null) {
1905
+ const v = this._virt;
1906
+ const tbody = v.tbody;
1907
+ const want = [];
1908
+ for (let i = start; i < end; i++) if (v.heights[i] > 0) want.push(i);
1909
+ const wanted = new Set(want);
1910
+ for (const [i, tr] of v.map) {
1911
+ if (!wanted.has(i)) { tr.remove(); v.map.delete(i); removed?.push(tr); }
1912
+ }
1913
+ v.start = start;
1914
+ v.end = end;
1915
+ this._virtSpacers();
1916
+ const added = [];
1917
+ // Right to left, so each new row goes in front of the one after it —
1918
+ // which is either a row that stayed (and is not moved) or the bottom
1919
+ // spacer (or the end of the body).
1920
+ let ref = v.bottom.isConnected ? v.bottom : null;
1921
+ for (let k = want.length - 1; k >= 0; k--) {
1922
+ const i = want[k];
1923
+ const have = v.map.get(i);
1924
+ if (have) { ref = have; continue; }
1925
+ const tr = this._buildBodyRow(v.rows[i], v.globalOf(i), i);
1926
+ v.map.set(i, tr);
1927
+ tbody.insertBefore(tr, ref);
1928
+ ref = tr;
1929
+ added.push(tr);
1930
+ }
1931
+ added.reverse();
1932
+ return added;
1933
+ }
1588
1934
 
1589
- table.appendChild(tbody);
1590
- this._tbodyEl = tbody;
1935
+ /**
1936
+ * Size the two spacers, and put each in the DOM only while it has a height.
1937
+ *
1938
+ * AND KEEP THE STRIPE. `tr:nth-child(even)` stripes by POSITION, and in a
1939
+ * full render row `i` is child `i + 1`. Here the first drawn row is preceded
1940
+ * by the top spacer, so its position is off by one — and by a different one
1941
+ * each time the window's start changes parity, which strobes every stripe
1942
+ * on the screen as you scroll. A hidden filler row after the spacer, present
1943
+ * exactly when `start` is even, puts the parity back: a `display: none` row
1944
+ * is still a child, and so still counted by `nth-child`.
1945
+ */
1946
+ _virtSpacers() {
1947
+ const v = this._virt;
1948
+ const n = v.rows.length;
1949
+ const above = v.offsets[v.start];
1950
+ const below = v.offsets[n] - v.offsets[v.end];
1951
+ const tbody = v.tbody;
1952
+ if (above > 0) {
1953
+ v.top.firstChild.style.height = `${above}px`;
1954
+ if (tbody.firstChild !== v.top) tbody.insertBefore(v.top, tbody.firstChild);
1955
+ const needFiller = v.start % 2 === 0;
1956
+ if (needFiller && v.top.nextSibling !== v.filler) v.top.after(v.filler);
1957
+ else if (!needFiller) v.filler.remove();
1958
+ } else {
1959
+ v.top.remove();
1960
+ v.filler.remove();
1961
+ }
1962
+ if (below > 0) {
1963
+ v.bottom.firstChild.style.height = `${below}px`;
1964
+ if (tbody.lastChild !== v.bottom) tbody.appendChild(v.bottom);
1965
+ } else {
1966
+ v.bottom.remove();
1967
+ }
1968
+ }
1591
1969
 
1592
- return table;
1970
+ /**
1971
+ * Recompute every row's height and redraw the window. For an embedder whose
1972
+ * `virtualRowHeight` answer has changed — a group folded, a header added.
1973
+ * A no-op on a table that is not virtual.
1974
+ */
1975
+ refreshVirtualLayout() {
1976
+ if (!this._virt) return;
1977
+ this._virtMeasure();
1978
+ this._virtComputeOffsets();
1979
+ this._virtUpdate({ force: true });
1980
+ }
1981
+
1982
+ /** The element that scrolls the body. Read-only; the table owns it. */
1983
+ get scrollElement() {
1984
+ return this._tableWrapEl || null;
1985
+ }
1986
+
1987
+ /** The `<tr>` drawing row `rowIndex` (the `__rowIndex` space), or null when
1988
+ * it is not in the DOM — outside the window, on another page, or hidden. */
1989
+ getRowElement(rowIndex) {
1990
+ const v = this._virt;
1991
+ if (v) {
1992
+ const local = this._virtLocalOf(rowIndex);
1993
+ return local < 0 ? null : (v.map.get(local) || null);
1994
+ }
1995
+ const tbody = this._tbodyEl;
1996
+ if (!tbody) return null;
1997
+ for (const tr of tbody.children) if (tr.__rowIndex === rowIndex) return tr;
1998
+ return null;
1999
+ }
2000
+
2001
+ /** Keep the window's row list and `config.rows` in step for one row. They
2002
+ * are the same array in server-side mode; in client mode the window reads
2003
+ * the filtered-and-sorted copy, which holds its own reference. */
2004
+ _virtSetRow(rowIndex, row) {
2005
+ const v = this._virt;
2006
+ if (!v) return;
2007
+ if (Array.isArray(this.config.rows) && rowIndex >= 0
2008
+ && rowIndex < this.config.rows.length) {
2009
+ this.config.rows[rowIndex] = row;
2010
+ }
2011
+ const local = this._virtLocalOf(rowIndex);
2012
+ if (local >= 0) v.rows[local] = row;
2013
+ }
2014
+
2015
+ /** `__rowIndex` → position in the window's row list, or -1. */
2016
+ _virtLocalOf(rowIndex) {
2017
+ const v = this._virt;
2018
+ if (!v) return -1;
2019
+ let local;
2020
+ if (v.serverSide) local = rowIndex - v.configOffset;
2021
+ else {
2022
+ const map = this._processedIndexMap;
2023
+ const processed = map ? map.indexOf(rowIndex) : rowIndex;
2024
+ local = processed < 0 ? -1 : processed - v.offset;
2025
+ }
2026
+ return local >= 0 && local < v.rows.length ? local : -1;
2027
+ }
2028
+
2029
+ /**
2030
+ * Scroll the body so row `rowIndex` is on screen, drawing it if it was not,
2031
+ * and return its `<tr>` (null when it cannot be shown: another page, or a
2032
+ * row the embedder hides). Works with or without `virtualize`.
2033
+ *
2034
+ * @param {number} rowIndex in the `__rowIndex` space.
2035
+ * @param {{block?: 'nearest'|'center'|'start'}} [opts]
2036
+ */
2037
+ scrollToRow(rowIndex, { block = 'nearest' } = {}) {
2038
+ const wrap = this._tableWrapEl;
2039
+ const v = this._virt;
2040
+ if (!v) {
2041
+ const tr = this.getRowElement(rowIndex);
2042
+ if (!tr || !wrap) return tr;
2043
+ const zoom = this._tableEl?.currentCSSZoom || 1;
2044
+ const box = wrap.getBoundingClientRect();
2045
+ const r = tr.getBoundingClientRect();
2046
+ const rowTop = (r.top - box.top) / zoom + wrap.scrollTop;
2047
+ const rowH = r.height / zoom;
2048
+ const target = this._scrollTarget(rowTop, rowH, wrap.scrollTop,
2049
+ wrap.clientHeight, block);
2050
+ if (target !== wrap.scrollTop) wrap.scrollTop = target;
2051
+ return tr;
2052
+ }
2053
+ const local = this._virtLocalOf(rowIndex);
2054
+ if (local < 0 || !(v.heights[local] > 0)) return null;
2055
+ const view = wrap?.clientHeight || VIRTUAL_FALLBACK_VIEWPORT_PX;
2056
+ const current = wrap?.scrollTop || 0;
2057
+ let target = this._scrollTarget(v.offsets[local], v.heights[local],
2058
+ current, view, block);
2059
+ target = Math.max(0, Math.min(target, Math.max(0, v.offsets[v.rows.length] - view)));
2060
+ if (wrap && Math.abs(target - current) >= 1) wrap.scrollTop = target;
2061
+ this._virtUpdate({ top: Math.abs(target - current) >= 1 ? target : current });
2062
+ return v.map.get(local) || null;
2063
+ }
2064
+
2065
+ _scrollTarget(rowTop, rowH, current, view, block) {
2066
+ if (block === 'start') return rowTop;
2067
+ if (block === 'center') return rowTop - Math.max(0, (view - rowH) / 2);
2068
+ if (rowTop < current) return rowTop;
2069
+ if (rowTop + rowH > current + view) return rowTop + rowH - view;
2070
+ return current;
2071
+ }
2072
+
2073
+ /** The first body row that draws a row of data — never a spacer, and never
2074
+ * a row an embedder injected (a group header spans every column with ONE
2075
+ * cell, so measuring it would report one column). */
2076
+ _firstBodyRow() {
2077
+ const tbody = this._tableEl?.querySelector?.('tbody');
2078
+ if (!tbody) return null;
2079
+ for (const tr of tbody.children) {
2080
+ if (tr.__rowIndex !== undefined) return tr;
2081
+ }
2082
+ return null;
1593
2083
  }
1594
2084
 
1595
2085
  /** Build (or rebuild) one row's cells in place.
@@ -1674,7 +2164,15 @@ export class DataTable {
1674
2164
  for (const candidate of tbody.children) {
1675
2165
  if (candidate.__rowIndex === index) { tr = candidate; break; }
1676
2166
  }
1677
- if (!tr) return false;
2167
+ // C10. OUTSIDE THE WINDOW IS NOT OFF THE PAGE. The row will be drawn the
2168
+ // moment it scrolls back in, from the window's own row list — so the
2169
+ // data has to change now, or it comes back showing the value it had
2170
+ // before this update. Without `virtualize` a row that is not in the DOM
2171
+ // is on another page, and that path is unchanged.
2172
+ if (!tr) {
2173
+ if (this._virt) this._virtSetRow(index, row);
2174
+ return false;
2175
+ }
1678
2176
 
1679
2177
  // Keep the caller's data in step, so a later full render agrees with
1680
2178
  // what is on screen. Server-side paging means config.rows may hold only
@@ -1682,6 +2180,7 @@ export class DataTable {
1682
2180
  if (Array.isArray(this.config.rows) && this.config.rows[index]) {
1683
2181
  this.config.rows[index] = row;
1684
2182
  }
2183
+ if (this._virt) this._virtSetRow(index, row);
1685
2184
 
1686
2185
  // Record focus BEFORE the replace: `replaceChildren` detaches the
1687
2186
  // focused cell and the browser moves focus to <body>.
@@ -1756,14 +2255,26 @@ export class DataTable {
1756
2255
  * never visible. Clearing inline widths first stops the last sync's forced
1757
2256
  * widths from constraining the measure.
1758
2257
  *
2258
+ * C10 — UNDER `virtualize` ONLY THE WINDOW IS IN THE DOM, so `max-content`
2259
+ * spans the eighty-odd rows drawn rather than every row loaded. The fit
2260
+ * pass that runs on every render is content with that (it is fitting what
2261
+ * is on screen). An EXPLICIT auto-size is not: it is a promise to fit the
2262
+ * column's content, and the longest value in a 6,000-row table is usually
2263
+ * not in the window. So `sample` adds, for the length of this one reflow,
2264
+ * the row holding each column's longest value — see `_virtSampleRows`.
2265
+ *
2266
+ * @param {{sample?: boolean}} [o]
1759
2267
  * @returns {number[]|null} width per DOM column index, or null when there
1760
2268
  * is nothing laid out to measure.
1761
2269
  */
1762
- _measureNaturalWidths() {
2270
+ _measureNaturalWidths({ sample = false } = {}) {
1763
2271
  const headerTable = this._headerTableEl;
1764
2272
  const bodyTable = this._tableEl;
1765
2273
  if (!headerTable || !bodyTable) return null;
1766
- const firstRow = bodyTable.querySelector('tbody > tr');
2274
+ // A ROW OF DATA, not merely the first `<tr>`: under C10 that is a
2275
+ // spacer, and an embedder may inject rows of its own (a group header is
2276
+ // one cell spanning every column, which would measure as ONE column).
2277
+ const firstRow = this._firstBodyRow();
1767
2278
  if (!firstRow) return null;
1768
2279
  const bodyCells = firstRow.children;
1769
2280
  if (!bodyCells.length) return null;
@@ -1772,10 +2283,17 @@ export class DataTable {
1772
2283
  for (const th of tr.children) this._clearCellWidth(th);
1773
2284
  });
1774
2285
  for (const td of bodyCells) this._clearCellWidth(td);
2286
+ // C10: the `<col>`s are what hold the widths, so they are what has to
2287
+ // let go for the measure (`.twm-dt-measuring col` also overrides them).
2288
+ for (const col of this._cols() || []) col.style.width = '';
1775
2289
  headerTable.style.width = '';
1776
2290
  bodyTable.style.width = '';
1777
2291
  headerTable.classList.add('twm-dt-measuring');
1778
2292
  bodyTable.classList.add('twm-dt-measuring');
2293
+ // Appended at the end, where they move no drawn row, and taken out
2294
+ // again below before anything paints.
2295
+ const sampled = sample ? this._virtSampleRows() : [];
2296
+ for (const tr of sampled) this._virt.tbody.appendChild(tr);
1779
2297
  // Force layout flush so measurements are current.
1780
2298
  // eslint-disable-next-line no-unused-expressions
1781
2299
  bodyTable.offsetWidth;
@@ -1811,13 +2329,60 @@ export class DataTable {
1811
2329
  natural[i] = Math.max(body, head);
1812
2330
  }
1813
2331
 
2332
+ for (const tr of sampled) tr.remove();
1814
2333
  headerTable.classList.remove('twm-dt-measuring');
1815
2334
  bodyTable.classList.remove('twm-dt-measuring');
1816
2335
  return natural;
1817
2336
  }
1818
2337
 
1819
2338
  /**
1820
- * Fit one column to its widest visible value and pin it there.
2339
+ * The rows an explicit auto-size must measure that the window has not
2340
+ * drawn: for each column, the drawable row whose value is LONGEST as a
2341
+ * string, plus the last row (the widest row number). Built with
2342
+ * `_buildBodyRow` — so a consumer's `renderCell` draws them exactly as it
2343
+ * would on screen — and handed back detached, for `_measureNaturalWidths`
2344
+ * to put in and take out around its one reflow.
2345
+ *
2346
+ * The string length is a proxy, not a measurement: in a proportional font
2347
+ * forty `i`s are narrower than thirty `W`s. It is the proxy a spreadsheet's
2348
+ * own autofit falls short by, and it is right about the case that matters
2349
+ * here — a value five times longer than anything in the window. One pass
2350
+ * over the loaded rows, on a click and never on a scroll or a resize.
2351
+ *
2352
+ * @returns {HTMLTableRowElement[]}
2353
+ */
2354
+ _virtSampleRows() {
2355
+ const v = this._virt;
2356
+ if (!v || !v.rows.length) return [];
2357
+ const longest = [];
2358
+ const at = [];
2359
+ let last = -1;
2360
+ for (let i = 0; i < v.rows.length; i++) {
2361
+ if (!(v.heights[i] > 0)) continue; // hidden: not drawn, not fitted
2362
+ last = i;
2363
+ const row = v.rows[i];
2364
+ if (!Array.isArray(row)) continue;
2365
+ for (let c = 0; c < row.length; c++) {
2366
+ const value = row[c];
2367
+ const len = value == null ? 0 : String(value).length;
2368
+ if (len > (longest[c] ?? -1)) { longest[c] = len; at[c] = i; }
2369
+ }
2370
+ }
2371
+ const want = new Set(at.filter((i) => i !== undefined));
2372
+ if (last >= 0) want.add(last);
2373
+ const rows = [];
2374
+ for (const i of want) {
2375
+ if (v.map.has(i)) continue; // drawn already; measured anyway
2376
+ const tr = this._buildBodyRow(v.rows[i], v.globalOf(i), i);
2377
+ tr.setAttribute('aria-hidden', 'true');
2378
+ rows.push(tr);
2379
+ }
2380
+ return rows;
2381
+ }
2382
+
2383
+ /**
2384
+ * Fit one column to its widest value and pin it there — every loaded
2385
+ * row's, under `virtualize` (see `_virtSampleRows`), not only the window's.
1821
2386
  *
1822
2387
  * The gesture is a double-click on the column's resize grip, which is where
1823
2388
  * every spreadsheet has put it — and the grip is this component's, which is
@@ -1832,7 +2397,7 @@ export class DataTable {
1832
2397
  * @returns {boolean} whether it had a layout to measure.
1833
2398
  */
1834
2399
  autoSizeColumn(domIdx, opts = {}) {
1835
- const natural = this._measureNaturalWidths();
2400
+ const natural = this._measureNaturalWidths({ sample: true });
1836
2401
  if (!natural || domIdx < 0 || domIdx >= natural.length) return false;
1837
2402
  this._colWidths[domIdx] = this._autoWidth(natural[domIdx], opts);
1838
2403
  this._colWidthsSig = this._colSig();
@@ -1857,7 +2422,7 @@ export class DataTable {
1857
2422
  * already, so a button that did that would be a button that does nothing.
1858
2423
  */
1859
2424
  autoSizeColumns(opts = {}) {
1860
- const natural = this._measureNaturalWidths();
2425
+ const natural = this._measureNaturalWidths({ sample: true });
1861
2426
  if (!natural) return false;
1862
2427
  this._colWidths = {};
1863
2428
  for (let i = 0; i < natural.length; i++) {
@@ -1909,10 +2474,15 @@ export class DataTable {
1909
2474
  const headerTable = this._headerTableEl;
1910
2475
  const bodyTable = this._tableEl;
1911
2476
  if (!headerTable || !bodyTable) return;
1912
- const firstRow = bodyTable.querySelector('tbody > tr');
1913
- if (!firstRow) return;
1914
- const bodyCells = firstRow.children;
1915
- if (!bodyCells.length) return;
2477
+ // C10. UNDER `virtualize` THE COLUMN TRACK IS THE `<colgroup>`, and the
2478
+ // column count is ITS length — not the first row's, which is whatever
2479
+ // row the window happens to start at (or a spacer). Without it, the
2480
+ // first ROW OF DATA; see `_firstBodyRow` for why not merely the first `<tr>`.
2481
+ const cols = this._cols();
2482
+ const firstRow = this._firstBodyRow();
2483
+ const bodyCells = cols ? [] : firstRow?.children;
2484
+ const n = cols ? cols.length : (bodyCells?.length || 0);
2485
+ if (!n) return;
1916
2486
 
1917
2487
  const headerRows = headerTable.querySelectorAll('thead > tr');
1918
2488
  // DO NOT MEASURE WHAT NOTHING WILL READ.
@@ -1930,8 +2500,8 @@ export class DataTable {
1930
2500
  // only thing left that the array supplies is `n`, which is its length.
1931
2501
  // So zeroes of the right length are not an approximation of the
1932
2502
  // measurement, they are indistinguishable from it.
1933
- const natural = this._allColumnsPinned(bodyCells.length)
1934
- ? new Array(bodyCells.length).fill(0)
2503
+ const natural = this._allColumnsPinned(n)
2504
+ ? new Array(n).fill(0)
1935
2505
  : this._measureNaturalWidths();
1936
2506
  if (!natural) return;
1937
2507
 
@@ -1941,7 +2511,7 @@ export class DataTable {
1941
2511
  const wrap = this._tableWrapEl;
1942
2512
  const headerWrap = this._headerWrapEl;
1943
2513
  const avail = wrap ? wrap.clientWidth : 0;
1944
- const firstIdx = this.config.showRowNumbers && bodyCells.length > 1 ? 1 : 0;
2514
+ const firstIdx = this.config.showRowNumbers && n > 1 ? 1 : 0;
1945
2515
  const widths = this._fitColumnWidths(natural, avail, {
1946
2516
  firstIdx,
1947
2517
  overrides: this._colWidths,
@@ -1959,8 +2529,14 @@ export class DataTable {
1959
2529
  this._setCellWidth(tr.children[i], widths[i]);
1960
2530
  }
1961
2531
  });
1962
- for (let i = 0; i < bodyCells.length && i < widths.length; i++) {
1963
- this._setCellWidth(bodyCells[i], widths[i]);
2532
+ if (cols) {
2533
+ for (let i = 0; i < cols.length && i < widths.length; i++) {
2534
+ cols[i].style.width = `${widths[i]}px`;
2535
+ }
2536
+ } else {
2537
+ for (let i = 0; i < bodyCells.length && i < widths.length; i++) {
2538
+ this._setCellWidth(bodyCells[i], widths[i]);
2539
+ }
1964
2540
  }
1965
2541
 
1966
2542
  // Lock both tables so the widths stick even when content changes.
@@ -2361,7 +2937,13 @@ export class DataTable {
2361
2937
  headerTable.querySelectorAll('thead > tr').forEach((tr) => {
2362
2938
  if (tr.children[i]) this._setCellWidth(tr.children[i], w);
2363
2939
  });
2364
- const bodyRow = bodyTable && bodyTable.querySelector('tbody > tr');
2940
+ // C10: the `<col>` is the track; without one, the first row of data.
2941
+ const cols = this._cols();
2942
+ if (cols) {
2943
+ if (cols[i]) cols[i].style.width = `${w}px`;
2944
+ return;
2945
+ }
2946
+ const bodyRow = bodyTable && this._firstBodyRow();
2365
2947
  if (bodyRow && bodyRow.children[i]) this._setCellWidth(bodyRow.children[i], w);
2366
2948
  }
2367
2949
 
@@ -2417,10 +2999,15 @@ export class DataTable {
2417
2999
  /** Add a native `title` tooltip to any body cell whose text is
2418
3000
  * clipped by its column width; remove ours once it fits again.
2419
3001
  * Leaves caller-supplied titles (e.g. from renderCell) untouched. */
2420
- _updateCellTooltips() {
3002
+ _updateCellTooltips(rows = null) {
2421
3003
  const bodyTable = this._tableEl;
2422
3004
  if (!bodyTable) return;
2423
- bodyTable.querySelectorAll('tbody td').forEach((td) => {
3005
+ // `rows`: only these (C10 — the rows a window slide just added).
3006
+ const cells = rows
3007
+ ? rows.flatMap((tr) => [...tr.children])
3008
+ : bodyTable.querySelectorAll('tbody td');
3009
+ cells.forEach((td) => {
3010
+ if (td.parentElement?.classList.contains('twm-dt-spacer')) return;
2424
3011
  const clipped = td.scrollWidth > td.clientWidth + 1;
2425
3012
  if (clipped) {
2426
3013
  if (!td.title) td.title = td.textContent;