flexdesk 0.2.0 → 0.4.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.
- package/README.md +26 -0
- package/css/base.css +2243 -872
- package/css/flexdesk.css +1375 -14
- package/css/overrides.css +44 -0
- package/css/tokens.css +45 -0
- package/dist/charts.js +5 -3
- package/dist/charts.js.map +1 -1
- package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
- package/dist/chunk-ELXVW542.js.map +7 -0
- package/dist/chunk-LH5TSOZW.js +1237 -0
- package/dist/chunk-LH5TSOZW.js.map +7 -0
- package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
- package/dist/chunk-O5OHMWBB.js.map +7 -0
- package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
- package/dist/chunk-QIU5S2RU.js.map +7 -0
- package/dist/chunk-QNQHQ24V.js +408 -0
- package/dist/chunk-QNQHQ24V.js.map +7 -0
- package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
- package/dist/chunk-XKDTIT4Q.js.map +7 -0
- package/dist/editor.js +3 -380
- package/dist/editor.js.map +3 -3
- package/dist/flexdesk.css +1375 -14
- package/dist/tiles.js +168 -41
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +45 -0
- package/dist/widgets.js +44 -14
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +3140 -157
- package/dist/wm.js.map +4 -4
- package/package.json +3 -2
- package/src/charts/chart_types.js +167 -0
- package/src/charts/plotly_wrapper.js +178 -10
- package/src/editor/notebook_tab_bar.js +39 -3
- package/src/tiles/tile_base.js +143 -35
- package/src/tiles/tile_grid.js +52 -1
- package/src/tiling/command_palette.js +71 -18
- package/src/tiling/desktops.js +36 -12
- package/src/tiling/keymap.js +24 -4
- package/src/tiling/shell.js +156 -25
- package/src/tiling/tab_strip.js +184 -0
- package/src/tiling/tile_breadcrumb.js +34 -2
- package/src/tiling/tile_renderer.js +1386 -21
- package/src/tiling/tile_tab_menu.js +101 -0
- package/src/tiling/tile_tree.js +115 -11
- package/src/tiling/wm.js +2375 -84
- package/src/tiling/zoom.js +248 -0
- package/src/ui/components/action_dropdown.js +34 -3
- package/src/ui/components/autocomplete_field.js +65 -13
- package/src/ui/components/context_menu.js +79 -8
- package/src/ui/components/data_table.js +508 -84
- package/src/ui/components/managed_window.js +928 -36
- package/src/ui/components/modal.js +214 -8
- package/dist/chunk-CT4YXXLP.js.map +0 -7
- package/dist/chunk-DRYCDMEG.js.map +0 -7
- package/dist/chunk-DVU44T77.js.map +0 -7
- package/dist/chunk-TLZUUFOE.js.map +0 -7
- package/dist/chunk-UCJ2WD4D.js +0 -625
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
1770
|
-
const
|
|
1771
|
-
|
|
1772
|
-
widths[
|
|
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 —
|
|
1838
|
-
* render against the freshly-built
|
|
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
|
-
/**
|
|
1852
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1919
|
-
|
|
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
|
-
/**
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
|
|
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
|
-
|
|
1942
|
-
|
|
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
|
-
|
|
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`;
|