flexdesk 0.2.0 → 0.3.0

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.
Files changed (56) hide show
  1. package/css/base.css +1484 -181
  2. package/css/flexdesk.css +1311 -18
  3. package/css/overrides.css +44 -0
  4. package/css/tokens.css +45 -0
  5. package/dist/charts.js +5 -3
  6. package/dist/charts.js.map +1 -1
  7. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  8. package/dist/chunk-ELXVW542.js.map +7 -0
  9. package/dist/chunk-LH5TSOZW.js +1237 -0
  10. package/dist/chunk-LH5TSOZW.js.map +7 -0
  11. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  12. package/dist/chunk-O5OHMWBB.js.map +7 -0
  13. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  14. package/dist/chunk-QIU5S2RU.js.map +7 -0
  15. package/dist/chunk-QNQHQ24V.js +408 -0
  16. package/dist/chunk-QNQHQ24V.js.map +7 -0
  17. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  18. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  19. package/dist/editor.js +3 -380
  20. package/dist/editor.js.map +3 -3
  21. package/dist/flexdesk.css +1311 -18
  22. package/dist/tiles.js +168 -41
  23. package/dist/tiles.js.map +2 -2
  24. package/dist/tokens.css +45 -0
  25. package/dist/widgets.js +44 -14
  26. package/dist/widgets.js.map +2 -2
  27. package/dist/wm.js +2983 -142
  28. package/dist/wm.js.map +4 -4
  29. package/package.json +3 -2
  30. package/src/charts/chart_types.js +167 -0
  31. package/src/charts/plotly_wrapper.js +178 -10
  32. package/src/editor/notebook_tab_bar.js +39 -3
  33. package/src/tiles/tile_base.js +143 -35
  34. package/src/tiles/tile_grid.js +52 -1
  35. package/src/tiling/command_palette.js +71 -18
  36. package/src/tiling/desktops.js +36 -12
  37. package/src/tiling/keymap.js +24 -4
  38. package/src/tiling/shell.js +135 -24
  39. package/src/tiling/tab_strip.js +184 -0
  40. package/src/tiling/tile_breadcrumb.js +34 -2
  41. package/src/tiling/tile_renderer.js +1386 -21
  42. package/src/tiling/tile_tab_menu.js +101 -0
  43. package/src/tiling/tile_tree.js +82 -0
  44. package/src/tiling/wm.js +2352 -74
  45. package/src/ui/components/action_dropdown.js +34 -3
  46. package/src/ui/components/autocomplete_field.js +65 -13
  47. package/src/ui/components/context_menu.js +79 -8
  48. package/src/ui/components/data_table.js +508 -84
  49. package/src/ui/components/managed_window.js +928 -36
  50. package/src/ui/components/modal.js +214 -8
  51. package/dist/chunk-CT4YXXLP.js.map +0 -7
  52. package/dist/chunk-DRYCDMEG.js.map +0 -7
  53. package/dist/chunk-DVU44T77.js.map +0 -7
  54. package/dist/chunk-TLZUUFOE.js.map +0 -7
  55. package/dist/chunk-UCJ2WD4D.js +0 -625
  56. package/dist/chunk-UCJ2WD4D.js.map +0 -7
@@ -7,6 +7,7 @@
7
7
  * - Keyboard shortcuts (Ctrl+A, Ctrl+C, Shift+Ctrl+C, Esc)
8
8
  * - Context menu (copy as TSV/CSV)
9
9
  * - Column sorting (internal or external via callback)
10
+ * - Column drag-to-resize, and double-click-to-auto-size on the same grip
10
11
  * - Per-column filtering (numeric operators, text substring)
11
12
  * - Column type detection (numeric/text alignment)
12
13
  * - Header icons
@@ -21,6 +22,16 @@ import { createRafResizeObserver } from '../utils/raf_resize_observer.js';
21
22
 
22
23
  const DEFAULT_PAGE_SIZE = 100;
23
24
 
25
+ /** How wide auto-sizing a column is allowed to make it.
26
+ *
27
+ * 520px, which is `_fitColumnWidths`' FIRST_CAP — the most generous ceiling
28
+ * this file already sanctions for a single column. Auto-size is an explicit
29
+ * gesture asking to see a column's content, so it is not held to the tighter
30
+ * 360px the automatic fit pass applies to the columns it is squeezing; but it
31
+ * is held to SOMETHING, because one 4,000-character note in one cell would
32
+ * otherwise produce a column nobody can scroll past. */
33
+ const AUTOSIZE_MAX_PX = 520;
34
+
24
35
  /**
25
36
  * Configuration options for DataTable
26
37
  * @typedef {Object} DataTableConfig
@@ -53,6 +64,7 @@ const DEFAULT_PAGE_SIZE = 100;
53
64
  * @property {boolean} [showExportButton=false] - Adds a "CSV" download button to the pagination strip that invokes `downloadCSV()`.
54
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.
55
66
  * @property {Object} [stateStore] - `{ ready(), get(key), set(key, state) }` — REQUIRED when `persistKey` is set. Built by `createTableStateStore()`.
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.
56
68
  */
57
69
 
