flexdesk 0.4.4 → 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.
@@ -411,9 +429,25 @@ export class DataTable {
411
429
  render() {
412
430
  // Capture active filter input before re-render
413
431
  const activeFilterColIdx = this._getActiveFilterColIdx();
432
+ // KEYBOARD FOCUS SURVIVES A RE-RENDER. Rendering replaces the table
433
+ // element, and a focused element that is removed hands focus to the
434
+ // page body, where the arrow keys reach nobody. A data refresh does
435
+ // exactly that (a record changed on disk, a live reload), so keyboard
436
+ // navigation silently died the first time the store moved.
437
+ const doc = this.container.ownerDocument;
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;
414
446
 
415
447
  this._cleanup();
416
448
  this.container.innerHTML = '';
449
+ // C10. A window belongs to one tbody; `_createTable` builds a new one.
450
+ this._virt = null;
417
451
 
418
452
  const { rows, pagination, emptyMessage } = this.config;
419
453
 
@@ -545,6 +579,24 @@ export class DataTable {
545
579
 
546
580
  this._tableWrapEl = tableWrap;
547
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
+ }
548
600
 
549
601
  // Sync header column widths to body after layout. Two-pass: the
550
602
  // first frame lets the browser settle natural widths from the
@@ -559,8 +611,11 @@ export class DataTable {
559
611
  try { this._resizeObserver.disconnect(); } catch (_) {}
560
612
  }
561
613
  if (typeof ResizeObserver !== 'undefined') {
562
- this._resizeObserver = createRafResizeObserver(
563
- () => 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
+ });
564
619
  this._resizeObserver.observe(this._wrapperEl);
565
620
  }
566
621
  // The header lives in its own non-scrolling wrap (so it can't
@@ -581,6 +636,9 @@ export class DataTable {
581
636
  if (activeFilterColIdx !== null) {
582
637
  this._restoreFilterFocus(activeFilterColIdx);
583
638
  }
639
+ else if (hadTableFocus && this._tableEl) {
640
+ try { this._tableEl.focus({ preventScroll: true }); } catch (_) { /* detached */ }
641
+ }
584
642
  }
585
643
 
586
644
  /**
@@ -715,6 +773,8 @@ export class DataTable {
715
773
  this._tableWrapEl = null;
716
774
  this._processedRows = null;
717
775
  this._processedIndexMap = null;
776
+ this._virt = null;
777
+ this._colgroupEl = null;
718
778
  }
719
779
 
720
780
  // ─────────────────────────────────────────────────────────────────
@@ -1460,7 +1520,7 @@ export class DataTable {
1460
1520
 
1461
1521
  _createTable() {
1462
1522
  const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
1463
- const { selected, sortColumn, sortAscending } = this._state;
1523
+ const { sortColumn, sortAscending } = this._state;
1464
1524
 
1465
1525
  // Server-side pagination: rows already represent current page, use config offset for row numbering
1466
1526
  // Client-side pagination: use processed (filtered+sorted) rows, then slice for current page
@@ -1540,46 +1600,486 @@ export class DataTable {
1540
1600
  thead.appendChild(filterRow);
1541
1601
  }
1542
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
+
1543
1617
  table.appendChild(thead);
1544
1618
 
1545
- // Body
1546
- const tbody = document.createElement('tbody');
1547
- pageRows.forEach((row, localIdx) => {
1619
+ // Map back to original config.rows index for selection tracking
1620
+ const globalOf = (localIdx) => {
1548
1621
  const processedIdx = offset + localIdx;
1549
- // Map back to original config.rows index for selection tracking
1550
- const globalIdx = isServerSide
1622
+ return isServerSide
1551
1623
  ? (configOffset || 0) + localIdx
1552
1624
  : (this._processedIndexMap?.[processedIdx] ?? processedIdx);
1553
- const tr = document.createElement('tr');
1554
- tr.__rowIndex = globalIdx;
1555
- tr.className = 'data-preview-row';
1625
+ };
1556
1626
 
1557
- if (selected.has(globalIdx)) {
1558
- tr.classList.add('selected');
1559
- }
1627
+ // Body
1628
+ const tbody = document.createElement('tbody');
1629
+ table.appendChild(tbody);
1630
+ this._tbodyEl = tbody;
1560
1631
 
1561
- 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
+ }
1562
1641
 
1563
- // Row-level click + right-click hooks (P2). Bound after
1564
- // cells so per-cell handlers run first.
1565
- if (this.config.onRowClick) {
1566
- tr.addEventListener('click', (ev) => {
1567
- this.config.onRowClick(globalIdx, row, ev);
1568
- });
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;
1569
1804
  }
1570
- if (this.config.onRowContextMenu) {
1571
- tr.addEventListener('contextmenu', (ev) => {
1572
- this.config.onRowContextMenu(globalIdx, row, ev);
1573
- });
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;
1574
1845
  }
1846
+ return false;
1847
+ }
1848
+ return false;
1849
+ }
1575
1850
 
1576
- tbody.appendChild(tr);
1577
- });
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
+ }
1578
1934
 
1579
- table.appendChild(tbody);
1580
- 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
+ }
1581
1969
 
1582
- 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;
1583
2083
  }
1584
2084
 
1585
2085
  /** Build (or rebuild) one row's cells in place.
@@ -1664,7 +2164,15 @@ export class DataTable {
1664
2164
  for (const candidate of tbody.children) {
1665
2165
  if (candidate.__rowIndex === index) { tr = candidate; break; }
1666
2166
  }
1667
- 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
+ }
1668
2176
 
1669
2177
  // Keep the caller's data in step, so a later full render agrees with
1670
2178
  // what is on screen. Server-side paging means config.rows may hold only
@@ -1672,6 +2180,7 @@ export class DataTable {
1672
2180
  if (Array.isArray(this.config.rows) && this.config.rows[index]) {
1673
2181
  this.config.rows[index] = row;
1674
2182
  }
2183
+ if (this._virt) this._virtSetRow(index, row);
1675
2184
 
1676
2185
  // Record focus BEFORE the replace: `replaceChildren` detaches the
1677
2186
  // focused cell and the browser moves focus to <body>.
@@ -1746,14 +2255,26 @@ export class DataTable {
1746
2255
  * never visible. Clearing inline widths first stops the last sync's forced
1747
2256
  * widths from constraining the measure.
1748
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]
1749
2267
  * @returns {number[]|null} width per DOM column index, or null when there
1750
2268
  * is nothing laid out to measure.
1751
2269
  */
1752
- _measureNaturalWidths() {
2270
+ _measureNaturalWidths({ sample = false } = {}) {
1753
2271
  const headerTable = this._headerTableEl;
1754
2272
  const bodyTable = this._tableEl;
1755
2273
  if (!headerTable || !bodyTable) return null;
1756
- 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();
1757
2278
  if (!firstRow) return null;
1758
2279
  const bodyCells = firstRow.children;
1759
2280
  if (!bodyCells.length) return null;
@@ -1762,10 +2283,17 @@ export class DataTable {
1762
2283
  for (const th of tr.children) this._clearCellWidth(th);
1763
2284
  });
1764
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 = '';
1765
2289
  headerTable.style.width = '';
1766
2290
  bodyTable.style.width = '';
1767
2291
  headerTable.classList.add('twm-dt-measuring');
1768
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);
1769
2297
  // Force layout flush so measurements are current.
1770
2298
  // eslint-disable-next-line no-unused-expressions
1771
2299
  bodyTable.offsetWidth;
@@ -1801,13 +2329,60 @@ export class DataTable {
1801
2329
  natural[i] = Math.max(body, head);
1802
2330
  }
1803
2331
 
2332
+ for (const tr of sampled) tr.remove();
1804
2333
  headerTable.classList.remove('twm-dt-measuring');
1805
2334
  bodyTable.classList.remove('twm-dt-measuring');
1806
2335
  return natural;
1807
2336
  }
