@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.
@@ -1,198 +1,594 @@
1
- import './css/scrollableTable.css';
2
-
3
- const DATA_KEY = '_apxScrollableTable';
4
- const CLASS_TABLE = 'apx-scrollable-table';
5
-
6
- const DEFAULT_MAX_HEIGHT = '200px';
7
-
8
- /**
9
- * Get number of columns from the first row that has cells (sum of colspan).
10
- * @param {HTMLTableSectionElement} section - thead, tbody, or tfoot
11
- * @returns {number}
12
- */
13
- function getColumnCountFromSection(section) {
14
- const firstRow = section?.querySelector(':scope > tr');
15
- if (!firstRow) return 0;
16
- let cols = 0;
17
- firstRow.querySelectorAll(':scope > th, :scope > td').forEach((cell) => {
18
- cols += parseInt(cell.getAttribute('colspan'), 10) || 1;
19
- });
20
- return cols;
21
- }
22
-
23
- /**
24
- * Get total column count for the table (from thead or tbody first row).
25
- * @param {HTMLTableElement} table
26
- * @returns {number}
27
- */
28
- function getTableColumnCount(table) {
29
- const thead = table.querySelector('thead');
30
- const tbody = table.querySelector('tbody');
31
- const countFromThead = thead ? getColumnCountFromSection(thead) : 0;
32
- const countFromTbody = tbody ? getColumnCountFromSection(tbody) : 0;
33
- return countFromThead || countFromTbody || 1;
34
- }
35
-
36
- /**
37
- * Count direct tr children in a section.
38
- * @param {HTMLTableSectionElement} section
39
- * @returns {number}
40
- */
41
- function getRowCount(section) {
42
- if (!section) return 0;
43
- return section.querySelectorAll(':scope > tr').length;
44
- }
45
-
46
- /**
47
- * Build a 2D grid of occupied slots for a section (for rowspan/colspan placement).
48
- * Place each cell in DOM order; return a list of { cell, row, col, colspan, rowspan }.
49
- * @param {HTMLTableSectionElement} section
50
- * @param {number} numRows
51
- * @param {number} numCols
52
- * @returns {{ cell: HTMLTableCellElement, row: number, col: number, colspan: number, rowspan: number }[]}
53
- */
54
- function computeCellPlacements(section, numRows, numCols) {
55
- if (!section || numRows === 0 || numCols === 0) return [];
56
- const occupied = Array.from({ length: numRows }, () => Array(numCols).fill(false));
57
- const placements = [];
58
- const rows = section.querySelectorAll(':scope > tr');
59
-
60
- for (let r = 0; r < rows.length; r++) {
61
- const tr = rows[r];
62
- const cells = tr.querySelectorAll(':scope > th, :scope > td');
63
- for (const cell of cells) {
64
- const colspan = Math.min(parseInt(cell.getAttribute('colspan'), 10) || 1, numCols);
65
- const rowspan = Math.min(parseInt(cell.getAttribute('rowspan'), 10) || 1, numRows - r);
66
- let col = 0;
67
- while (col < numCols) {
68
- let free = true;
69
- for (let rr = r; rr < r + rowspan && free; rr++) {
70
- for (let cc = col; cc < col + colspan && free; cc++) {
71
- if (occupied[rr]?.[cc]) free = false;
72
- }
73
- }
74
- if (free) break;
75
- col++;
76
- }
77
- if (col + colspan > numCols) continue;
78
- for (let rr = r; rr < r + rowspan; rr++) {
79
- for (let cc = col; cc < col + colspan; cc++) {
80
- if (occupied[rr]) occupied[rr][cc] = true;
81
- }
82
- }
83
- placements.push({ cell, row: r, col, colspan, rowspan });
84
- }
85
- }
86
- return placements;
87
- }
88
-
89
- /**
90
- * Apply grid placement styles to a list of placements (1-based line numbers for CSS Grid).
91
- */
92
- function applyPlacements(placements) {
93
- placements.forEach(({ cell, row, col, colspan, rowspan }) => {
94
- cell.style.gridRow = `${row + 1} / span ${rowspan}`;
95
- cell.style.gridColumn = `${col + 1} / span ${colspan}`;
96
- });
97
- }
98
-
99
- /**
100
- * Clear grid placement styles from all th/td in a table (for refresh).
101
- * @param {HTMLTableElement} table
102
- */
103
- function clearPlacements(table) {
104
- table.querySelectorAll('th, td').forEach((cell) => {
105
- cell.style.gridRow = '';
106
- cell.style.gridColumn = '';
107
- });
108
- }
109
-
110
- /**
111
- * Normalize maxHeight option to a CSS length string.
112
- * @param {number|string} value
113
- * @returns {string}
114
- */
115
- function normalizeMaxHeight(value) {
116
- if (value == null) return DEFAULT_MAX_HEIGHT;
117
- if (typeof value === 'number') return `${value}px`;
118
- return String(value);
119
- }
120
-
121
- /**
122
- * Apply scrollable table layout to a single table.
123
- * @param {HTMLTableElement} table
124
- * @param {{ maxHeight?: number|string }} options
125
- */
126
- function applyScrollableTable(table, options) {
127
- const thead = table.querySelector('thead');
128
- const tbody = table.querySelector('tbody');
129
- const tfoot = table.querySelector('tfoot');
130
-
131
- const numCols = getTableColumnCount(table);
132
- if (numCols === 0) return;
133
-
134
- const theadRows = getRowCount(thead);
135
- const tbodyRows = getRowCount(tbody);
136
- const tfootRows = getRowCount(tfoot);
137
-
138
- table.style.setProperty('--apx-scrollable-cols', String(numCols));
139
- table.style.setProperty('--apx-scrollable-thead-rows', String(Math.max(1, theadRows)));
140
- table.style.setProperty('--apx-scrollable-tbody-rows', String(Math.max(1, tbodyRows)));
141
- table.style.setProperty('--apx-scrollable-tfoot-rows', String(Math.max(1, tfootRows)));
142
- table.style.setProperty('--apx-scrollable-body-max-height', normalizeMaxHeight(options.maxHeight));
143
-
144
- table.classList.add(CLASS_TABLE);
145
- table.classList.toggle('apx-scrollable-table--has-tfoot', !!(tfoot && tfootRows > 0));
146
- table.classList.toggle('apx-scrollable-table--no-thead', !(thead && theadRows > 0));
147
-
148
- clearPlacements(table);
149
-
150
- const sections = [
151
- { section: thead, rows: Math.max(1, theadRows) },
152
- { section: tbody, rows: Math.max(1, tbodyRows) },
153
- { section: tfoot, rows: Math.max(1, tfootRows) }
154
- ];
155
- sections.forEach(({ section, rows }) => {
156
- if (!section) return;
157
- const placements = computeCellPlacements(section, rows, numCols);
158
- applyPlacements(placements);
159
- });
160
- }
161
-
162
- /**
163
- * Augments the APX object with scrollableTable(options | 'refresh').
164
- * Makes the tbody of a table scrollable while keeping thead/tfoot fixed and columns aligned (CSS Grid + subgrid).
165
- *
166
- * @param {Object} apx - The APX object to augment.
167
- * @example
168
- * APX('table.data-grid').scrollableTable({ maxHeight: 300 });
169
- * APX('table.data-grid').scrollableTable('refresh');
170
- */
171
- export default function augmentWithScrollableTable(apx) {
172
- apx.scrollableTable = function (optionsOrAction) {
173
- const isRefresh = optionsOrAction === 'refresh';
174
- const options = isRefresh ? null : (optionsOrAction && typeof optionsOrAction === 'object' ? optionsOrAction : {});
175
-
176
- apx.elements.forEach((element) => {
177
- if (element.tagName !== 'TABLE') return;
178
- const table = /** @type {HTMLTableElement} */ (element);
179
-
180
- const ref = table[DATA_KEY];
181
- if (ref) {
182
- if (isRefresh) {
183
- applyScrollableTable(table, ref.options);
184
- } else if (options && Object.keys(options).length > 0) {
185
- ref.options = { ...ref.options, ...options };
186
- applyScrollableTable(table, ref.options);
187
- }
188
- return;
189
- }
190
- if (isRefresh) return;
191
-
192
- applyScrollableTable(table, options);
193
- table[DATA_KEY] = { options: { ...options } };
194
- });
195
-
196
- return apx;
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
+ }