58
70
  export class DataTable {
@@ -104,6 +116,12 @@ export class DataTable {
104
116
  // ephemeral. WHERE the store persists is the embedder's business.
105
117
  persistKey: null,
106
118
  stateStore: null,
119
+ // How the automatic fit pass sizes an undragged column — see the
120
+ // typedef. 'container' is exactly what every consumer got before
121
+ // this key existed, so the default is not a preference: it is the
122
+ // promise that adding the key changed no existing layout by a
123
+ // pixel. Only a consumer that asks for 'content' sees anything new.
124
+ columnFit: 'container',
107
125
  ...config,
108
126
  };
109
127
 
@@ -1536,42 +1554,7 @@ export class DataTable {
1536
1554
  tr.classList.add('selected');
1537
1555
  }
1538
1556
 
1539
- if (showRowNumbers) {
1540
- const td = document.createElement('td');
1541
- td.className = 'num';
1542
- td.textContent = String(globalIdx + 1);
1543
- tr.appendChild(td);
1544
- }
1545
-
1546
- for (let colIdx = 0; colIdx < row.length; colIdx++) {
1547
- const td = document.createElement('td');
1548
- td.className = this._columnTypes[colIdx] || 'text';
1549
-
1550
- // Use custom cell renderer if provided
1551
- const value = row[colIdx];
1552
- if (this.config.renderCell) {
1553
- const handled = this.config.renderCell(td, value, colIdx, globalIdx, row);
1554
- if (!handled) {
1555
- td.textContent = this._formatValue(value, colIdx);
1556
- }
1557
- } else {
1558
- td.textContent = this._formatValue(value, colIdx);
1559
- }
1560
-
1561
- // Cell-level right-click hook (P2). Fires before the
1562
- // row-level hook and the built-in context menu — the
1563
- // caller can ev.preventDefault() to suppress the
1564
- // default copy menu on this cell only.
1565
- if (this.config.onCellContextMenu) {
1566
- const cellColIdx = colIdx;
1567
- td.addEventListener('contextmenu', (ev) => {
1568
- this.config.onCellContextMenu(
1569
- cellColIdx, globalIdx, value, td, ev);
1570
- });
1571
- }
1572
-
1573
- tr.appendChild(td);
1574
- }
1557
+ this._fillRowCells(tr, row, globalIdx, showRowNumbers);
1575
1558
 
1576
1559
  // Row-level click + right-click hooks (P2). Bound after
1577
1560
  // cells so per-cell handlers run first.
@@ -1595,30 +1578,183 @@ export class DataTable {
1595
1578
  return table;
1596
1579
  }
1597
1580
 
1581
+ /** Build (or rebuild) one row's cells in place.
1582
+ *
1583
+ * Extracted from the body loop so that `updateRow` and the initial
1584
+ * render share ONE cell-building path. Two paths would drift, and the
1585
+ * drift would show as a cell that renders differently after a live
1586
+ * update than it did on load. */
1587
+ _fillRowCells(tr, row, globalIdx, showRowNumbers) {
1588
+ tr.replaceChildren();
1589
+
1590
+ if (showRowNumbers) {
1591
+ const td = document.createElement('td');
1592
+ td.className = 'num';
1593
+ td.textContent = String(globalIdx + 1);
1594
+ tr.appendChild(td);
1595
+ }
1596
+
1597
+ for (let colIdx = 0; colIdx < row.length; colIdx++) {
1598
+ const td = document.createElement('td');
1599
+ td.className = this._columnTypes[colIdx] || 'text';
1600
+
1601
+ // Use custom cell renderer if provided
1602
+ const value = row[colIdx];
1603
+ if (this.config.renderCell) {
1604
+ const handled = this.config.renderCell(td, value, colIdx, globalIdx, row);
1605
+ if (!handled) {
1606
+ td.textContent = this._formatValue(value, colIdx);
1607
+ }
1608
+ } else {
1609
+ td.textContent = this._formatValue(value, colIdx);
1610
+ }
1611
+
1612
+ // Cell-level right-click hook (P2). Fires before the row-level
1613
+ // hook and the built-in context menu — the caller can
1614
+ // ev.preventDefault() to suppress the default copy menu on this
1615
+ // cell only.
1616
+ if (this.config.onCellContextMenu) {
1617
+ const cellColIdx = colIdx;
1618
+ td.addEventListener('contextmenu', (ev) => {
1619
+ this.config.onCellContextMenu(
1620
+ cellColIdx, globalIdx, value, td, ev);
1621
+ });
1622
+ }
1623
+
1624
+ tr.appendChild(td);
1625
+ }
1626
+ }
1627
+
1628
+ /** Re-render ONE row in place, preserving everything around it.
1629
+ *
1630
+ * `render()` rebuilds the entire `<tbody>`, which takes the scroll
1631
+ * position, any open editor, the keyboard focus and the measured column
1632
+ * widths with it. That is fine for a sort or a page change and wrong for
1633
+ * a single-cell commit or a live update arriving over a socket — the
1634
+ * common case in an editable grid, where a full rebuild once per keystroke
1635
+ * is both visible and destructive.
1636
+ *
1637
+ * What survives, by construction:
1638
+ * - scroll position, because the tbody is not replaced;
1639
+ * - the separately-rendered thead/tbody column widths, because the
1640
+ * explicit widths live on the header cells and on the FIRST body row,
1641
+ * and all three of the properties `_setCellWidth` writes are re-applied
1642
+ * here when that first row is the one being replaced;
1643
+ * - selection, because the `selected` class is recomputed from the
1644
+ * selection set rather than carried on the old element;
1645
+ * - keyboard focus, because the focused element's position is recorded
1646
+ * before the replace and restored after.
1647
+ *
1648
+ * @param {number} index row index as tracked by `tr.__rowIndex`
1649
+ * @param {any[]} row the new cell values
1650
+ * @returns {boolean} false when the row is not currently rendered
1651
+ * (it is on another page, or outside the render
1652
+ * window) — which is NOT an error: the caller has
1653
+ * nothing to update on screen.
1654
+ */
1655
+ updateRow(index, row) {
1656
+ const tbody = this._tbodyEl;
1657
+ if (!tbody) return false;
1658
+
1659
+ let tr = null;
1660
+ for (const candidate of tbody.children) {
1661
+ if (candidate.__rowIndex === index) { tr = candidate; break; }
1662
+ }
1663
+ if (!tr) return false;
1664
+
1665
+ // Keep the caller's data in step, so a later full render agrees with
1666
+ // what is on screen. Server-side paging means config.rows may hold only
1667
+ // the current page; the index map is what resolves that.
1668
+ if (Array.isArray(this.config.rows) && this.config.rows[index]) {
1669
+ this.config.rows[index] = row;
1670
+ }
1671
+
1672
+ // Record focus BEFORE the replace: `replaceChildren` detaches the
1673
+ // focused cell and the browser moves focus to <body>.
1674
+ const active = document.activeElement;
1675
+ let focusedCol = -1;
1676
+ if (active && tr.contains(active)) {
1677
+ focusedCol = Array.prototype.indexOf.call(tr.children, active.closest('td'));
1678
+ }
1679
+
1680
+ // ALL THREE PROPERTIES, not just `width`. `_setCellWidth` writes
1681
+ // `width` together with `min-width: 0` and `max-width: none`, precisely
1682
+ // to escape the CSS floors on these cells (base.css pins the first
1683
+ // column at 120px/min 100px and gives every other column a 80px min).
1684
+ // Carrying the width across the rebuild and leaving the other two behind
1685
+ // let those floors back in — so a column measured narrower than its
1686
+ // floor SNAPPED WIDER the moment one of its cells was repainted, and
1687
+ // under `table-layout: fixed` this row is the column track, so the whole
1688
+ // column moved. In an editable grid that is one visible jump per saved
1689
+ // cell, which is what it looked like to the person doing the saving.
1690
+ const isFirstRow = tr === tbody.firstElementChild;
1691
+ const widths = isFirstRow
1692
+ ? Array.prototype.map.call(tr.children, (td) => ({
1693
+ width: td.style.width,
1694
+ minWidth: td.style.minWidth,
1695
+ maxWidth: td.style.maxWidth,
1696
+ }))
1697
+ : null;
1698
+
1699
+ const showRowNumbers = this.config.showRowNumbers !== false
1700
+ && tr.firstElementChild?.classList.contains('num');
1701
+ this._fillRowCells(tr, row, index, showRowNumbers);
1702
+
1703
+ // `_syncHeaderWidths` derives column widths from the FIRST body row.
1704
+ // Under a recycling virtualiser that row changes on every scroll — and
1705
+ // here it changes on every update — so the explicit widths have to be
1706
+ // carried across the rebuild or the columns jitter.
1707
+ if (widths) {
1708
+ widths.forEach((saved, i) => {
1709
+ const cell = tr.children[i];
1710
+ if (!saved.width || !cell) return;
1711
+ cell.style.width = saved.width;
1712
+ cell.style.minWidth = saved.minWidth;
1713
+ cell.style.maxWidth = saved.maxWidth;
1714
+ });
1715
+ }
1716
+
1717
+ tr.classList.toggle('selected', this._state?.selected?.has(index) === true);
1718
+
1719
+ if (focusedCol >= 0 && tr.children[focusedCol]) {
1720
+ tr.children[focusedCol].focus?.();
1721
+ }
1722
+ return true;
1723
+ }
1724
+
1598
1725
  /** Sync the (separate) header table's column widths to the body
1599
1726
  * table's measured widths. Without this, the two tables compute
1600
1727
  * widths independently and the columns drift apart. Locks both
1601
1728
  * tables to `table-layout: fixed` and writes explicit width onto
1602
1729
  * each header cell of every header row (header + filter row). */
1603
- _syncHeaderWidths() {
1730
+ /**
1731
+ * Measure every column's NATURAL content width, in one transient reflow.
1732
+ *
1733
+ * Extracted from `_syncHeaderWidths` so that auto-size can ask the same
1734
+ * question the fit pass asks, and get the same answer. Two measurement
1735
+ * passes would drift, and the drift would show as a double-click that
1736
+ * sized a column differently from the render that follows it.
1737
+ *
1738
+ * The pass ignores the CSS caps (the 150px-pinned first column, the 80px
1739
+ * min on the rest) and the filter-row inputs: `twm-dt-measuring` flips both
1740
+ * tables to `table-layout:auto; width:max-content` with those caps off (via
1741
+ * `!important`) for a single reflow, then reverts before paint — it is
1742
+ * never visible. Clearing inline widths first stops the last sync's forced
1743
+ * widths from constraining the measure.
1744
+ *
1745
+ * @returns {number[]|null} width per DOM column index, or null when there
1746
+ * is nothing laid out to measure.
1747
+ */
1748
+ _measureNaturalWidths() {
1604
1749
  const headerTable = this._headerTableEl;
1605
1750
  const bodyTable = this._tableEl;
1606
- if (!headerTable || !bodyTable) return;
1751
+ if (!headerTable || !bodyTable) return null;
1607
1752
  const firstRow = bodyTable.querySelector('tbody > tr');
1608
- if (!firstRow) return;
1753
+ if (!firstRow) return null;
1609
1754
  const bodyCells = firstRow.children;
1610
- if (!bodyCells.length) return;
1611
-
1612
- const headerRows = headerTable.querySelectorAll('thead > tr');
1755
+ if (!bodyCells.length) return null;
1613
1756
 
1614
- // ── Measure pass: let every column expand to its NATURAL content
1615
- // width, ignoring the CSS caps (the 150px-pinned first column, the
1616
- // 80px min on the rest) and the filter-row inputs. `dt-measuring`
1617
- // flips both tables to `table-layout:auto; width:max-content` with
1618
- // those caps off (via `!important`) for a single reflow; we read
1619
- // the widths, then revert. Clearing inline widths first stops the
1620
- // last sync's forced widths from constraining the measure.
1621
- headerRows.forEach((tr) => {
1757
+ headerTable.querySelectorAll('thead > tr').forEach((tr) => {
1622
1758
  for (const th of tr.children) this._clearCellWidth(th);
1623
1759
  });
1624
1760
  for (const td of bodyCells) this._clearCellWidth(td);
@@ -1636,16 +1772,154 @@ export class DataTable {
1636
1772
  const labelRow = headerTable.querySelector('thead > tr');
1637
1773
  const cols = bodyCells.length;
1638
1774
  const natural = new Array(cols);
1775
+ // ── AND CSS `zoom` IS DIVIDED BACK OUT ─────────────────────────
1776
+ //
1777
+ // `getBoundingClientRect()` reports CLIENT pixels, so under a `zoom`
1778
+ // anywhere above this table every width read here comes back multiplied
1779
+ // by that zoom — while `_setCellWidth` writes a plain `width` in the
1780
+ // cell's OWN pixels, which the same zoom then multiplies again. The two
1781
+ // halves of one measure-and-apply loop would be in different units:
1782
+ // measured at 200% a column is pinned twice as wide as its text needs,
1783
+ // and at 50% it is pinned half as wide and every value ellipsises. The
1784
+ // fit pass has the same split, because its `avail` is `clientWidth`,
1785
+ // which is NOT zoom-adjusted.
1786
+ //
1787
+ // `currentCSSZoom` is the effective zoom on this element — 1 when
1788
+ // nothing above it zooms, and `undefined` in an engine that predates
1789
+ // the property — so this is exactly a no-op for every consumer that
1790
+ // does not zoom, which is all of them but the Tables grid.
1791
+ const zoom = bodyTable.currentCSSZoom || 1;
1639
1792
  for (let i = 0; i < cols; i++) {
1640
- const body = bodyCells[i].getBoundingClientRect().width;
1793
+ const body = bodyCells[i].getBoundingClientRect().width / zoom;
1641
1794
  const head = labelRow && labelRow.children[i]
1642
- ? labelRow.children[i].getBoundingClientRect().width
1795
+ ? labelRow.children[i].getBoundingClientRect().width / zoom
1643
1796
  : 0;
1644
1797
  natural[i] = Math.max(body, head);
1645
1798
  }
1646
1799
 
1647
1800
  headerTable.classList.remove('twm-dt-measuring');
1648
1801
  bodyTable.classList.remove('twm-dt-measuring');
1802
+ return natural;
1803
+ }
1804
+
1805
+ /**
1806
+ * Fit one column to its widest visible value and pin it there.
1807
+ *
1808
+ * The gesture is a double-click on the column's resize grip, which is where
1809
+ * every spreadsheet has put it — and the grip is this component's, which is
1810
+ * why the behaviour is too. A consumer that wanted this had no hook to hang
1811
+ * it on: `_installColumnResizers` bound `mousedown` and a `click` that only
1812
+ * suppressed the sort, and the measurement, the pin, the table-width
1813
+ * invariant and the write-through to `stateStore` are all private here.
1814
+ *
1815
+ * @param {number} domIdx column index INCLUDING the row-number column when
1816
+ * `showRowNumbers` is on — the same index space as `_colWidths`.
1817
+ * @param {{maxWidth?: number}} [opts] ceiling; defaults to AUTOSIZE_MAX_PX.
1818
+ * @returns {boolean} whether it had a layout to measure.
1819
+ */
1820
+ autoSizeColumn(domIdx, opts = {}) {
1821
+ const natural = this._measureNaturalWidths();
1822
+ if (!natural || domIdx < 0 || domIdx >= natural.length) return false;
1823
+ this._colWidths[domIdx] = this._autoWidth(natural[domIdx], opts);
1824
+ this._colWidthsSig = this._colSig();
1825
+ this._savePersisted();
1826
+ this._syncHeaderWidths();
1827
+ return true;
1828
+ }
1829
+
1830
+ /**
1831
+ * The same for every column at once.
1832
+ *
1833
+ * It REPLACES existing drag overrides rather than sizing around them: "fit
1834
+ * every column to its content" that quietly excepted the three columns you
1835
+ * had dragged would be a button whose result depends on history nobody can
1836
+ * see.
1837
+ *
1838
+ * The result may well be wider than the container — forty columns of real
1839
+ * content usually are — and that is the intended answer, not a failure:
1840
+ * `_syncHeaderWidths` honours overrides verbatim, sizes both tables to their
1841
+ * total and the body wrap scrolls sideways with the header following it.
1842
+ * Squeezing them to fit is what the automatic fit pass does on every render
1843
+ * already, so a button that did that would be a button that does nothing.
1844
+ */
1845
+ autoSizeColumns(opts = {}) {
1846
+ const natural = this._measureNaturalWidths();
1847
+ if (!natural) return false;
1848
+ this._colWidths = {};
1849
+ for (let i = 0; i < natural.length; i++) {
1850
+ this._colWidths[i] = this._autoWidth(natural[i], opts);
1851
+ }
1852
+ this._colWidthsSig = this._colSig();
1853
+ this._savePersisted();
1854
+ this._syncHeaderWidths();
1855
+ return true;
1856
+ }
1857
+
1858
+ /** One measured width, floored at the drag-resize minimum and capped, with
1859
+ * the same sub-pixel pad `_fitColumnWidths` adds against ellipsis. */
1860
+ _autoWidth(natural, opts = {}) {
1861
+ const max = opts.maxWidth ?? AUTOSIZE_MAX_PX;
1862
+ return Math.min(max, Math.max(40, Math.ceil((natural || 0) + 2)));
1863
+ }
1864
+
1865
+ /**
1866
+ * Does every one of the `n` DOM columns already carry an override taken
1867
+ * against the column set that is on screen right now?
1868
+ *
1869
+ * The signature half is not belt-and-braces. `render()` drops `_colWidths`
1870
+ * when `_colSig` changes, but `_syncHeaderWidths` is also reached straight
1871
+ * from the ResizeObserver, which never goes through `render()` — so a set
1872
+ * of overrides measured against the PREVIOUS headers can still be sitting
1873
+ * in `_colWidths` when this is asked. Answering "pinned" then would skip
1874
+ * the measure and hand `_fitColumnWidths` widths keyed to columns that are
1875
+ * no longer there, each one landing on its neighbour.
1876
+ *
1877
+ * `== null` rather than a falsy test, to match `_fitColumnWidths`' own
1878
+ * `overrides[i] != null`: a legitimately-stored 0 must be read the same way
1879
+ * in both places or the two disagree about which columns are flexible.
1880
+ */
1881
+ _allColumnsPinned(n) {
1882
+ if (!n || this._colWidthsSig !== this._colSig()) return false;
1883
+ for (let i = 0; i < n; i++) {
1884
+ if (this._colWidths[i] == null) return false;
1885
+ }
1886
+ return true;
1887
+ }
1888
+
1889
+ /** Sync the (separate) header table's column widths to the body
1890
+ * table's measured widths. Without this, the two tables compute
1891
+ * widths independently and the columns drift apart. Locks both
1892
+ * tables to `table-layout: fixed` and writes explicit width onto
1893
+ * each header cell of every header row (header + filter row). */
1894
+ _syncHeaderWidths() {
1895
+ const headerTable = this._headerTableEl;
1896
+ const bodyTable = this._tableEl;
1897
+ if (!headerTable || !bodyTable) return;
1898
+ const firstRow = bodyTable.querySelector('tbody > tr');
1899
+ if (!firstRow) return;
1900
+ const bodyCells = firstRow.children;
1901
+ if (!bodyCells.length) return;
1902
+
1903
+ const headerRows = headerTable.querySelectorAll('thead > tr');
1904
+ // DO NOT MEASURE WHAT NOTHING WILL READ.
1905
+ //
1906
+ // `_measureNaturalWidths` flips BOTH tables to
1907
+ // `table-layout:auto; width:max-content` with the CSS caps lifted, for
1908
+ // a full synchronous reflow (`.twm-dt-measuring`, css/base.css). This
1909
+ // method is the ResizeObserver's callback, so that reflow fires once
1910
+ // per animation frame for the whole of a splitter drag — and an
1911
+ // embedder that pages a thousand rows at a time across twenty columns
1912
+ // re-lays-out ~20,000 cells per frame for it.
1913
+ //
1914
+ // When every column is pinned, `_fitColumnWidths` reads `natural[i]`
1915
+ // for no `i` at all: each column takes its override verbatim, and the
1916
+ // only thing left that the array supplies is `n`, which is its length.
1917
+ // So zeroes of the right length are not an approximation of the
1918
+ // measurement, they are indistinguishable from it.
1919
+ const natural = this._allColumnsPinned(bodyCells.length)
1920
+ ? new Array(bodyCells.length).fill(0)
1921
+ : this._measureNaturalWidths();
1922
+ if (!natural) return;
1649
1923
 
1650
1924
  // ── Fit pass: turn natural widths into final widths that respect
1651
1925
  // the available space, favour the first column, and cap runaways.
@@ -1653,10 +1927,11 @@ export class DataTable {
1653
1927
  const wrap = this._tableWrapEl;
1654
1928
  const headerWrap = this._headerWrapEl;
1655
1929
  const avail = wrap ? wrap.clientWidth : 0;
1656
- const firstIdx = this.config.showRowNumbers && cols > 1 ? 1 : 0;
1930
+ const firstIdx = this.config.showRowNumbers && bodyCells.length > 1 ? 1 : 0;
1657
1931
  const widths = this._fitColumnWidths(natural, avail, {
1658
1932
  firstIdx,
1659
1933
  overrides: this._colWidths,
1934
+ fit: this.config.columnFit,
1660
1935
  });
1661
1936
  const total = widths.reduce((a, b) => a + b, 0);
1662
1937
 
@@ -1696,9 +1971,23 @@ export class DataTable {
1696
1971
  // Scrollbar gutter: when the body shows a vertical scrollbar, its
1697
1972
  // content is narrower than the wrap by `scrollbarW`. The header
1698
1973
  // wrap has no scrollbar, so pad it on the right to match.
1974
+ //
1975
+ // C29b. AND THE SAME NUMBER IS PUBLISHED AS A CUSTOM PROPERTY, because
1976
+ // padding does not help the one thing C29 was for. A `position: sticky`
1977
+ // cell pins to its scroll container's SCROLLPORT, and the scrollport of
1978
+ // a box with a classic scrollbar excludes that scrollbar while padding
1979
+ // is inside it — so `right: 0` in the body pins `sbw` px to the LEFT of
1980
+ // `right: 0` in the header, and a pinned trailing column would sit
1981
+ // visibly out of line with its own heading. There is nothing a
1982
+ // stylesheet can measure this from: it is the platform's scrollbar
1983
+ // width, known here and nowhere else. Written on the WRAPPER rather
1984
+ // than either box so a consumer's rule can read it from whichever of
1985
+ // the two it is styling, and written on every sync because a scrollbar
1986
+ // appears and disappears with the row count.
1699
1987
  if (wrap && headerWrap) {
1700
1988
  const sbw = Math.max(0, wrap.offsetWidth - wrap.clientWidth);
1701
1989
  headerWrap.style.paddingRight = sbw ? `${sbw}px` : '';
1990
+ this._wrapperEl?.style?.setProperty('--twm-dt-gutter', `${sbw}px`);
1702
1991
  }
1703
1992
 
1704
1993
  // Keep the header aligned with the body's current horizontal
@@ -1724,9 +2013,17 @@ export class DataTable {
1724
2013
  * the rest (trim the widest first) until it fits; only if even the
1725
2014
  * floors overflow do we give up and let the body scroll sideways.
1726
2015
  *
2016
+ * Under `fit: 'content'` the first and last of those goals invert: each
2017
+ * unpinned column asks for its own content width through the same
2018
+ * `_autoWidth` the grip double-click uses, and an overflow is the answer
2019
+ * rather than a problem — the caller wanted a table that scrolls sideways,
2020
+ * not one squeezed toward the floor. The grow-to-fill branch is shared by
2021
+ * both, because a table narrower than its container leaves a dead gap on
2022
+ * the right under either reading.
2023
+ *
1727
2024
  * @param {number[]} natural measured content width per column (px)
1728
2025
  * @param {number} avail usable width of the body wrap (px)
1729
- * @param {{firstIdx?:number, overrides?:Object}} [opts]
2026
+ * @param {{firstIdx?:number, overrides?:Object, fit?:'container'|'content'}} [opts]
1730
2027
  * @returns {number[]} final width per column (px)
1731
2028
  */
1732
2029
  _fitColumnWidths(natural, avail, opts = {}) {
@@ -1738,6 +2035,10 @@ export class DataTable {
1738
2035
  const n = natural.length;
1739
2036
  const firstIdx = opts.firstIdx ?? 0;
1740
2037
  const overrides = opts.overrides || {};
2038
+ // Anything that is not the literal opt-in is the historical behaviour.
2039
+ // Read once, so an embedder that passes a typo gets the old layout
2040
+ // rather than half of each.
2041
+ const toContent = opts.fit === 'content';
1741
2042
 
1742
2043
  // Desired (pre-fit) width per column. Pinned columns take their
1743
2044
  // override verbatim and sit out the grow/shrink redistribution.
@@ -1749,6 +2050,20 @@ export class DataTable {
1749
2050
  pinned[i] = true;
1750
2051
  continue;
1751
2052
  }
2053
+ if (toContent) {
2054
+ // THE SAME CALL THE GRIP DOUBLE-CLICK MAKES — `autoSizeColumn`
2055
+ // → `_autoWidth` — and deliberately not a second arithmetic
2056
+ // that happens to agree with it today. Two width formulas
2057
+ // drift, and the drift shows up as a column that jumps the
2058
+ // first time anyone double-clicks its grip.
2059
+ //
2060
+ // FIRST_CAP has nothing to say here. It exists to favour one
2061
+ // column while the others are being squeezed; under a content
2062
+ // fit nothing is being squeezed, so there is no column to
2063
+ // favour and every one of them is held to the same ceiling.
2064
+ desired[i] = this._autoWidth(natural[i]);
2065
+ continue;
2066
+ }
1752
2067
  const cap = i === firstIdx ? FIRST_CAP : CAP;
1753
2068
  desired[i] = Math.min(cap, Math.max(FLOOR, Math.ceil(natural[i] + PAD)));
1754
2069
  }
@@ -1762,18 +2077,33 @@ export class DataTable {
1762
2077
 
1763
2078
  if (sum <= avail) {
1764
2079
  // Grow flexible columns evenly to fill the remaining space so
1765
- // the table doesn't leave a dead gap on the right.
2080
+ // the table doesn't leave a dead gap on the right. When EVERY
2081
+ // column is pinned (e.g. after the user has dragged a column —
2082
+ // the resize freezes all columns as overrides), fall back to
2083
+ // the last column so the table still spans the full container:
2084
+ // the width invariant — a table never renders narrower than
2085
+ // 100% of its wrap.
1766
2086
  const flex = [];
1767
2087
  for (let i = 0; i < n; i++) if (!pinned[i]) flex.push(i);
1768
2088
  const slack = avail - sum;
1769
- if (slack > 0 && flex.length) {
1770
- const per = Math.floor(slack / flex.length);
1771
- for (const i of flex) widths[i] += per;
1772
- widths[flex[flex.length - 1]] += slack - per * flex.length;
2089
+ if (slack > 0) {
2090
+ const targets = flex.length ? flex : [n - 1];
2091
+ const per = Math.floor(slack / targets.length);
2092
+ for (const i of targets) widths[i] += per;
2093
+ widths[targets[targets.length - 1]] += slack - per * targets.length;
1773
2094
  }
1774
2095
  return widths;
1775
2096
  }
1776
2097
 
2098
+ // OVERFLOW IS THE ANSWER UNDER A CONTENT FIT, not a case to recover
2099
+ // from. Everything below squeezes the unprotected columns toward the
2100
+ // 40px floor so that forty columns can be made to fit a pane — which is
2101
+ // precisely the outcome `columnFit: 'content'` opted out of. Handing
2102
+ // back the content widths lets the caller's `total > avail + 1` test
2103
+ // size both tables to the total, and the body wrap then scrolls
2104
+ // sideways with the header following it.
2105
+ if (toContent) return widths;
2106
+
1777
2107
  // Overflow: protect the first column + pinned columns, water-fill
1778
2108
  // the rest down toward the floor.
1779
2109
  let protectedSum = 0;
@@ -1834,8 +2164,9 @@ export class DataTable {
1834
2164
 
1835
2165
  /** Wire the body wrap's horizontal scroll to the header. The header
1836
2166
  * sits in an overflow:hidden wrap, so it can't scroll sideways on
1837
- * its own — translate it by the body's scrollLeft. Re-installed each
1838
- * render against the freshly-built wrap. */
2167
+ * its own — `_syncHeaderScroll` scrolls it programmatically to the
2168
+ * body's `scrollLeft`. Re-installed each render against the freshly-built
2169
+ * wrap. */
1839
2170
  _installHeaderScrollSync() {
1840
2171
  const wrap = this._tableWrapEl;
1841
2172
  if (!wrap) return;
@@ -1848,14 +2179,58 @@ export class DataTable {
1848
2179
  this._syncHeaderScroll();
1849
2180
  }
1850
2181
 
1851
- /** Translate the header table to match the body's horizontal scroll
1852
- * so the columns stay aligned when the table overflows sideways. */
2182
+ /**
2183
+ * Scroll the header wrap to match the body's horizontal scroll so the
2184
+ * columns stay aligned when the table overflows sideways.
2185
+ *
2186
+ * ══ C29. A TRANSFORM IS WHAT MAKES A STICKY COLUMN IMPOSSIBLE ═══════
2187
+ *
2188
+ * This wrote `headerTable.style.transform = translateX(-scrollLeft)`. It
2189
+ * aligns the two tables perfectly and it forecloses `position: sticky`
2190
+ * entirely, because the two halves of the table then pin against different
2191
+ * things: a sticky cell in the BODY pins to the body wrap's scrollport,
2192
+ * while its header counterpart is moved by a transform inside a box that
2193
+ * never scrolls at all. Scroll sideways and the pinned body column stands
2194
+ * still while its header slides away with everything else — so a table
2195
+ * with a trailing verb column (the row-actions column Tables needs pinned
2196
+ * to the right edge) can have a sticky body or an aligned header, never
2197
+ * both. A transform also establishes a containing block for fixed/sticky
2198
+ * descendants, which breaks the header cell independently of the offset.
2199
+ *
2200
+ * AN `overflow: hidden` BOX IS STILL A SCROLL CONTAINER. It has no
2201
+ * scrollbar and no user affordance, and `scrollLeft` moves it exactly like
2202
+ * any other. Scrolling the wrap rather than transforming its child gives
2203
+ * the header a real scrollport at the same offset as the body's, which is
2204
+ * the one arrangement in which a sticky cell on each side pins to the same
2205
+ * place. Identical for ordinary content: same pixels, no transform, no new
2206
+ * containing block.
2207
+ *
2208
+ * THE CLAMP IS ALREADY PAID FOR. A scroll container clamps `scrollLeft` to
2209
+ * `scrollWidth - clientWidth`, and the body wrap carries a vertical
2210
+ * scrollbar the header wrap does not — so the header would clamp short by
2211
+ * the scrollbar width and desync at the far right. `_syncHeaderWidths`
2212
+ * already pads the header wrap by exactly that gutter (`paddingRight`,
2213
+ * :1905), and end padding counts toward `scrollWidth`, so the two maxima
2214
+ * coincide. The residual below is the honest belt for the day some engine
2215
+ * disagrees about that: it is zero in the ordinary case, so the transform
2216
+ * is not set and sticky keeps working, and it is the difference rather
2217
+ * than the whole offset if it is ever not.
2218
+ */
1853
2219
  _syncHeaderScroll() {
1854
2220
  const wrap = this._tableWrapEl;
1855
2221
  const headerTable = this._headerTableEl;
1856
2222
  if (!wrap || !headerTable) return;
1857
2223
  const x = wrap.scrollLeft;
1858
- headerTable.style.transform = x ? `translateX(${-x}px)` : '';
2224
+ const headerWrap = this._headerWrapEl;
2225
+ if (!headerWrap) {
2226
+ // No wrap to scroll — a consumer that built the header some other
2227
+ // way still gets the behaviour it always had.
2228
+ headerTable.style.transform = x ? `translateX(${-x}px)` : '';
2229
+ return;
2230
+ }
2231
+ headerWrap.scrollLeft = x;
2232
+ const residual = x - headerWrap.scrollLeft;
2233
+ headerTable.style.transform = residual ? `translateX(${-residual}px)` : '';
1859
2234
  }
1860
2235
 
1861
2236
  /** Drag-to-resize: hang a thin grab handle off the right edge of
@@ -1875,6 +2250,15 @@ export class DataTable {
1875
2250
  (ev) => this._beginColResize(ev, domIdx));
1876
2251
  // Keep a resize gesture from registering as a sort click.
1877
2252
  grip.addEventListener('click', (ev) => ev.stopPropagation());
2253
+ // Double-click the grip: fit the column to its content. The gesture
2254
+ // every spreadsheet has, on the affordance it has always been on.
2255
+ // Stopped as well as prevented, or a consumer that opens a column
2256
+ // menu from the header (Tables does) opens it on the way past.
2257
+ grip.addEventListener('dblclick', (ev) => {
2258
+ ev.preventDefault();
2259
+ ev.stopPropagation();
2260
+ this.autoSizeColumn(domIdx);
2261
+ });
1878
2262
  th.appendChild(grip);
1879
2263
  });
1880
2264
  }
@@ -1885,7 +2269,6 @@ export class DataTable {
1885
2269
  const headerTable = this._headerTableEl;
1886
2270
  const bodyTable = this._tableEl;
1887
2271
  const headRow = headerTable && headerTable.querySelector('thead > tr');
1888
- const bodyRow = bodyTable && bodyTable.querySelector('tbody > tr');
1889
2272
  if (!headRow) return;
1890
2273
 
1891
2274
  // Freeze EVERY column at its current width on BOTH tables and
@@ -1897,16 +2280,7 @@ export class DataTable {
1897
2280
  (c) => c.getBoundingClientRect().width);
1898
2281
  headerTable.style.tableLayout = 'fixed';
1899
2282
  if (bodyTable) bodyTable.style.tableLayout = 'fixed';
1900
- const applyCol = (i, w) => {
1901
- this._colWidths[i] = w;
1902
- headerTable.querySelectorAll('thead > tr').forEach((tr) => {
1903
- if (tr.children[i]) this._setCellWidth(tr.children[i], w);
1904
- });
1905
- if (bodyRow && bodyRow.children[i]) {
1906
- this._setCellWidth(bodyRow.children[i], w);
1907
- }
1908
- };
1909
- startWidths.forEach((w, i) => applyCol(i, w));
2283
+ startWidths.forEach((w, i) => this._pinColumnWidth(i, w));
1910
2284
  this._applyTableWidth();
1911
2285
 
1912
2286
  const startX = ev.clientX;
@@ -1915,8 +2289,11 @@ export class DataTable {
1915
2289
  const onMove = (mv) => {
1916
2290
  const w = Math.max(
1917
2291
  MIN, Math.round(startWidths[domIdx] + (mv.clientX - startX)));
1918
- applyCol(domIdx, w);
1919
- this._applyTableWidth();
2292
+ this._pinColumnWidth(domIdx, w);
2293
+ // Pass the dragged column so the fill invariant reclaims any
2294
+ // freed width into a DIFFERENT (flexible/last) column, never
2295
+ // shrinking the table below the container.
2296
+ this._applyTableWidth(domIdx);
1920
2297
  this._syncHeaderScroll();
1921
2298
  };
1922
2299
  const onUp = () => {
@@ -1930,19 +2307,66 @@ export class DataTable {
1930
2307
  document.addEventListener('mouseup', onUp);
1931
2308
  }
1932
2309
 
1933
- /** Size both tables to the sum of the header row's explicit column
1934
- * widths so a widened column grows the table (→ horizontal scroll)
1935
- * rather than stealing from its neighbours. */
1936
- _applyTableWidth() {
2310
+ /** Pin one column to an explicit width across BOTH tables (every
2311
+ * header row + the body's first row) and record it as a user
2312
+ * override so `_syncHeaderWidths` honors it on later renders. The
2313
+ * single place that writes a column width during a drag / fill. */
2314
+ _pinColumnWidth(i, w) {
2315
+ const headerTable = this._headerTableEl;
2316
+ const bodyTable = this._tableEl;
2317
+ if (!headerTable) return;
2318
+ this._colWidths[i] = w;
2319
+ headerTable.querySelectorAll('thead > tr').forEach((tr) => {
2320
+ if (tr.children[i]) this._setCellWidth(tr.children[i], w);
2321
+ });
2322
+ const bodyRow = bodyTable && bodyTable.querySelector('tbody > tr');
2323
+ if (bodyRow && bodyRow.children[i]) this._setCellWidth(bodyRow.children[i], w);
2324
+ }
2325
+
2326
+ /** Size both tables so a widened column grows the table (→ horizontal
2327
+ * scroll) rather than stealing from its neighbours — while enforcing
2328
+ * the TABLE WIDTH INVARIANT: the table is never narrower than its
2329
+ * container. Any width freed by dragging a column in stretches a
2330
+ * flexible column (the last one, stepping off the column being
2331
+ * dragged) so the table always spans ≥100% of the wrap.
2332
+ *
2333
+ * @param {number|null} dragIdx column the user is actively dragging,
2334
+ * so the fill lands on a DIFFERENT column and doesn't fight the
2335
+ * drag. Null (non-drag callers) lets the last column flex. */
2336
+ _applyTableWidth(dragIdx = null) {
1937
2337
  const headerTable = this._headerTableEl;
1938
2338
  const bodyTable = this._tableEl;
1939
2339
  const headRow = headerTable && headerTable.querySelector('thead > tr');
1940
2340
  if (!headRow) return;
1941
- let total = 0;
1942
- for (const th of headRow.children) {
2341
+ const cells = [...headRow.children];
2342
+ const cols = cells.length;
2343
+ if (cols === 0) return;
2344
+
2345
+ const FLOOR = 40; // matches the drag-resize minimum
2346
+ const widthOf = (th) => {
1943
2347
  const px = parseFloat(th.style.width);
1944
- total += Number.isFinite(px) ? px : th.getBoundingClientRect().width;
2348
+ return Number.isFinite(px) ? px : th.getBoundingClientRect().width;
2349
+ };
2350
+ const cur = cells.map(widthOf);
2351
+
2352
+ // Fill invariant: pick a flexible column and stretch it to soak up
2353
+ // any freed width so the summed total is never below the container.
2354
+ const wrap = this._tableWrapEl;
2355
+ const avail = wrap ? wrap.clientWidth : 0;
2356
+ let flexIdx = cols - 1;
2357
+ if (dragIdx != null && flexIdx === dragIdx) flexIdx -= 1; // step off the dragged col
2358
+ if (avail > 1 && flexIdx >= 0 && flexIdx !== dragIdx) {
2359
+ let rest = 0;
2360
+ for (let i = 0; i < cols; i++) if (i !== flexIdx) rest += cur[i];
2361
+ const fill = Math.max(FLOOR, Math.round(avail - rest));
2362
+ if (Math.round(fill) !== Math.round(cur[flexIdx])) {
2363
+ this._pinColumnWidth(flexIdx, fill);
2364
+ cur[flexIdx] = fill;
2365
+ }
1945
2366
  }
2367
+
2368
+ let total = 0;
2369
+ for (const w of cur) total += w;
1946
2370
  total = Math.ceil(total);
1947
2371
  headerTable.style.width = `${total}px`;
1948
2372
  if (bodyTable) bodyTable.style.width = `${total}px`;