@appius-fr/apx 2.7.0 → 2.8.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/APX.mjs +1 -2
- package/README.md +32 -7
- package/dist/APX.dev.mjs +777 -296
- package/dist/APX.mjs +1 -1
- package/dist/APX.prod.mjs +1 -1
- package/dist/APX.standalone.js +691 -160
- package/dist/APX.standalone.js.map +1 -1
- package/modules/scrollableTable/CHANGELOG.md +45 -0
- package/modules/scrollableTable/README.md +122 -52
- package/modules/scrollableTable/css/scrollableTable.css +67 -60
- package/modules/scrollableTable/scrollableTable.mjs +594 -198
- package/modules/tools/CHANGELOG.md +29 -0
- package/modules/tools/README.md +50 -1
- package/modules/tools/exports.mjs +7 -1
- package/modules/tools/getScrollbarSize.mjs +24 -0
- package/modules/tools/loadCss.mjs +46 -0
- package/modules/tristate/CHANGELOG.md +5 -3
- package/package.json +1 -1
- package/modules/common.mjs +0 -18
|
@@ -1,198 +1,594 @@
|
|
|
1
|
-
import './css/scrollableTable.css';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
1
|
+
import './css/scrollableTable.css';
|
|
2
|
+
import { getScrollbarSize } from '../tools/getScrollbarSize.mjs';
|
|
3
|
+
|
|
4
|
+
const DATA_KEY = '_apxScrollableTable';
|
|
5
|
+
const CLASS_TABLE = 'apx-scrollable-table';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_HEIGHT = '200px';
|
|
8
|
+
const DEFAULT_GUTTER_PX = 17;
|
|
9
|
+
|
|
10
|
+
const THROTTLE_MS = 16;
|
|
11
|
+
|
|
12
|
+
/** @type {Set<HTMLTableElement>} */
|
|
13
|
+
const dynamicTables = new Set();
|
|
14
|
+
/** @type {Map<Element, Set<HTMLTableElement>>} */
|
|
15
|
+
const scrollSourceToTables = new Map();
|
|
16
|
+
/** @type {Map<Element, Set<HTMLTableElement>>} */
|
|
17
|
+
const resizeSourceToTables = new Map();
|
|
18
|
+
/** @type {boolean} */
|
|
19
|
+
let resizeWindowAttached = false;
|
|
20
|
+
/** @type {ResizeObserver|null} */
|
|
21
|
+
let resizeObserver = null;
|
|
22
|
+
/** @type {number} */
|
|
23
|
+
let throttleLast = 0;
|
|
24
|
+
/** @type {number|null} */
|
|
25
|
+
let throttleRaf = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sum of colspans for one row (direct th/td children of tr).
|
|
29
|
+
* @param {HTMLTableRowElement} tr
|
|
30
|
+
* @returns {number}
|
|
31
|
+
*/
|
|
32
|
+
function getRowColumnCount(tr) {
|
|
33
|
+
let cols = 0;
|
|
34
|
+
tr.querySelectorAll(':scope > th, :scope > td').forEach((cell) => {
|
|
35
|
+
cols += parseInt(cell.getAttribute('colspan'), 10) || 1;
|
|
36
|
+
});
|
|
37
|
+
return cols;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get maximum column count in a section (max sum of colspans across all rows).
|
|
42
|
+
* @param {HTMLTableSectionElement} section - thead, tbody, or tfoot
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
function getColumnCountFromSection(section) {
|
|
46
|
+
if (!section) return 0;
|
|
47
|
+
const rows = section.querySelectorAll(':scope > tr');
|
|
48
|
+
let maxCols = 0;
|
|
49
|
+
rows.forEach((tr) => {
|
|
50
|
+
const sum = getRowColumnCount(tr);
|
|
51
|
+
if (sum > maxCols) maxCols = sum;
|
|
52
|
+
});
|
|
53
|
+
return maxCols;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get total column count for the table (max across thead, tbody, tfoot).
|
|
58
|
+
* @param {HTMLTableElement} table
|
|
59
|
+
* @returns {number}
|
|
60
|
+
*/
|
|
61
|
+
function getTableColumnCount(table) {
|
|
62
|
+
const thead = table.querySelector('thead');
|
|
63
|
+
const tbody = table.querySelector('tbody');
|
|
64
|
+
const tfoot = table.querySelector('tfoot');
|
|
65
|
+
const countThead = thead ? getColumnCountFromSection(thead) : 0;
|
|
66
|
+
const countTbody = tbody ? getColumnCountFromSection(tbody) : 0;
|
|
67
|
+
const countTfoot = tfoot ? getColumnCountFromSection(tfoot) : 0;
|
|
68
|
+
return Math.max(countThead, countTbody, countTfoot, 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Measure current column widths from the table in its natural layout (before applying scrollable class).
|
|
73
|
+
* Prefers a row where each cell has colspan 1 so we get one width per column; otherwise splits cell widths by colspan.
|
|
74
|
+
* @param {HTMLTableElement} table - table not yet with scrollable class
|
|
75
|
+
* @param {number} numCols
|
|
76
|
+
* @returns {number[]} pixel widths per column
|
|
77
|
+
*/
|
|
78
|
+
function measureColumnWidths(table, numCols) {
|
|
79
|
+
const thead = table.querySelector('thead');
|
|
80
|
+
const tbody = table.querySelector('tbody');
|
|
81
|
+
const sections = [thead, tbody].filter(Boolean);
|
|
82
|
+
/** @type {HTMLTableRowElement|null} */
|
|
83
|
+
let bestRow = null;
|
|
84
|
+
for (const section of sections) {
|
|
85
|
+
const rows = section.querySelectorAll(':scope > tr');
|
|
86
|
+
for (const tr of rows) {
|
|
87
|
+
const cells = tr.querySelectorAll(':scope > th, :scope > td');
|
|
88
|
+
if (getRowColumnCount(tr) !== numCols) continue;
|
|
89
|
+
const allSingle = Array.from(cells).every((c) => (parseInt(c.getAttribute('colspan'), 10) || 1) === 1);
|
|
90
|
+
if (allSingle && cells.length === numCols) {
|
|
91
|
+
bestRow = tr;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (!bestRow) bestRow = tr;
|
|
95
|
+
}
|
|
96
|
+
if (bestRow && Array.from(bestRow.querySelectorAll(':scope > th, :scope > td')).every((c) => (parseInt(c.getAttribute('colspan'), 10) || 1) === 1))
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
if (!bestRow) return [];
|
|
100
|
+
|
|
101
|
+
const widths = new Array(numCols).fill(0);
|
|
102
|
+
const cells = bestRow.querySelectorAll(':scope > th, :scope > td');
|
|
103
|
+
let col = 0;
|
|
104
|
+
for (const cell of cells) {
|
|
105
|
+
const span = Math.min(parseInt(cell.getAttribute('colspan'), 10) || 1, numCols - col);
|
|
106
|
+
if (span <= 0) break;
|
|
107
|
+
const w = cell.getBoundingClientRect().width;
|
|
108
|
+
const perCol = w / span;
|
|
109
|
+
for (let i = 0; i < span; i++) widths[col + i] = perCol;
|
|
110
|
+
col += span;
|
|
111
|
+
}
|
|
112
|
+
if (col === 0) return [];
|
|
113
|
+
const fallback = widths.some((w) => w > 0) ? Math.max(80, ...widths.filter((w) => w > 0)) / 2 : 80;
|
|
114
|
+
for (let i = 0; i < numCols; i++) if (widths[i] <= 0) widths[i] = fallback;
|
|
115
|
+
return widths.slice(0, numCols);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Count direct tr children in a section.
|
|
120
|
+
* @param {HTMLTableSectionElement} section
|
|
121
|
+
* @returns {number}
|
|
122
|
+
*/
|
|
123
|
+
function getRowCount(section) {
|
|
124
|
+
if (!section) return 0;
|
|
125
|
+
return section.querySelectorAll(':scope > tr').length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build a 2D grid of occupied slots for a section (for rowspan/colspan placement).
|
|
130
|
+
* Place each cell in DOM order; return a list of { cell, row, col, colspan, rowspan }.
|
|
131
|
+
* @param {HTMLTableSectionElement} section
|
|
132
|
+
* @param {number} numRows
|
|
133
|
+
* @param {number} numCols
|
|
134
|
+
* @returns {{ cell: HTMLTableCellElement, row: number, col: number, colspan: number, rowspan: number }[]}
|
|
135
|
+
*/
|
|
136
|
+
function computeCellPlacements(section, numRows, numCols) {
|
|
137
|
+
if (!section || numRows === 0 || numCols === 0) return [];
|
|
138
|
+
const occupied = Array.from({ length: numRows }, () => Array(numCols).fill(false));
|
|
139
|
+
const placements = [];
|
|
140
|
+
const rows = section.querySelectorAll(':scope > tr');
|
|
141
|
+
|
|
142
|
+
for (let r = 0; r < rows.length; r++) {
|
|
143
|
+
const tr = rows[r];
|
|
144
|
+
const cells = tr.querySelectorAll(':scope > th, :scope > td');
|
|
145
|
+
for (const cell of cells) {
|
|
146
|
+
const colspan = Math.min(parseInt(cell.getAttribute('colspan'), 10) || 1, numCols);
|
|
147
|
+
const rowspan = Math.min(parseInt(cell.getAttribute('rowspan'), 10) || 1, numRows - r);
|
|
148
|
+
let col = 0;
|
|
149
|
+
while (col < numCols) {
|
|
150
|
+
let free = true;
|
|
151
|
+
for (let rr = r; rr < r + rowspan && free; rr++) {
|
|
152
|
+
for (let cc = col; cc < col + colspan && free; cc++) {
|
|
153
|
+
if (occupied[rr]?.[cc]) free = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (free) break;
|
|
157
|
+
col++;
|
|
158
|
+
}
|
|
159
|
+
if (col + colspan > numCols) continue;
|
|
160
|
+
for (let rr = r; rr < r + rowspan; rr++) {
|
|
161
|
+
for (let cc = col; cc < col + colspan; cc++) {
|
|
162
|
+
if (occupied[rr]) occupied[rr][cc] = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
placements.push({ cell, row: r, col, colspan, rowspan });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return placements;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Apply grid placement styles to a list of placements (1-based line numbers for CSS Grid).
|
|
173
|
+
*/
|
|
174
|
+
function applyPlacements(placements) {
|
|
175
|
+
placements.forEach(({ cell, row, col, colspan, rowspan }) => {
|
|
176
|
+
cell.style.gridRow = `${row + 1} / span ${rowspan}`;
|
|
177
|
+
cell.style.gridColumn = `${col + 1} / span ${colspan}`;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Clear grid placement styles from all th/td in a table (for refresh).
|
|
183
|
+
* @param {HTMLTableElement} table
|
|
184
|
+
*/
|
|
185
|
+
function clearPlacements(table) {
|
|
186
|
+
table.querySelectorAll('th, td').forEach((cell) => {
|
|
187
|
+
cell.style.gridRow = '';
|
|
188
|
+
cell.style.gridColumn = '';
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Normalize height/maxHeight option to a CSS length string.
|
|
194
|
+
* @param {number|string} value
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function normalizeHeight(value) {
|
|
198
|
+
if (value == null) return DEFAULT_MAX_HEIGHT;
|
|
199
|
+
if (typeof value === 'number') return `${value}px`;
|
|
200
|
+
return String(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve current body height from options: either bodyHeightDynamic.get(table) or static height/maxHeight.
|
|
205
|
+
* @param {Object} options - scrollableTable options (may include bodyHeightDynamic, height, maxHeight)
|
|
206
|
+
* @param {HTMLTableElement} table
|
|
207
|
+
* @returns {{ bodySize: string, useFixedHeight: boolean }}
|
|
208
|
+
*/
|
|
209
|
+
function resolveBodyHeight(options, table) {
|
|
210
|
+
const dyn = options.bodyHeightDynamic;
|
|
211
|
+
if (dyn && typeof dyn.get === 'function') {
|
|
212
|
+
const value = dyn.get(table);
|
|
213
|
+
const bodySize = normalizeHeight(value);
|
|
214
|
+
const useFixedHeight = dyn.useAs === 'height';
|
|
215
|
+
return { bodySize, useFixedHeight };
|
|
216
|
+
}
|
|
217
|
+
const useFixedHeight = options.height != null;
|
|
218
|
+
const raw = useFixedHeight ? options.height : options.maxHeight;
|
|
219
|
+
const bodySize = normalizeHeight(raw);
|
|
220
|
+
return { bodySize, useFixedHeight };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Update only the tbody height CSS variable and class (used on scroll/resize for dynamic height).
|
|
225
|
+
* @param {HTMLTableElement} table
|
|
226
|
+
* @param {Object} options - full scrollableTable options (with ref.options when called from throttle)
|
|
227
|
+
*/
|
|
228
|
+
function updateTableBodyHeight(table, options) {
|
|
229
|
+
const { bodySize, useFixedHeight } = resolveBodyHeight(options, table);
|
|
230
|
+
table.style.setProperty('--apx-scrollable-body-max-height', bodySize);
|
|
231
|
+
table.classList.toggle('apx-scrollable-table--body-height', useFixedHeight);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Run lazy cleanup then updateTableBodyHeight for all tables still in dynamicTables. (Called by throttled entry point or RAF.)
|
|
236
|
+
*/
|
|
237
|
+
function flushDynamicHeightUpdate() {
|
|
238
|
+
const toRemove = [];
|
|
239
|
+
dynamicTables.forEach((table) => {
|
|
240
|
+
if (!table.isConnected) toRemove.push(table);
|
|
241
|
+
});
|
|
242
|
+
toRemove.forEach((table) => removeTableFromDynamicSources(table));
|
|
243
|
+
dynamicTables.forEach((table) => {
|
|
244
|
+
const ref = table[DATA_KEY];
|
|
245
|
+
if (ref?.options) updateTableBodyHeight(table, ref.options);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Throttled entry: run flushDynamicHeightUpdate now or schedule with RAF.
|
|
251
|
+
*/
|
|
252
|
+
function runDynamicHeightUpdate() {
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
if (now - throttleLast < THROTTLE_MS) {
|
|
255
|
+
if (!throttleRaf) {
|
|
256
|
+
throttleRaf = requestAnimationFrame(() => {
|
|
257
|
+
throttleRaf = null;
|
|
258
|
+
throttleLast = Date.now();
|
|
259
|
+
flushDynamicHeightUpdate();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
throttleLast = now;
|
|
265
|
+
flushDynamicHeightUpdate();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Resolve updateOn into scroll targets (Element[]) and resize: { window: boolean, elements: Element[] }.
|
|
270
|
+
* @param {Object} options - options.bodyHeightDynamic.updateOn
|
|
271
|
+
* @returns {{ scrollTargets: Element[], resizeWindow: boolean, resizeElements: Element[] }}
|
|
272
|
+
*/
|
|
273
|
+
function resolveUpdateOn(options) {
|
|
274
|
+
const u = options?.bodyHeightDynamic?.updateOn;
|
|
275
|
+
const scrollOn = u?.scrollOn;
|
|
276
|
+
const resizeOn = u?.resizeOn;
|
|
277
|
+
const scrollTargets = [];
|
|
278
|
+
if (scrollOn != null && Array.isArray(scrollOn) && scrollOn.length > 0) {
|
|
279
|
+
scrollOn.forEach((x) => {
|
|
280
|
+
if (x === 'document') scrollTargets.push(document.documentElement);
|
|
281
|
+
else if (x && typeof x.addEventListener === 'function') scrollTargets.push(x);
|
|
282
|
+
});
|
|
283
|
+
} else if (u?.scroll === true) {
|
|
284
|
+
scrollTargets.push(document.documentElement);
|
|
285
|
+
scrollTargets.push(typeof window !== 'undefined' ? window : document.documentElement);
|
|
286
|
+
}
|
|
287
|
+
let resizeWindow = false;
|
|
288
|
+
const resizeElements = [];
|
|
289
|
+
if (resizeOn != null && Array.isArray(resizeOn) && resizeOn.length > 0) {
|
|
290
|
+
resizeOn.forEach((x) => {
|
|
291
|
+
if (x === 'window') resizeWindow = true;
|
|
292
|
+
else if (x && typeof x.addEventListener === 'function') resizeElements.push(x);
|
|
293
|
+
});
|
|
294
|
+
} else if (u?.resize !== false) {
|
|
295
|
+
resizeWindow = true;
|
|
296
|
+
resizeElements.push(document.documentElement);
|
|
297
|
+
}
|
|
298
|
+
return { scrollTargets, resizeWindow, resizeElements };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Remove table from dynamicTables and from all scroll/resize Maps; detach listeners if Set becomes empty.
|
|
303
|
+
* @param {HTMLTableElement} table
|
|
304
|
+
*/
|
|
305
|
+
function removeTableFromDynamicSources(table) {
|
|
306
|
+
dynamicTables.delete(table);
|
|
307
|
+
scrollSourceToTables.forEach((set, el) => {
|
|
308
|
+
set.delete(table);
|
|
309
|
+
if (set.size === 0) {
|
|
310
|
+
scrollSourceToTables.delete(el);
|
|
311
|
+
el.removeEventListener('scroll', onScrollThrottled);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
resizeSourceToTables.forEach((set, el) => {
|
|
315
|
+
set.delete(table);
|
|
316
|
+
if (set.size === 0) {
|
|
317
|
+
resizeSourceToTables.delete(el);
|
|
318
|
+
if (resizeObserver) resizeObserver.unobserve(el);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
if (resizeWindowAttached && dynamicTables.size === 0) {
|
|
322
|
+
window.removeEventListener('resize', onResizeThrottled);
|
|
323
|
+
resizeWindowAttached = false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function onScrollThrottled() {
|
|
328
|
+
runDynamicHeightUpdate();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function onResizeThrottled() {
|
|
332
|
+
runDynamicHeightUpdate();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Register a table with bodyHeightDynamic: add to Set and attach scroll/resize listeners per updateOn.
|
|
337
|
+
* @param {HTMLTableElement} table
|
|
338
|
+
* @param {Object} options - full options (ref.options)
|
|
339
|
+
*/
|
|
340
|
+
function registerDynamicTable(table, options) {
|
|
341
|
+
const { scrollTargets, resizeWindow, resizeElements } = resolveUpdateOn(options);
|
|
342
|
+
dynamicTables.add(table);
|
|
343
|
+
scrollTargets.forEach((el) => {
|
|
344
|
+
let set = scrollSourceToTables.get(el);
|
|
345
|
+
if (!set) {
|
|
346
|
+
set = new Set();
|
|
347
|
+
scrollSourceToTables.set(el, set);
|
|
348
|
+
el.addEventListener('scroll', onScrollThrottled, { passive: true });
|
|
349
|
+
}
|
|
350
|
+
set.add(table);
|
|
351
|
+
});
|
|
352
|
+
if (resizeWindow) {
|
|
353
|
+
if (!resizeWindowAttached) {
|
|
354
|
+
resizeWindowAttached = true;
|
|
355
|
+
window.addEventListener('resize', onResizeThrottled, { passive: true });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
resizeElements.forEach((el) => {
|
|
359
|
+
let set = resizeSourceToTables.get(el);
|
|
360
|
+
if (!set) {
|
|
361
|
+
set = new Set();
|
|
362
|
+
resizeSourceToTables.set(el, set);
|
|
363
|
+
if (!resizeObserver) resizeObserver = new ResizeObserver(onResizeThrottled);
|
|
364
|
+
resizeObserver.observe(el);
|
|
365
|
+
}
|
|
366
|
+
set.add(table);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Build grid-template-columns from measured widths and optional overrides per column.
|
|
372
|
+
* @param {number} numCols
|
|
373
|
+
* @param {number[]} columnWidths
|
|
374
|
+
* @param {Object<number, string>|(string|null)[]} columnOverrides - map column index → CSS value (e.g. '2fr'), or array; null/empty = use measured
|
|
375
|
+
* @param {string} [unit='fr'] - CSS unit for measured widths ('fr' or 'px')
|
|
376
|
+
* @returns {string}
|
|
377
|
+
*/
|
|
378
|
+
function buildTemplateColumns(numCols, columnWidths, columnOverrides, unit = 'fr') {
|
|
379
|
+
if (!columnWidths || columnWidths.length !== numCols) return '';
|
|
380
|
+
const get = (i) =>
|
|
381
|
+
Array.isArray(columnOverrides) ? columnOverrides[i] : columnOverrides[i];
|
|
382
|
+
const parts = [];
|
|
383
|
+
for (let i = 0; i < numCols; i++) {
|
|
384
|
+
const ov = get(i);
|
|
385
|
+
if (ov != null && typeof ov === 'string' && ov.trim() !== '') {
|
|
386
|
+
parts.push(ov.trim());
|
|
387
|
+
} else {
|
|
388
|
+
parts.push(`${Math.round(columnWidths[i])}${unit}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return parts.join(' ');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Build grid-template-rows from measured heights and optional overrides per row.
|
|
396
|
+
* @param {number} numRows
|
|
397
|
+
* @param {number[]} rowHeights
|
|
398
|
+
* @param {Object<number, string>|(string|null)[]} rowOverrides - map row index → CSS value (e.g. '48px', '2fr'), or array; null/empty = use measured
|
|
399
|
+
* @param {string} [unit='px'] - CSS unit for measured heights ('px' or 'fr')
|
|
400
|
+
* @returns {string}
|
|
401
|
+
*/
|
|
402
|
+
function buildTemplateRows(numRows, rowHeights, rowOverrides, unit = 'px') {
|
|
403
|
+
if (!rowHeights || rowHeights.length < numRows) return '';
|
|
404
|
+
const get = (i) =>
|
|
405
|
+
Array.isArray(rowOverrides) ? rowOverrides[i] : rowOverrides?.[i];
|
|
406
|
+
const parts = [];
|
|
407
|
+
for (let i = 0; i < numRows; i++) {
|
|
408
|
+
const ov = get(i);
|
|
409
|
+
if (ov != null && typeof ov === 'string' && ov.trim() !== '') {
|
|
410
|
+
parts.push(ov.trim());
|
|
411
|
+
} else {
|
|
412
|
+
parts.push(`${Math.round(rowHeights[i] ?? 0)}${unit}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return parts.join(' ');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Measure each tr height in a section (table must not yet have scrollable class).
|
|
420
|
+
* @param {HTMLTableSectionElement|null} section
|
|
421
|
+
* @returns {number[]}
|
|
422
|
+
*/
|
|
423
|
+
function measureRowHeights(section) {
|
|
424
|
+
if (!section) return [];
|
|
425
|
+
const rows = section.querySelectorAll(':scope > tr');
|
|
426
|
+
return Array.from(rows).map((tr) => tr.getBoundingClientRect().height);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Apply scrollable table layout to a single table.
|
|
431
|
+
* @param {HTMLTableElement} table
|
|
432
|
+
* @param {Object} options - scrollableTable options
|
|
433
|
+
* @param {number|string} [options.maxHeight] - Max height of tbody (default '200px'). Ignored when height or bodyHeightDynamic is set.
|
|
434
|
+
* @param {number|string} [options.height] - Fixed height of tbody. Ignored when bodyHeightDynamic is set.
|
|
435
|
+
* @param {{ get: (function(HTMLTableElement): number|string), useAs: 'height'|'maxHeight', updateOn?: { scroll?: boolean, resize?: boolean, scrollOn?: ('document'|Element)[], resizeOn?: ('window'|Element)[] } }} [options.bodyHeightDynamic] - When set, body size is computed by get(table) and re-applied when scroll/resize sources fire. updateOn: scrollOn/resizeOn list elements (sentinels 'document'/'window'); or use scroll (default false) / resize (default true) booleans.
|
|
436
|
+
* @param {string} [options.gridTemplateColumns]
|
|
437
|
+
* @param {{ thead?: string, tbody?: string, tfoot?: string }} [options.gridTemplateRows]
|
|
438
|
+
* @param {{ thead?: Object<number, string>|(string|null)[], tbody?: Object<number, string>|(string|null)[], tfoot?: Object<number, string>|(string|null)[] }} [options.rowOverrides]
|
|
439
|
+
* @param {Object<number, string>|(string|null)[]} [options.columnOverrides]
|
|
440
|
+
* @param {{ cols?: string, rows?: string }} [options.defaultSizingUnit] - CSS unit for measured widths/heights. cols defaults to 'fr', rows defaults to 'px'.
|
|
441
|
+
* @param {{ columnWidths?: number[], rowHeights?: { thead?: number[], tbody?: number[], tfoot?: number[] } } | undefined} ref - existing ref when refreshing
|
|
442
|
+
*/
|
|
443
|
+
function applyScrollableTable(table, options, ref) {
|
|
444
|
+
const thead = table.querySelector('thead');
|
|
445
|
+
const tbody = table.querySelector('tbody');
|
|
446
|
+
const tfoot = table.querySelector('tfoot');
|
|
447
|
+
|
|
448
|
+
const numCols = getTableColumnCount(table);
|
|
449
|
+
if (numCols === 0) return;
|
|
450
|
+
|
|
451
|
+
const alreadyScrollable = table.classList.contains(CLASS_TABLE);
|
|
452
|
+
const customTemplate = typeof options.gridTemplateColumns === 'string' && options.gridTemplateColumns.trim().length > 0;
|
|
453
|
+
const columnWidths = customTemplate
|
|
454
|
+
? null
|
|
455
|
+
: (ref?.columnWidths ?? (alreadyScrollable ? null : measureColumnWidths(table, numCols)));
|
|
456
|
+
|
|
457
|
+
const theadRows = getRowCount(thead);
|
|
458
|
+
const tbodyRows = getRowCount(tbody);
|
|
459
|
+
const tfootRows = getRowCount(tfoot);
|
|
460
|
+
|
|
461
|
+
const rowHeights =
|
|
462
|
+
ref?.rowHeights ??
|
|
463
|
+
(alreadyScrollable
|
|
464
|
+
? null
|
|
465
|
+
: {
|
|
466
|
+
thead: measureRowHeights(thead),
|
|
467
|
+
tbody: measureRowHeights(tbody),
|
|
468
|
+
tfoot: measureRowHeights(tfoot)
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
table.style.setProperty('--apx-scrollable-cols', String(numCols));
|
|
472
|
+
table.style.setProperty('--apx-scrollable-thead-rows', String(Math.max(1, theadRows)));
|
|
473
|
+
table.style.setProperty('--apx-scrollable-tbody-rows', String(Math.max(1, tbodyRows)));
|
|
474
|
+
table.style.setProperty('--apx-scrollable-tfoot-rows', String(Math.max(1, tfootRows)));
|
|
475
|
+
const { bodySize, useFixedHeight } = resolveBodyHeight(options, table);
|
|
476
|
+
table.style.setProperty('--apx-scrollable-body-max-height', bodySize);
|
|
477
|
+
table.classList.toggle('apx-scrollable-table--body-height', useFixedHeight);
|
|
478
|
+
|
|
479
|
+
table.classList.add(CLASS_TABLE);
|
|
480
|
+
table.classList.toggle('apx-scrollable-table--has-tfoot', !!(tfoot && tfootRows > 0));
|
|
481
|
+
table.classList.toggle('apx-scrollable-table--no-thead', !(thead && theadRows > 0));
|
|
482
|
+
|
|
483
|
+
// Force reflow so the grid layout is established before setting measured template-columns.
|
|
484
|
+
// Without this, the browser batches class + template into one pass and the tbody overflows horizontally.
|
|
485
|
+
table.offsetHeight; // eslint-disable-line no-unused-expressions
|
|
486
|
+
|
|
487
|
+
const colUnit = options.defaultSizingUnit?.cols || 'fr';
|
|
488
|
+
const rowUnit = options.defaultSizingUnit?.rows || 'px';
|
|
489
|
+
|
|
490
|
+
let gutterFallbackPx = DEFAULT_GUTTER_PX;
|
|
491
|
+
try {
|
|
492
|
+
const measured = getScrollbarSize('vertical');
|
|
493
|
+
if (typeof measured === 'number' && Number.isFinite(measured) && measured >= 0) {
|
|
494
|
+
gutterFallbackPx = measured;
|
|
495
|
+
}
|
|
496
|
+
} catch (_) {
|
|
497
|
+
/* use DEFAULT_GUTTER_PX */
|
|
498
|
+
}
|
|
499
|
+
const gutterSuffix = ` minmax(var(--apx-scrollable-gutter-width, ${gutterFallbackPx}px), var(--apx-scrollable-gutter-width, ${gutterFallbackPx}px))`;
|
|
500
|
+
if (customTemplate) {
|
|
501
|
+
table.style.setProperty('--apx-scrollable-template-columns', options.gridTemplateColumns.trim() + gutterSuffix);
|
|
502
|
+
} else if (columnWidths && columnWidths.length === numCols) {
|
|
503
|
+
const template =
|
|
504
|
+
options.columnOverrides != null
|
|
505
|
+
? buildTemplateColumns(numCols, columnWidths, options.columnOverrides, colUnit)
|
|
506
|
+
: columnWidths.map((w) => `${Math.round(w)}${colUnit}`).join(' ');
|
|
507
|
+
table.style.setProperty('--apx-scrollable-template-columns', template + gutterSuffix);
|
|
508
|
+
} else {
|
|
509
|
+
table.style.removeProperty('--apx-scrollable-template-columns');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
clearPlacements(table);
|
|
513
|
+
|
|
514
|
+
const sections = [
|
|
515
|
+
{ section: thead, rows: Math.max(1, theadRows), heights: rowHeights?.thead, key: 'thead' },
|
|
516
|
+
{ section: tbody, rows: Math.max(1, tbodyRows), heights: rowHeights?.tbody, key: 'tbody' },
|
|
517
|
+
{ section: tfoot, rows: Math.max(1, tfootRows), heights: rowHeights?.tfoot, key: 'tfoot' }
|
|
518
|
+
];
|
|
519
|
+
const customRows = options.gridTemplateRows && typeof options.gridTemplateRows === 'object' ? options.gridTemplateRows : null;
|
|
520
|
+
const rowOverrides = options.rowOverrides && typeof options.rowOverrides === 'object' ? options.rowOverrides : null;
|
|
521
|
+
sections.forEach(({ section, rows, heights, key }) => {
|
|
522
|
+
if (!section) return;
|
|
523
|
+
const varName = `--apx-scrollable-${key}-template-rows`;
|
|
524
|
+
const custom = customRows?.[key];
|
|
525
|
+
const overrides = rowOverrides?.[key];
|
|
526
|
+
if (typeof custom === 'string' && custom.trim().length > 0) {
|
|
527
|
+
section.style.setProperty(varName, custom.trim());
|
|
528
|
+
} else if (heights && heights.length >= rows) {
|
|
529
|
+
const template =
|
|
530
|
+
overrides != null
|
|
531
|
+
? buildTemplateRows(rows, heights.slice(0, rows), overrides, rowUnit)
|
|
532
|
+
: heights
|
|
533
|
+
.slice(0, rows)
|
|
534
|
+
.map((h) => `${Math.round(h)}${rowUnit}`)
|
|
535
|
+
.join(' ');
|
|
536
|
+
section.style.setProperty(varName, template);
|
|
537
|
+
} else {
|
|
538
|
+
section.style.removeProperty(varName);
|
|
539
|
+
}
|
|
540
|
+
const placements = computeCellPlacements(section, rows, numCols);
|
|
541
|
+
applyPlacements(placements);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return { columnWidths, rowHeights };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Augments the APX object with scrollableTable(options | 'refresh').
|
|
549
|
+
* Makes the tbody of a table scrollable while keeping thead/tfoot fixed and columns aligned (CSS Grid + subgrid).
|
|
550
|
+
*
|
|
551
|
+
* @param {Object} apx - The APX object to augment.
|
|
552
|
+
* @example
|
|
553
|
+
* APX('table.data-grid').scrollableTable({ maxHeight: 300 });
|
|
554
|
+
* APX('table.data-grid').scrollableTable('refresh');
|
|
555
|
+
*/
|
|
556
|
+
export default function augmentWithScrollableTable(apx) {
|
|
557
|
+
apx.scrollableTable = function (optionsOrAction) {
|
|
558
|
+
const isRefresh = optionsOrAction === 'refresh';
|
|
559
|
+
const options = isRefresh ? null : (optionsOrAction && typeof optionsOrAction === 'object' ? optionsOrAction : {});
|
|
560
|
+
|
|
561
|
+
apx.elements.forEach((element) => {
|
|
562
|
+
if (element.tagName !== 'TABLE') return;
|
|
563
|
+
const table = /** @type {HTMLTableElement} */ (element);
|
|
564
|
+
|
|
565
|
+
const ref = table[DATA_KEY];
|
|
566
|
+
if (ref) {
|
|
567
|
+
if (isRefresh) {
|
|
568
|
+
applyScrollableTable(table, ref.options, ref);
|
|
569
|
+
} else if (options && Object.keys(options).length > 0) {
|
|
570
|
+
ref.options = { ...ref.options, ...options };
|
|
571
|
+
const result = applyScrollableTable(table, ref.options, ref);
|
|
572
|
+
if (result?.columnWidths) ref.columnWidths = result.columnWidths;
|
|
573
|
+
if (result?.rowHeights) ref.rowHeights = result.rowHeights;
|
|
574
|
+
}
|
|
575
|
+
const currentOptions = ref.options;
|
|
576
|
+
if (currentOptions?.bodyHeightDynamic) registerDynamicTable(table, currentOptions);
|
|
577
|
+
else removeTableFromDynamicSources(table);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (isRefresh) return;
|
|
581
|
+
|
|
582
|
+
console.log('[APX scrollableTable] create', table);
|
|
583
|
+
const result = applyScrollableTable(table, options, undefined);
|
|
584
|
+
table[DATA_KEY] = {
|
|
585
|
+
options: { ...options },
|
|
586
|
+
columnWidths: result?.columnWidths || undefined,
|
|
587
|
+
rowHeights: result?.rowHeights || undefined
|
|
588
|
+
};
|
|
589
|
+
if (options?.bodyHeightDynamic) registerDynamicTable(table, options);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
return apx;
|
|
593
|
+
};
|
|
594
|
+
}
|