1808
2337
 
1809
2338
  /**
1810
- * 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.
1811
2386
  *
1812
2387
  * The gesture is a double-click on the column's resize grip, which is where
1813
2388
  * every spreadsheet has put it — and the grip is this component's, which is
@@ -1822,7 +2397,7 @@ export class DataTable {
1822
2397
  * @returns {boolean} whether it had a layout to measure.
1823
2398
  */
1824
2399
  autoSizeColumn(domIdx, opts = {}) {
1825
- const natural = this._measureNaturalWidths();
2400
+ const natural = this._measureNaturalWidths({ sample: true });
1826
2401
  if (!natural || domIdx < 0 || domIdx >= natural.length) return false;
1827
2402
  this._colWidths[domIdx] = this._autoWidth(natural[domIdx], opts);
1828
2403
  this._colWidthsSig = this._colSig();
@@ -1847,7 +2422,7 @@ export class DataTable {
1847
2422
  * already, so a button that did that would be a button that does nothing.
1848
2423
  */
1849
2424
  autoSizeColumns(opts = {}) {
1850
- const natural = this._measureNaturalWidths();
2425
+ const natural = this._measureNaturalWidths({ sample: true });
1851
2426
  if (!natural) return false;
1852
2427
  this._colWidths = {};
1853
2428
  for (let i = 0; i < natural.length; i++) {
@@ -1899,10 +2474,15 @@ export class DataTable {
1899
2474
  const headerTable = this._headerTableEl;
1900
2475
  const bodyTable = this._tableEl;
1901
2476
  if (!headerTable || !bodyTable) return;
1902
- const firstRow = bodyTable.querySelector('tbody > tr');
1903
- if (!firstRow) return;
1904
- const bodyCells = firstRow.children;
1905
- 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;
1906
2486
 
1907
2487
  const headerRows = headerTable.querySelectorAll('thead > tr');
1908
2488
  // DO NOT MEASURE WHAT NOTHING WILL READ.
@@ -1920,8 +2500,8 @@ export class DataTable {
1920
2500
  // only thing left that the array supplies is `n`, which is its length.
1921
2501
  // So zeroes of the right length are not an approximation of the
1922
2502
  // measurement, they are indistinguishable from it.
1923
- const natural = this._allColumnsPinned(bodyCells.length)
1924
- ? new Array(bodyCells.length).fill(0)
2503
+ const natural = this._allColumnsPinned(n)
2504
+ ? new Array(n).fill(0)
1925
2505
  : this._measureNaturalWidths();
1926
2506
  if (!natural) return;
1927
2507
 
@@ -1931,7 +2511,7 @@ export class DataTable {
1931
2511
  const wrap = this._tableWrapEl;
1932
2512
  const headerWrap = this._headerWrapEl;
1933
2513
  const avail = wrap ? wrap.clientWidth : 0;
1934
- const firstIdx = this.config.showRowNumbers && bodyCells.length > 1 ? 1 : 0;
2514
+ const firstIdx = this.config.showRowNumbers && n > 1 ? 1 : 0;
1935
2515
  const widths = this._fitColumnWidths(natural, avail, {
1936
2516
  firstIdx,
1937
2517
  overrides: this._colWidths,
@@ -1949,8 +2529,14 @@ export class DataTable {
1949
2529
  this._setCellWidth(tr.children[i], widths[i]);
1950
2530
  }
1951
2531
  });
1952
- for (let i = 0; i < bodyCells.length && i < widths.length; i++) {
1953
- 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
+ }
1954
2540
  }
1955
2541
 
1956
2542
  // Lock both tables so the widths stick even when content changes.
@@ -2267,7 +2853,20 @@ export class DataTable {
2267
2853
  });
2268
2854
  }
2269
2855
 
2856
+ /**
2857
+ * CSS px per screen px. Content inside a zoomed tile reports rects and
2858
+ * pointer coordinates in screen pixels, while `style.width` is CSS pixels;
2859
+ * mixing them widened every column by the zoom factor on a mere click.
2860
+ */
2861
+ _zoomFactor() {
2862
+ const el = this._tableWrapEl || this._tableEl;
2863
+ const css = el?.offsetWidth || 0;
2864
+ const screen = el?.getBoundingClientRect?.().width || 0;
2865
+ return css > 0 && screen > 0 ? screen / css : 1;
2866
+ }
2867
+
2270
2868
  _beginColResize(ev, domIdx) {
2869
+ if (ev.button != null && ev.button !== 0) return;
2271
2870
  ev.preventDefault();
2272
2871
  ev.stopPropagation();
2273
2872
  const headerTable = this._headerTableEl;
@@ -2275,24 +2874,38 @@ export class DataTable {
2275
2874
  const headRow = headerTable && headerTable.querySelector('thead > tr');
2276
2875
  if (!headRow) return;
2277
2876
 
2278
- // Freeze EVERY column at its current width on BOTH tables and
2279
- // lock fixed layout up front, so the drag moves only the grabbed
2280
- // column and a neighbour can never absorb it. Widths come from
2281
- // the header row (the column source of truth); the body's first
2282
- // row is pinned to match so the two tables stay in lock-step.
2283
- const startWidths = [...headRow.children].map(
2284
- (c) => c.getBoundingClientRect().width);
2285
- headerTable.style.tableLayout = 'fixed';
2286
- if (bodyTable) bodyTable.style.tableLayout = 'fixed';
2287
- startWidths.forEach((w, i) => this._pinColumnWidth(i, w));
2288
- this._applyTableWidth();
2289
-
2877
+ // NOTHING HAPPENS UNTIL THE POINTER MOVES. Freezing on mousedown pinned
2878
+ // every column on a plain click, and the first click of a double-click,
2879
+ // so a click on a grip was already a resize and switched auto-fit off
2880
+ // for every column for good.
2881
+ //
2882
+ // Once the drag is real: freeze EVERY column at its current width on
2883
+ // BOTH tables and lock fixed layout, so the drag moves only the grabbed
2884
+ // column and a neighbour can never absorb it. Widths come from the
2885
+ // header row (the column source of truth), in CSS pixels.
2290
2886
  const startX = ev.clientX;
2887
+ const THRESHOLD = 3;
2291
2888
  const MIN = 40;
2292
- document.body.classList.add('twm-dt-col-resizing');
2889
+ let startWidths = null;
2890
+ const begin = () => {
2891
+ const z = this._zoomFactor();
2892
+ startWidths = [...headRow.children].map((c) => {
2893
+ const set = parseFloat(c.style.width);
2894
+ return Number.isFinite(set) ? set : c.getBoundingClientRect().width / z;
2895
+ });
2896
+ headerTable.style.tableLayout = 'fixed';
2897
+ if (bodyTable) bodyTable.style.tableLayout = 'fixed';
2898
+ startWidths.forEach((w, i) => this._pinColumnWidth(i, w));
2899
+ this._applyTableWidth();
2900
+ document.body.classList.add('twm-dt-col-resizing');
2901
+ };
2293
2902
  const onMove = (mv) => {
2294
- const w = Math.max(
2295
- MIN, Math.round(startWidths[domIdx] + (mv.clientX - startX)));
2903
+ const dx = mv.clientX - startX;
2904
+ if (!startWidths) {
2905
+ if (Math.abs(dx) < THRESHOLD) return;
2906
+ begin();
2907
+ }
2908
+ const w = Math.max(MIN, Math.round(startWidths[domIdx] + dx / this._zoomFactor()));
2296
2909
  this._pinColumnWidth(domIdx, w);
2297
2910
  // Pass the dragged column so the fill invariant reclaims any
2298
2911
  // freed width into a DIFFERENT (flexible/last) column, never
@@ -2303,6 +2916,7 @@ export class DataTable {
2303
2916
  const onUp = () => {
2304
2917
  document.removeEventListener('mousemove', onMove);
2305
2918
  document.removeEventListener('mouseup', onUp);
2919
+ if (!startWidths) return; // a click: nothing changed
2306
2920
  document.body.classList.remove('twm-dt-col-resizing');
2307
2921
  this._updateCellTooltips();
2308
2922
  this._savePersisted();
@@ -2323,7 +2937,13 @@ export class DataTable {
2323
2937
  headerTable.querySelectorAll('thead > tr').forEach((tr) => {
2324
2938
  if (tr.children[i]) this._setCellWidth(tr.children[i], w);
2325
2939
  });
2326
- 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();
2327
2947
  if (bodyRow && bodyRow.children[i]) this._setCellWidth(bodyRow.children[i], w);
2328
2948
  }
2329
2949
 
@@ -2379,10 +2999,15 @@ export class DataTable {
2379
2999
  /** Add a native `title` tooltip to any body cell whose text is
2380
3000
  * clipped by its column width; remove ours once it fits again.
2381
3001
  * Leaves caller-supplied titles (e.g. from renderCell) untouched. */
2382
- _updateCellTooltips() {
3002
+ _updateCellTooltips(rows = null) {
2383
3003
  const bodyTable = this._tableEl;
2384
3004
  if (!bodyTable) return;
2385
- 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;
2386
3011
  const clipped = td.scrollWidth > td.clientWidth + 1;
2387
3012
  if (clipped) {
2388
3013
  if (!td.title) td.title = td.textContent;