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