flexdesk 0.4.5 → 0.4.7

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,2096 +1,2628 @@
1
- // src/ui/utils/raf_resize_observer.js
2
- function createRafResizeObserver(callback) {
3
- let scheduled = false;
4
- let latestEntries = [];
5
- return new ResizeObserver((entries) => {
6
- latestEntries = entries;
7
- if (scheduled) return;
8
- scheduled = true;
9
- requestAnimationFrame(() => {
10
- scheduled = false;
11
- callback(latestEntries);
12
- });
13
- });
14
- }
15
-
16
- // src/ui/components/data_table.js
17
- var DEFAULT_PAGE_SIZE = 100;
18
- var AUTOSIZE_MAX_PX = 520;
19
- var DataTable = class {
20
- /**
21
- * Create a DataTable instance
22
- * @param {HTMLElement} container - Container element to render into
23
- * @param {DataTableConfig} config - Configuration options
24
- */
25
- constructor(container, config = {}) {
26
- this.container = container;
27
- this.config = {
28
- headers: [],
29
- rows: [],
30
- pageSize: DEFAULT_PAGE_SIZE,
31
- pagination: true,
32
- selectable: true,
33
- copyable: true,
34
- sortable: false,
35
- filterable: false,
36
- readonly: false,
37
- showRowNumbers: false,
38
- emptyMessage: "No data",
39
- onSort: null,
40
- onSelectionChange: null,
41
- formatValue: null,
42
- getHeaderIcon: null,
43
- getColumnType: null,
44
- services: null,
45
- // Server-side pagination
46
- totalCount: null,
47
- offset: 0,
48
- onPageChange: null,
49
- onJumpToRow: null,
50
- // Custom cell rendering
51
- renderCell: null,
52
- // Rendering density + bottom-panel hooks (P2)
53
- mode: "normal",
54
- onRowClick: null,
55
- onRowContextMenu: null,
56
- onCellContextMenu: null,
57
- showExportButton: false,
58
- // The host port. TOP LEVEL, deliberately — NOT inside `services`,
59
- // which most call sites never pass. Supplies the native save
60
- // dialog for downloadCSV(); absent => Blob fallback.
61
- host: null,
62
- // Opt-in persistence: when set, this table's sort, filters, and
63
- // column widths survive teardown/reload, keyed by this string
64
- // inside the injected `stateStore`. Omit it and the table stays
65
- // ephemeral. WHERE the store persists is the embedder's business.
66
- persistKey: null,
67
- stateStore: null,
68
- // How the automatic fit pass sizes an undragged column — see the
69
- // typedef. 'container' is exactly what every consumer got before
70
- // this key existed, so the default is not a preference: it is the
71
- // promise that adding the key changed no existing layout by a
72
- // pixel. Only a consumer that asks for 'content' sees anything new.
73
- columnFit: "container",
74
- ...config
75
- };
76
- if (this.config.persistKey && !this.config.stateStore) {
77
- throw new Error(`DataTable: persistKey "${this.config.persistKey}" requires { stateStore }`);
78
- }
79
- this._state = {
80
- offset: 0,
81
- selected: /* @__PURE__ */ new Set(),
82
- anchorIndex: null,
83
- sortColumn: null,
84
- sortAscending: true,
85
- filters: /* @__PURE__ */ new Map()
86
- };
87
- this._columnTypes = [];
88
- this._processedRows = null;
89
- this._processedIndexMap = null;
90
- this._filterDebounceTimer = null;
91
- this._wrapperEl = null;
92
- this._paginationEl = null;
93
- this._tableEl = null;
94
- this._tbodyEl = null;
95
- this._contextMenuEl = null;
96
- this._filterDropdownEl = null;
97
- this._filterDropdownCleanup = null;
98
- this._colWidths = {};
99
- this._colWidthsSig = null;
100
- this._disposers = [];
101
- this._contextMenuGlobals = [];
102
- if (this.config.persistKey) {
103
- const store = this.config.stateStore;
104
- this._restorePersisted(store.get(this.config.persistKey));
105
- store.ready().then(() => {
106
- const late = store.get(this.config.persistKey);
107
- if (late && this._restorePersisted(late) && this._wrapperEl) {
108
- this._invalidateProcessedCache?.();
109
- this.render();
110
- }
111
- });
112
- }
113
- }
114
- /** Apply a persisted blob ({sort, asc, filters, widths}) onto this
115
- * table's live state. Returns true if anything was applied. */
116
- _restorePersisted(blob) {
117
- if (!blob) return false;
118
- let applied = false;
119
- if (typeof blob.sortColumn === "number" || blob.sortColumn === null) {
120
- this._state.sortColumn = blob.sortColumn;
121
- this._state.sortAscending = blob.sortAscending !== false;
122
- applied = true;
123
- }
124
- if (Array.isArray(blob.filters)) {
125
- this._state.filters = new Map(blob.filters);
126
- applied = true;
127
- }
128
- if (blob.colWidths && typeof blob.colWidths === "object") {
129
- this._colWidths = {};
130
- for (const [k, v] of Object.entries(blob.colWidths)) {
131
- this._colWidths[Number(k)] = v;
132
- }
133
- this._colWidthsSig = this._colSig();
134
- applied = true;
135
- }
136
- return applied;
137
- }
138
- /** Identity of the current column set — restored widths/sort are
139
- * keyed by position, so a schema change invalidates them. */
140
- _colSig() {
141
- return (this.config.headers || []).join("") + (this.config.showRowNumbers ? "|#" : "");
142
- }
143
- /** Snapshot the persistable slice of state to the project store.
144
- * No-op unless `persistKey` is set. Debounced inside the store. */
145
- _savePersisted() {
146
- if (!this.config.persistKey) return;
147
- this.config.stateStore.set(this.config.persistKey, {
148
- sortColumn: this._state.sortColumn,
149
- sortAscending: this._state.sortAscending,
150
- filters: [...this._state.filters.entries()],
151
- colWidths: { ...this._colWidths }
152
- });
153
- }
154
- /**
155
- * Update data and re-render
156
- * @param {Object} updates - Partial config updates (headers, rows, etc.)
157
- */
158
- setData(updates) {
159
- if (updates.headers && this._state.filters.size > 0) {
160
- const oldHeaders = this.config.headers;
161
- const newHeaders = updates.headers;
162
- if (oldHeaders.length !== newHeaders.length || oldHeaders.some((h, i) => h !== newHeaders[i])) {
163
- this._state.filters.clear();
164
- }
165
- }
166
- Object.assign(this.config, updates);
167
- if (updates.rows) {
168
- this._state.selected.clear();
169
- this._state.anchorIndex = null;
170
- if (this._state.offset >= updates.rows.length) {
171
- this._state.offset = 0;
172
- }
173
- this._columnTypes = this._detectColumnTypes();
174
- }
175
- this._processedRows = null;
176
- this._processedIndexMap = null;
177
- this.render();
178
- }
179
- /**
180
- * Show / hide a translucent loading overlay over the table body.
181
- * Used by server-side consumers between firing a fetch and
182
- * receiving the new rows so the UI doesn't appear frozen.
183
- * Callers either:
184
- * - call `setLoading(true)` before their fetch, `setLoading(false)` after, OR
185
- * - return a Promise from `onPageChange` / `onSort` callbacks
186
- * — the overlay auto-shows during the await.
187
- * @param {boolean} on
188
- */
189
- setLoading(on) {
190
- if (!this._wrapperEl) return;
191
- let overlay = this._wrapperEl.querySelector(".twm-data-table__loading");
192
- if (on) {
193
- if (!overlay) {
194
- overlay = document.createElement("div");
195
- overlay.className = "twm-data-table__loading";
196
- overlay.innerHTML = '<div class="twm-data-table__spinner"></div>';
197
- if (!this._wrapperEl.style.position) {
198
- this._wrapperEl.style.position = "relative";
199
- }
200
- this._wrapperEl.appendChild(overlay);
201
- }
202
- } else if (overlay) {
203
- overlay.remove();
204
- }
205
- }
206
- /**
207
- * Get current selection indices
208
- * @returns {number[]} Array of selected row indices
209
- */
210
- getSelection() {
211
- return Array.from(this._state.selected).sort((a, b) => a - b);
212
- }
213
- /**
214
- * Set selection programmatically
215
- * @param {number[]} indices - Row indices to select
216
- */
217
- setSelection(indices) {
218
- this._state.selected.clear();
219
- for (const idx of indices) {
220
- if (idx >= 0 && idx < this.config.rows.length) {
221
- this._state.selected.add(idx);
222
- }
223
- }
224
- this._updateRowSelection();
225
- this._notifySelectionChange();
226
- }
227
- /**
228
- * Clear selection
229
- */
230
- clearSelection() {
231
- this._state.selected.clear();
232
- this._state.anchorIndex = null;
233
- this._updateRowSelection();
234
- this._notifySelectionChange();
235
- }
236
- /**
237
- * Go to specific page
238
- * @param {number} page - Page number (0-indexed)
239
- */
240
- goToPage(page) {
241
- const rowCount = this._getProcessedRows().length;
242
- const maxPage = Math.ceil(rowCount / this.config.pageSize) - 1;
243
- this._state.offset = Math.max(0, Math.min(page, maxPage)) * this.config.pageSize;
244
- this.render();
245
- }
246
- /**
247
- * Go to specific row
248
- * @param {number} rowIndex - Row index
249
- */
250
- goToRow(rowIndex) {
251
- const rowCount = this._getProcessedRows().length;
252
- const idx = Math.max(0, Math.min(rowIndex, rowCount - 1));
253
- this._state.offset = Math.floor(idx / this.config.pageSize) * this.config.pageSize;
254
- this.render();
255
- }
256
- /**
257
- * Sort by column
258
- * @param {number} colIndex - Column index
259
- * @param {boolean} [ascending] - Sort direction (toggles if same column)
260
- */
261
- sortBy(colIndex, ascending) {
262
- if (this._state.sortColumn === colIndex && ascending === void 0) {
263
- this._state.sortAscending = !this._state.sortAscending;
264
- } else {
265
- this._state.sortColumn = colIndex;
266
- this._state.sortAscending = ascending ?? true;
267
- }
268
- this._processedRows = null;
269
- this._processedIndexMap = null;
270
- const ret = this.config.onSort?.(colIndex, this._state.sortAscending);
271
- this._awaitWithSpinner(ret);
272
- this._savePersisted();
273
- this.render();
274
- }
275
- /** Internal: if `maybePromise` is a Promise (or thenable),
276
- * toggle the loading overlay around it. No-op otherwise. */
277
- _awaitWithSpinner(maybePromise) {
278
- if (!maybePromise || typeof maybePromise.then !== "function") return;
279
- this.setLoading(true);
280
- Promise.resolve(maybePromise).finally(() => this.setLoading(false));
281
- }
282
- /**
283
- * Clear all column filters
284
- */
285
- clearFilters() {
286
- this._state.filters.clear();
287
- this._processedRows = null;
288
- this._processedIndexMap = null;
289
- this._state.offset = 0;
290
- this._savePersisted();
291
- this.render();
292
- }
293
- /**
294
- * Get the number of rows after filtering
295
- * @returns {number}
296
- */
297
- getFilteredRowCount() {
298
- return this._getProcessedRows().length;
299
- }
300
- /**
301
- * Render the table
302
- */
303
- render() {
304
- const activeFilterColIdx = this._getActiveFilterColIdx();
305
- const doc = this.container.ownerDocument;
306
- const hadTableFocus = !!this._tableEl && doc?.activeElement === this._tableEl;
307
- this._cleanup();
308
- this.container.innerHTML = "";
309
- const { rows, pagination, emptyMessage } = this.config;
310
- const colSig = this._colSig();
311
- if (this._colWidthsSig !== colSig) {
312
- this._colWidths = {};
313
- this._colWidthsSig = colSig;
314
- }
315
- if (this._columnTypes.length === 0) {
316
- this._columnTypes = this._detectColumnTypes();
317
- }
318
- this._wrapperEl = document.createElement("div");
319
- this._wrapperEl.className = "twm-data-table-component" + (this.config.mode === "compact" ? " twm-data-table-component--compact" : "");
320
- this._wrapperEl.style.cssText = "display:flex; flex-direction:column; height:100%; min-height:0;";
321
- if (pagination && rows.length > 0) {
322
- this._paginationEl = this._createPagination();
323
- if (this._paginationEl) this._wrapperEl.appendChild(this._paginationEl);
324
- }
325
- const tableWrap = document.createElement("div");
326
- tableWrap.className = this.config.readonly ? "twm-preview-table-wrap twm-preview-table-wrap--wizard" : "twm-preview-table-wrap";
327
- tableWrap.style.cssText = "flex:1 1 0; min-height:0; min-width:0; overflow:auto;";
328
- if (rows.length === 0) {
329
- const { headers, showRowNumbers } = this.config;
330
- const headerWrap = document.createElement("div");
331
- headerWrap.className = "twm-preview-table-header-wrap";
332
- headerWrap.style.cssText = "flex:0 0 auto; overflow:hidden; min-width:0;";
333
- const headerTable = document.createElement("table");
334
- headerTable.className = this.config.readonly ? "twm-preview-table twm-preview-table--readonly" : "twm-preview-table";
335
- const thead = document.createElement("thead");
336
- const tr = document.createElement("tr");
337
- if (showRowNumbers) {
338
- const th = document.createElement("th");
339
- th.className = "num";
340
- th.textContent = "#";
341
- tr.appendChild(th);
342
- }
343
- headers.forEach((h, colIdx) => {
344
- const th = document.createElement("th");
345
- th.className = this._columnTypes[colIdx] || "text";
346
- th.textContent = h;
347
- tr.appendChild(th);
348
- });
349
- thead.appendChild(tr);
350
- headerTable.appendChild(thead);
351
- headerWrap.appendChild(headerTable);
352
- this._wrapperEl.appendChild(headerWrap);
353
- this._headerWrapEl = headerWrap;
354
- this._headerTableEl = headerTable;
355
- const bodyTable = document.createElement("table");
356
- bodyTable.className = headerTable.className;
357
- const tbody = document.createElement("tbody");
358
- const emptyTr = document.createElement("tr");
359
- emptyTr.className = "data-preview-row data-table__empty-row";
360
- const colCount = headers.length + (showRowNumbers ? 1 : 0);
361
- const emptyTd = document.createElement("td");
362
- emptyTd.colSpan = colCount;
363
- emptyTd.style.cssText = "text-align:center; font-style:italic; color:#888; padding:16px;";
364
- emptyTd.textContent = emptyMessage;
365
- emptyTr.appendChild(emptyTd);
366
- tbody.appendChild(emptyTr);
367
- bodyTable.appendChild(tbody);
368
- tableWrap.appendChild(bodyTable);
369
- this._wrapperEl.appendChild(tableWrap);
370
- this._tableEl = bodyTable;
371
- this._tableWrapEl = tableWrap;
372
- } else {
373
- const fullTable = this._createTable();
374
- this._tableEl = fullTable;
375
- const thead = fullTable.querySelector("thead");
376
- if (thead) {
377
- const headerWrap = document.createElement("div");
378
- headerWrap.className = "twm-preview-table-header-wrap";
379
- headerWrap.style.cssText = "flex:0 0 auto; overflow:hidden; min-width:0;";
380
- const headerTable = document.createElement("table");
381
- headerTable.className = fullTable.className;
382
- headerTable.appendChild(thead);
383
- headerWrap.appendChild(headerTable);
384
- this._wrapperEl.appendChild(headerWrap);
385
- this._headerTableEl = headerTable;
386
- this._headerWrapEl = headerWrap;
387
- } else {
388
- this._headerTableEl = null;
389
- }
390
- tableWrap.appendChild(fullTable);
391
- this._wrapperEl.appendChild(tableWrap);
392
- }
393
- this._tableWrapEl = tableWrap;
394
- this.container.appendChild(this._wrapperEl);
395
- if (this._headerTableEl && this._tableEl) {
396
- requestAnimationFrame(() => this._syncHeaderWidths());
397
- if (this._resizeObserver) {
398
- try {
399
- this._resizeObserver.disconnect();
400
- } catch (_) {
401
- }
402
- }
403
- if (typeof ResizeObserver !== "undefined") {
404
- this._resizeObserver = createRafResizeObserver(
405
- () => this._syncHeaderWidths()
406
- );
407
- this._resizeObserver.observe(this._wrapperEl);
408
- }
409
- this._installHeaderScrollSync();
410
- if (rows.length > 0) this._installColumnResizers();
411
- }
412
- if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly)) {
413
- this._installInteractions();
414
- }
415
- if (activeFilterColIdx !== null) {
416
- this._restoreFilterFocus(activeFilterColIdx);
417
- } else if (hadTableFocus && this._tableEl) {
418
- try {
419
- this._tableEl.focus({ preventScroll: true });
420
- } catch (_) {
421
- }
422
- }
423
- }
424
- /**
425
- * Copy selected rows to clipboard
426
- * @param {'tsv'|'csv'} [format='tsv'] - Output format
427
- */
428
- async copyToClipboard(format = "tsv") {
429
- const indices = this.getSelection();
430
- if (indices.length === 0) {
431
- this._notify("Copy", "No rows selected.", "warn");
432
- return;
433
- }
434
- const { headers, rows } = this.config;
435
- const matrix = [headers];
436
- for (const idx of indices) {
437
- const row = rows[idx];
438
- if (row) {
439
- matrix.push(row.map((val, colIdx) => this._formatValue(val, colIdx)));
440
- }
441
- }
442
- const separator = format === "csv" ? "," : " ";
443
- const text = format === "csv" ? matrix.map((row) => row.map((cell) => this._csvEscape(cell)).join(separator)).join("\n") : matrix.map((row) => row.join(separator)).join("\n");
444
- let ok = false;
445
- try {
446
- await navigator.clipboard.writeText(text);
447
- ok = true;
448
- } catch (_) {
449
- ok = this._fallbackCopy(text);
450
- }
451
- if (ok) {
452
- const desc = indices.length === 1 ? "row" : "rows";
453
- const suffix = format === "csv" ? " as CSV" : "";
454
- this._notify("Copy", `Copied ${indices.length} ${desc}${suffix}.`, "info");
455
- } else {
456
- this._notify("Copy", "Clipboard unavailable.", "warn");
457
- }
458
- this._hideContextMenu();
459
- }
460
- /**
461
- * Download all data as CSV
462
- * @param {string} [filename='data.csv'] - Filename for download
463
- */
464
- async downloadCSV(filename = "data.csv") {
465
- const { headers, rows } = this.config;
466
- if (rows.length === 0) {
467
- this._notify("Download", "No data to download.", "warn");
468
- return;
469
- }
470
- const lines = [headers.join(",")];
471
- for (const row of rows) {
472
- lines.push(row.map(
473
- (val, colIdx) => this._csvEscape(this._formatValue(val, colIdx))
474
- ).join(","));
475
- }
476
- const csv = lines.join("\n");
477
- const dialogs = this.config.host?.dialogs;
478
- let result = null;
479
- if (dialogs) {
480
- try {
481
- result = await dialogs.saveFile({ data: csv, filename, kind: "csv" });
482
- } catch (err) {
483
- console.error("[DataTable] CSV download failed:", err);
484
- this._notify("Download", "Failed to save file", "error");
485
- return;
486
- }
487
- }
488
- if (result) {
489
- if (result.ok) {
490
- this._notify("Download", `Saved ${rows.length} rows to ${result.path}`, "success");
491
- } else if (!result.cancelled) {
492
- this._notify("Download", result.error || "Failed to save file", "error");
493
- }
494
- return;
495
- }
496
- const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
497
- const url = URL.createObjectURL(blob);
498
- const link = document.createElement("a");
499
- link.href = url;
500
- link.download = filename;
501
- link.click();
502
- URL.revokeObjectURL(url);
503
- this._notify("Download", "Download started", "info");
504
- }
505
- /**
506
- * Dispose and cleanup
507
- */
508
- dispose() {
509
- this._cleanup();
510
- this._closeFilterDropdown();
511
- this._teardownContextMenu();
512
- if (this._resizeObserver) {
513
- try {
514
- this._resizeObserver.disconnect();
515
- } catch (_) {
516
- }
517
- this._resizeObserver = null;
518
- }
519
- if (this._onBodyScroll && this._scrollSyncEl) {
520
- try {
521
- this._scrollSyncEl.removeEventListener("scroll", this._onBodyScroll);
522
- } catch (_) {
523
- }
524
- this._onBodyScroll = null;
525
- this._scrollSyncEl = null;
526
- }
527
- this.container.innerHTML = "";
528
- this._wrapperEl = null;
529
- this._paginationEl = null;
530
- this._tableEl = null;
531
- this._tbodyEl = null;
532
- this._headerTableEl = null;
533
- this._headerWrapEl = null;
534
- this._tableWrapEl = null;
535
- this._processedRows = null;
536
- this._processedIndexMap = null;
537
- }
538
- // ─────────────────────────────────────────────────────────────────
539
- // Processed rows pipeline (filter → sort → cache)
540
- // ─────────────────────────────────────────────────────────────────
541
- _getProcessedRows() {
542
- if (this._processedRows !== null) return this._processedRows;
543
- const isServerSide = typeof this.config.onPageChange === "function";
544
- let rows = this.config.rows;
545
- let indexMap = rows.map((_, i) => i);
546
- if (!isServerSide && this._state.filters.size > 0) {
547
- const result = this._applyFilters(rows, indexMap);
548
- rows = result.rows;
549
- indexMap = result.indexMap;
550
- }
551
- if (!isServerSide && this.config.sortable && this._state.sortColumn !== null && !this.config.onSort) {
552
- const result = this._applySorting(rows, indexMap);
553
- rows = result.rows;
554
- indexMap = result.indexMap;
555
- }
556
- this._processedRows = rows;
557
- this._processedIndexMap = indexMap;
558
- return rows;
559
- }
560
- _applyFilters(rows, indexMap) {
561
- const filters = this._state.filters;
562
- const filteredRows = [];
563
- const filteredMap = [];
564
- for (let i = 0; i < rows.length; i++) {
565
- const row = rows[i];
566
- let pass = true;
567
- for (const [colIdx, filterText] of filters) {
568
- if (!filterText) continue;
569
- const value = row[colIdx];
570
- const colType = this._columnTypes[colIdx];
571
- if (colType === "num") {
572
- if (!this._matchNumericFilter(value, filterText)) {
573
- pass = false;
574
- break;
575
- }
576
- } else {
577
- if (!this._matchTextFilter(value, filterText)) {
578
- pass = false;
579
- break;
580
- }
581
- }
582
- }
583
- if (pass) {
584
- filteredRows.push(row);
585
- filteredMap.push(indexMap[i]);
586
- }
587
- }
588
- return { rows: filteredRows, indexMap: filteredMap };
589
- }
590
- _applySorting(rows, indexMap) {
591
- const colIdx = this._state.sortColumn;
592
- const asc = this._state.sortAscending;
593
- const paired = rows.map((row, i) => ({ row, origIdx: indexMap[i] }));
594
- const isNumeric = this._columnTypes[colIdx] === "num";
595
- paired.sort((a, b) => {
596
- let valA = a.row[colIdx];
597
- let valB = b.row[colIdx];
598
- if (valA == null && valB == null) return 0;
599
- if (valA == null) return 1;
600
- if (valB == null) return -1;
601
- if (isNumeric) {
602
- valA = typeof valA === "number" ? valA : parseFloat(valA);
603
- valB = typeof valB === "number" ? valB : parseFloat(valB);
604
- if (!Number.isFinite(valA)) return 1;
605
- if (!Number.isFinite(valB)) return -1;
606
- } else {
607
- valA = String(valA).toLowerCase();
608
- valB = String(valB).toLowerCase();
609
- }
610
- let cmp = 0;
611
- if (valA < valB) cmp = -1;
612
- else if (valA > valB) cmp = 1;
613
- return asc ? cmp : -cmp;
614
- });
615
- return {
616
- rows: paired.map((p) => p.row),
617
- indexMap: paired.map((p) => p.origIdx)
618
- };
619
- }
620
- _matchNumericFilter(value, filterText) {
621
- const text = filterText.trim();
622
- if (!text) return true;
623
- const numVal = typeof value === "number" ? value : parseFloat(value);
624
- if (!Number.isFinite(numVal)) return false;
625
- const rangeMatch = text.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
626
- if (rangeMatch) {
627
- const lo = parseFloat(rangeMatch[1]);
628
- const hi = parseFloat(rangeMatch[2]);
629
- return numVal >= lo && numVal <= hi;
630
- }
631
- const opMatch = text.match(/^(>=|<=|!=|>|<|=)\s*(-?[\d.]+)$/);
632
- if (opMatch) {
633
- const op = opMatch[1];
634
- const target = parseFloat(opMatch[2]);
635
- if (!Number.isFinite(target)) return true;
636
- switch (op) {
637
- case ">":
638
- return numVal > target;
639
- case "<":
640
- return numVal < target;
641
- case ">=":
642
- return numVal >= target;
643
- case "<=":
644
- return numVal <= target;
645
- case "!=":
646
- return Math.abs(numVal - target) > 1e-9;
647
- case "=":
648
- return Math.abs(numVal - target) <= 1e-9;
649
- }
650
- }
651
- const plain = parseFloat(text);
652
- if (Number.isFinite(plain)) {
653
- return Math.abs(numVal - plain) <= 1e-9;
654
- }
655
- return true;
656
- }
657
- _matchTextFilter(value, filterText) {
658
- if (!filterText) return true;
659
- const haystack = (value == null ? "" : String(value)).toLowerCase();
660
- const text = filterText.trim();
661
- if (text.startsWith('="') && text.endsWith('"')) {
662
- return haystack === text.slice(2, -1).toLowerCase();
663
- }
664
- if (text.startsWith("!")) {
665
- return !haystack.includes(text.slice(1).toLowerCase());
666
- }
667
- if (text.startsWith("^")) {
668
- return haystack.startsWith(text.slice(1).toLowerCase());
669
- }
670
- if (text.endsWith("$")) {
671
- return haystack.endsWith(text.slice(0, -1).toLowerCase());
672
- }
673
- return haystack.includes(text.toLowerCase());
674
- }
675
- _invalidateProcessedCache() {
676
- this._processedRows = null;
677
- this._processedIndexMap = null;
678
- }
679
- // ─────────────────────────────────────────────────────────────────
680
- // Filter row
681
- // ─────────────────────────────────────────────────────────────────
682
- _createFilterRow(headers) {
683
- const { showRowNumbers } = this.config;
684
- const tr = document.createElement("tr");
685
- tr.className = "twm-data-table__filter-row";
686
- if (showRowNumbers) {
687
- const th = document.createElement("th");
688
- th.className = "data-table__filter-cell data-table__filter-cell--empty";
689
- tr.appendChild(th);
690
- }
691
- headers.forEach((header, colIdx) => {
692
- const th = document.createElement("th");
693
- th.className = "data-table__filter-cell";
694
- const wrapper = document.createElement("div");
695
- wrapper.className = "twm-data-table__filter-wrapper";
696
- const input = document.createElement("input");
697
- input.type = "text";
698
- input.className = "twm-data-table__filter-input";
699
- const isNumeric = this._columnTypes[colIdx] === "num";
700
- input.placeholder = isNumeric ? "e.g. >100" : "Filter...";
701
- const existingFilter = this._state.filters.get(colIdx);
702
- if (existingFilter) {
703
- input.value = existingFilter;
704
- }
705
- const dropdownBtn = document.createElement("button");
706
- dropdownBtn.type = "button";
707
- dropdownBtn.className = "twm-data-table__filter-dropdown-btn";
708
- dropdownBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:14px;">tune</span>';
709
- dropdownBtn.addEventListener("click", (e) => {
710
- e.stopPropagation();
711
- this._openFilterDropdown(colIdx, th, input);
712
- });
713
- const clearBtn = document.createElement("button");
714
- clearBtn.type = "button";
715
- clearBtn.className = "twm-data-table__filter-clear";
716
- clearBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:12px;">close</span>';
717
- clearBtn.style.display = existingFilter ? "" : "none";
718
- clearBtn.addEventListener("click", (e) => {
719
- e.stopPropagation();
720
- input.value = "";
721
- this._onFilterInput(colIdx, "");
722
- clearBtn.style.display = "none";
723
- input.focus();
724
- });
725
- input.addEventListener("input", () => {
726
- clearBtn.style.display = input.value ? "" : "none";
727
- this._onFilterInputDebounced(colIdx, input.value);
728
- });
729
- th.addEventListener("click", (e) => e.stopPropagation());
730
- wrapper.appendChild(input);
731
- wrapper.appendChild(dropdownBtn);
732
- wrapper.appendChild(clearBtn);
733
- th.appendChild(wrapper);
734
- tr.appendChild(th);
735
- });
736
- return tr;
737
- }
738
- // ─────────────────────────────────────────────────────────────────
739
- // Filter dropdown
740
- // ─────────────────────────────────────────────────────────────────
741
- _openFilterDropdown(colIdx, anchorEl, filterInput) {
742
- this._closeFilterDropdown();
743
- const isNumeric = this._columnTypes[colIdx] === "num";
744
- const currentFilter = this._state.filters.get(colIdx) || "";
745
- const parsed = this._parseFilterForDropdown(currentFilter, isNumeric);
746
- const panel = document.createElement("div");
747
- panel.className = "twm-data-table__filter-dropdown";
748
- const selectLabel = document.createElement("label");
749
- selectLabel.className = "twm-data-table__filter-dropdown-label";
750
- selectLabel.textContent = isNumeric ? "Operator" : "Mode";
751
- const select = document.createElement("select");
752
- select.className = "twm-data-table__filter-dropdown-select";
753
- const options = isNumeric ? [
754
- { value: "=", label: "Equals" },
755
- { value: "!=", label: "Not equals" },
756
- { value: ">", label: "Greater than" },
757
- { value: ">=", label: "Greater or equal" },
758
- { value: "<", label: "Less than" },
759
- { value: "<=", label: "Less or equal" },
760
- { value: "..", label: "Between" }
761
- ] : [
762
- { value: "contains", label: "Contains" },
763
- { value: "equals", label: "Equals" },
764
- { value: "starts", label: "Starts with" },
765
- { value: "ends", label: "Ends with" },
766
- { value: "not", label: "Not contains" }
767
- ];
768
- for (const opt of options) {
769
- const optEl = document.createElement("option");
770
- optEl.value = opt.value;
771
- optEl.textContent = opt.label;
772
- if (opt.value === parsed.operator) optEl.selected = true;
773
- select.appendChild(optEl);
774
- }
775
- const valueLabel = document.createElement("label");
776
- valueLabel.className = "twm-data-table__filter-dropdown-label";
777
- valueLabel.textContent = "Value";
778
- const valueInput = document.createElement("input");
779
- valueInput.type = isNumeric ? "number" : "text";
780
- valueInput.className = "twm-data-table__filter-dropdown-input";
781
- valueInput.placeholder = isNumeric ? "Number..." : "Text...";
782
- valueInput.value = parsed.value;
783
- const value2Label = document.createElement("label");
784
- value2Label.className = "twm-data-table__filter-dropdown-label";
785
- value2Label.textContent = "And";
786
- const value2Input = document.createElement("input");
787
- value2Input.type = "number";
788
- value2Input.className = "twm-data-table__filter-dropdown-input";
789
- value2Input.placeholder = "Number...";
790
- value2Input.value = parsed.value2;
791
- const value2Container = document.createElement("div");
792
- value2Container.className = "twm-data-table__filter-dropdown-between";
793
- value2Container.style.display = isNumeric && parsed.operator === ".." ? "" : "none";
794
- value2Container.appendChild(value2Label);
795
- value2Container.appendChild(value2Input);
796
- select.addEventListener("change", () => {
797
- value2Container.style.display = isNumeric && select.value === ".." ? "" : "none";
798
- });
799
- const actions = document.createElement("div");
800
- actions.className = "twm-data-table__filter-dropdown-actions";
801
- const clearBtn = document.createElement("button");
802
- clearBtn.type = "button";
803
- clearBtn.className = "twm-data-table__filter-dropdown-btn-action twm-data-table__filter-dropdown-btn-action--clear";
804
- clearBtn.textContent = "Clear";
805
- clearBtn.addEventListener("click", (e) => {
806
- e.stopPropagation();
807
- filterInput.value = "";
808
- this._onFilterInput(colIdx, "");
809
- this._closeFilterDropdown();
810
- });
811
- const applyBtn = document.createElement("button");
812
- applyBtn.type = "button";
813
- applyBtn.className = "twm-data-table__filter-dropdown-btn-action twm-data-table__filter-dropdown-btn-action--apply";
814
- applyBtn.textContent = "Apply";
815
- applyBtn.addEventListener("click", (e) => {
816
- e.stopPropagation();
817
- const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, isNumeric);
818
- filterInput.value = composed;
819
- this._onFilterInput(colIdx, composed);
820
- this._closeFilterDropdown();
821
- });
822
- actions.appendChild(clearBtn);
823
- actions.appendChild(applyBtn);
824
- panel.appendChild(selectLabel);
825
- panel.appendChild(select);
826
- panel.appendChild(valueLabel);
827
- panel.appendChild(valueInput);
828
- panel.appendChild(value2Container);
829
- panel.appendChild(actions);
830
- document.body.appendChild(panel);
831
- const anchorRect = anchorEl.getBoundingClientRect();
832
- let left = anchorRect.left;
833
- let top = anchorRect.bottom + 4;
834
- const panelRect = panel.getBoundingClientRect();
835
- if (left + panelRect.width > window.innerWidth) {
836
- left = window.innerWidth - panelRect.width - 8;
837
- }
838
- if (top + panelRect.height > window.innerHeight) {
839
- top = anchorRect.top - panelRect.height - 4;
840
- }
841
- panel.style.left = `${left}px`;
842
- panel.style.top = `${top}px`;
843
- requestAnimationFrame(() => valueInput.focus());
844
- const handleOutsideClick = (e) => {
845
- if (!panel.contains(e.target)) {
846
- this._closeFilterDropdown();
847
- }
848
- };
849
- const handleEscape = (e) => {
850
- if (e.key === "Escape") {
851
- this._closeFilterDropdown();
852
- }
853
- };
854
- const handleEnter = (e) => {
855
- if (e.key === "Enter") {
856
- e.preventDefault();
857
- const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, isNumeric);
858
- filterInput.value = composed;
859
- this._onFilterInput(colIdx, composed);
860
- this._closeFilterDropdown();
861
- }
862
- };
863
- setTimeout(() => {
864
- document.addEventListener("click", handleOutsideClick, true);
865
- }, 0);
866
- document.addEventListener("keydown", handleEscape);
867
- panel.addEventListener("keydown", handleEnter);
868
- this._filterDropdownEl = panel;
869
- this._filterDropdownCleanup = () => {
870
- document.removeEventListener("click", handleOutsideClick, true);
871
- document.removeEventListener("keydown", handleEscape);
872
- };
873
- }
874
- _closeFilterDropdown() {
875
- if (this._filterDropdownEl?.parentNode) {
876
- this._filterDropdownEl.parentNode.removeChild(this._filterDropdownEl);
877
- }
878
- this._filterDropdownCleanup?.();
879
- this._filterDropdownEl = null;
880
- this._filterDropdownCleanup = null;
881
- }
882
- _parseFilterForDropdown(filterText, isNumeric) {
883
- const text = (filterText || "").trim();
884
- if (!text) {
885
- return { operator: isNumeric ? "=" : "contains", value: "", value2: "" };
886
- }
887
- if (isNumeric) {
888
- const rangeMatch = text.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
889
- if (rangeMatch) {
890
- return { operator: "..", value: rangeMatch[1], value2: rangeMatch[2] };
891
- }
892
- const opMatch = text.match(/^(>=|<=|!=|>|<|=)\s*(-?[\d.]+)$/);
893
- if (opMatch) {
894
- return { operator: opMatch[1], value: opMatch[2], value2: "" };
895
- }
896
- return { operator: "=", value: text, value2: "" };
897
- }
898
- if (text.startsWith('="') && text.endsWith('"')) {
899
- return { operator: "equals", value: text.slice(2, -1), value2: "" };
900
- }
901
- if (text.startsWith("!")) {
902
- return { operator: "not", value: text.slice(1), value2: "" };
903
- }
904
- if (text.startsWith("^")) {
905
- return { operator: "starts", value: text.slice(1), value2: "" };
906
- }
907
- if (text.endsWith("$")) {
908
- return { operator: "ends", value: text.slice(0, -1), value2: "" };
909
- }
910
- return { operator: "contains", value: text, value2: "" };
911
- }
912
- _composeFilterString(operator, value, value2, isNumeric) {
913
- if (!value && operator !== "..") return "";
914
- if (isNumeric) {
915
- if (operator === "..") {
916
- return value && value2 ? `${value}..${value2}` : "";
917
- }
918
- if (operator === "=") return value;
919
- return `${operator}${value}`;
920
- }
921
- switch (operator) {
922
- case "contains":
923
- return value;
924
- case "equals":
925
- return value ? `="${value}"` : "";
926
- case "starts":
927
- return value ? `^${value}` : "";
928
- case "ends":
929
- return value ? `${value}$` : "";
930
- case "not":
931
- return value ? `!${value}` : "";
932
- default:
933
- return value;
934
- }
935
- }
936
- _onFilterInputDebounced(colIdx, value) {
937
- if (this._filterDebounceTimer) {
938
- clearTimeout(this._filterDebounceTimer);
939
- }
940
- this._filterDebounceTimer = setTimeout(() => {
941
- this._onFilterInput(colIdx, value);
942
- }, 200);
943
- }
944
- _onFilterInput(colIdx, value) {
945
- if (value) {
946
- this._state.filters.set(colIdx, value);
947
- } else {
948
- this._state.filters.delete(colIdx);
949
- }
950
- this._invalidateProcessedCache();
951
- this._state.offset = 0;
952
- this._state.selected.clear();
953
- this._state.anchorIndex = null;
954
- this._savePersisted();
955
- this.render();
956
- }
957
- _getActiveFilterColIdx() {
958
- const active = document.activeElement;
959
- if (!active || !active.classList.contains("twm-data-table__filter-input")) return null;
960
- const cell = active.closest(".data-table__filter-cell");
961
- if (!cell) return null;
962
- const row = cell.parentElement;
963
- if (!row) return null;
964
- const cells = Array.from(row.children);
965
- const idx = cells.indexOf(cell);
966
- return this.config.showRowNumbers ? idx - 1 : idx;
967
- }
968
- _restoreFilterFocus(colIdx) {
969
- const filterRow = this._wrapperEl?.querySelector(".twm-data-table__filter-row");
970
- if (!filterRow) return;
971
- const cellIdx = this.config.showRowNumbers ? colIdx + 1 : colIdx;
972
- const cell = filterRow.children[cellIdx];
973
- const input = cell?.querySelector(".twm-data-table__filter-input");
974
- if (input) {
975
- input.focus();
976
- input.setSelectionRange(input.value.length, input.value.length);
977
- }
978
- }
979
- // ─────────────────────────────────────────────────────────────────
980
- // Private methods
981
- // ─────────────────────────────────────────────────────────────────
982
- _cleanup() {
983
- this._disposers.forEach((dispose) => {
984
- try {
985
- dispose?.();
986
- } catch (_) {
987
- }
988
- });
989
- this._disposers = [];
990
- this._closeFilterDropdown();
991
- if (this._filterDebounceTimer) {
992
- clearTimeout(this._filterDebounceTimer);
993
- this._filterDebounceTimer = null;
994
- }
995
- }
996
- _detectColumnTypes() {
997
- const { headers, rows, getColumnType } = this.config;
998
- const types = [];
999
- for (let colIdx = 0; colIdx < headers.length; colIdx++) {
1000
- if (getColumnType) {
1001
- types.push(getColumnType(colIdx, rows));
1002
- continue;
1003
- }
1004
- let numericCount = 0;
1005
- let sampleCount = 0;
1006
- const maxSamples = Math.min(10, rows.length);
1007
- for (let i = 0; i < maxSamples; i++) {
1008
- const value = rows[i]?.[colIdx];
1009
- if (value != null && value !== "") {
1010
- sampleCount++;
1011
- if (typeof value === "number" || /^-?[\d,.]+%?$/.test(String(value).trim())) {
1012
- numericCount++;
1013
- }
1014
- }
1015
- }
1016
- types.push(sampleCount > 0 && numericCount / sampleCount > 0.5 ? "num" : "text");
1017
- }
1018
- return types;
1019
- }
1020
- _createPagination() {
1021
- const { rows, pageSize, totalCount: configTotalCount, offset: configOffset, onPageChange, onJumpToRow } = this.config;
1022
- const isServerSide = typeof onPageChange === "function";
1023
- const offset = isServerSide ? configOffset || 0 : this._state.offset;
1024
- const processedRows = isServerSide ? rows : this._getProcessedRows();
1025
- const effectiveTotal = isServerSide ? configTotalCount || rows.length : processedRows.length;
1026
- const unfilteredTotal = isServerSide ? configTotalCount || rows.length : rows.length;
1027
- const isFiltered = !isServerSide && this._state.filters.size > 0;
1028
- const displayedRows = isServerSide ? rows.length : Math.min(pageSize, effectiveTotal - offset);
1029
- const endRow = Math.min(offset + displayedRows, effectiveTotal);
1030
- const totalPages = Math.max(1, Math.ceil(effectiveTotal / pageSize));
1031
- if (totalPages <= 1 && !isFiltered) return null;
1032
- const currentPage = Math.floor(offset / pageSize) + 1;
1033
- const hasPrev = offset > 0;
1034
- const hasNext = offset + pageSize < effectiveTotal;
1035
- const el = document.createElement("div");
1036
- el.className = "twm-pagination-controls";
1037
- el.style.cssText = "display:flex; align-items:center; gap:6px; padding:2px 8px; border-bottom:1px solid #2a2a2a; font-size:11px;";
1038
- const info = document.createElement("span");
1039
- info.style.cssText = "color:#aaa; white-space:nowrap;";
1040
- if (effectiveTotal === 0) {
1041
- info.textContent = isFiltered ? `0 of ${unfilteredTotal.toLocaleString()} rows match` : "No rows";
1042
- } else if (isFiltered) {
1043
- info.textContent = `Rows ${offset + 1}\u2013${endRow} of ${effectiveTotal.toLocaleString()} (${unfilteredTotal.toLocaleString()} total)`;
1044
- } else {
1045
- info.textContent = `Rows ${offset + 1}\u2013${endRow} of ${effectiveTotal.toLocaleString()}`;
1046
- }
1047
- const spacer = document.createElement("div");
1048
- spacer.style.flex = "1";
1049
- const handlePageChange = (newOffset) => {
1050
- if (isServerSide) {
1051
- this._awaitWithSpinner(onPageChange(newOffset, pageSize));
1052
- } else {
1053
- this._state.offset = newOffset;
1054
- this.render();
1055
- }
1056
- };
1057
- const firstBtn = this._createPaginationBtn("first_page", "First page", !hasPrev, () => {
1058
- handlePageChange(0);
1059
- });
1060
- const prevBtn = this._createPaginationBtn("chevron_left", "Previous page", !hasPrev, () => {
1061
- handlePageChange(Math.max(0, offset - pageSize));
1062
- });
1063
- const pageInfo = document.createElement("span");
1064
- pageInfo.style.cssText = "color:#aaa; font-size:10px; min-width:72px; text-align:center;";
1065
- pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
1066
- const nextBtn = this._createPaginationBtn("chevron_right", "Next page", !hasNext, () => {
1067
- handlePageChange(offset + pageSize);
1068
- });
1069
- const lastBtn = this._createPaginationBtn("last_page", "Last page", !hasNext, () => {
1070
- handlePageChange(Math.floor((effectiveTotal - 1) / pageSize) * pageSize);
1071
- });
1072
- const jumpInput = document.createElement("input");
1073
- jumpInput.type = "number";
1074
- jumpInput.className = "twm-pagination-input";
1075
- jumpInput.style.cssText = "width:54px; padding:1px 4px; border:1px solid #444; background:#2a2a2a; color:#ccc; font-size:10px;";
1076
- jumpInput.min = "1";
1077
- jumpInput.max = String(effectiveTotal);
1078
- jumpInput.placeholder = "Row #";
1079
- const jumpBtn = this._createPaginationBtn(null, "Go to row", false, () => {
1080
- const target = Number(jumpInput.value);
1081
- if (Number.isFinite(target) && target >= 1) {
1082
- if (isServerSide && onJumpToRow) {
1083
- onJumpToRow(target - 1);
1084
- } else if (isServerSide) {
1085
- const newOffset = Math.floor((target - 1) / pageSize) * pageSize;
1086
- handlePageChange(newOffset);
1087
- } else {
1088
- this.goToRow(target - 1);
1089
- }
1090
- }
1091
- }, "Go");
1092
- el.appendChild(info);
1093
- el.appendChild(spacer);
1094
- el.appendChild(firstBtn);
1095
- el.appendChild(prevBtn);
1096
- el.appendChild(pageInfo);
1097
- el.appendChild(nextBtn);
1098
- el.appendChild(lastBtn);
1099
- el.appendChild(jumpInput);
1100
- el.appendChild(jumpBtn);
1101
- if (this.config.showExportButton) {
1102
- const exportBtn = this._createPaginationBtn(
1103
- "download",
1104
- "Download visible rows as CSV",
1105
- false,
1106
- () => this.downloadCSV("data.csv")
1107
- );
1108
- el.appendChild(exportBtn);
1109
- }
1110
- return el;
1111
- }
1112
- _createPaginationBtn(icon, tooltip, disabled, onClick, textLabel = null) {
1113
- const btn = document.createElement("button");
1114
- btn.type = "button";
1115
- btn.className = "hoverbutton twm-pagination-btn twm-has-tooltip";
1116
- btn.setAttribute("data-tooltip", tooltip);
1117
- btn.disabled = disabled;
1118
- btn.style.cssText = "padding:1px 3px; background:transparent; border:1px solid #444; color:#aaa; cursor:pointer; display:flex; align-items:center; justify-content:center; line-height:1;";
1119
- if (disabled) {
1120
- btn.style.opacity = "0.4";
1121
- btn.style.cursor = "not-allowed";
1122
- }
1123
- if (icon) {
1124
- const iconEl = document.createElement("span");
1125
- iconEl.className = "material-symbols-outlined";
1126
- iconEl.style.fontSize = "14px";
1127
- iconEl.textContent = icon;
1128
- btn.appendChild(iconEl);
1129
- } else if (textLabel) {
1130
- btn.textContent = textLabel;
1131
- btn.style.fontSize = "10px";
1132
- btn.style.padding = "1px 6px";
1133
- }
1134
- btn.addEventListener("click", onClick);
1135
- return btn;
1136
- }
1137
- _createTable() {
1138
- const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
1139
- const { selected, sortColumn, sortAscending } = this._state;
1140
- const isServerSide = typeof onPageChange === "function";
1141
- const processedRows = isServerSide ? rows : this._getProcessedRows();
1142
- const offset = isServerSide ? configOffset || 0 : this._state.offset;
1143
- const pageRows = isServerSide || !this.config.pagination ? processedRows : processedRows.slice(offset, offset + pageSize);
1144
- const table = document.createElement("table");
1145
- table.className = readonly ? "twm-preview-table twm-preview-table--readonly" : "twm-preview-table";
1146
- table.tabIndex = 0;
1147
- const thead = document.createElement("thead");
1148
- const headerRow = document.createElement("tr");
1149
- if (showRowNumbers) {
1150
- const th = document.createElement("th");
1151
- th.className = "num";
1152
- th.textContent = "#";
1153
- headerRow.appendChild(th);
1154
- }
1155
- headers.forEach((header, colIdx) => {
1156
- const th = document.createElement("th");
1157
- th.className = this._columnTypes[colIdx] || "text";
1158
- if (sortable) {
1159
- th.classList.add("sortable");
1160
- th.style.cursor = "pointer";
1161
- }
1162
- if (getHeaderIcon) {
1163
- const iconInfo = getHeaderIcon(header, colIdx);
1164
- if (iconInfo) {
1165
- const iconSpan = document.createElement("span");
1166
- iconSpan.className = "material-symbols-outlined twm-header-icon twm-has-tooltip";
1167
- iconSpan.setAttribute("data-tooltip", iconInfo.title || "");
1168
- iconSpan.style.cssText = "font-size:16px; vertical-align:middle; margin-right:4px; opacity:0.7;";
1169
- iconSpan.textContent = iconInfo.icon;
1170
- th.appendChild(iconSpan);
1171
- }
1172
- }
1173
- th.appendChild(document.createTextNode(header));
1174
- if (sortable) {
1175
- const sortIcon = document.createElement("span");
1176
- sortIcon.className = "material-symbols-outlined twm-sort-icon";
1177
- sortIcon.style.cssText = "font-size:14px; margin-left:4px; opacity:0.5;";
1178
- if (sortColumn === colIdx) {
1179
- sortIcon.textContent = sortAscending ? "arrow_upward" : "arrow_downward";
1180
- sortIcon.style.opacity = "1";
1181
- } else {
1182
- sortIcon.textContent = "unfold_more";
1183
- }
1184
- th.appendChild(sortIcon);
1185
- th.addEventListener("click", () => this.sortBy(colIdx));
1186
- }
1187
- th.title = header;
1188
- headerRow.appendChild(th);
1189
- });
1190
- thead.appendChild(headerRow);
1191
- if (filterable) {
1192
- const filterRow = this._createFilterRow(headers);
1193
- thead.appendChild(filterRow);
1194
- }
1195
- table.appendChild(thead);
1196
- const tbody = document.createElement("tbody");
1197
- pageRows.forEach((row, localIdx) => {
1198
- const processedIdx = offset + localIdx;
1199
- const globalIdx = isServerSide ? (configOffset || 0) + localIdx : this._processedIndexMap?.[processedIdx] ?? processedIdx;
1200
- const tr = document.createElement("tr");
1201
- tr.__rowIndex = globalIdx;
1202
- tr.className = "data-preview-row";
1203
- if (selected.has(globalIdx)) {
1204
- tr.classList.add("selected");
1205
- }
1206
- this._fillRowCells(tr, row, globalIdx, showRowNumbers);
1207
- if (this.config.onRowClick) {
1208
- tr.addEventListener("click", (ev) => {
1209
- this.config.onRowClick(globalIdx, row, ev);
1210
- });
1211
- }
1212
- if (this.config.onRowContextMenu) {
1213
- tr.addEventListener("contextmenu", (ev) => {
1214
- this.config.onRowContextMenu(globalIdx, row, ev);
1215
- });
1216
- }
1217
- tbody.appendChild(tr);
1218
- });
1219
- table.appendChild(tbody);
1220
- this._tbodyEl = tbody;
1221
- return table;
1222
- }
1223
- /** Build (or rebuild) one row's cells in place.
1224
- *
1225
- * Extracted from the body loop so that `updateRow` and the initial
1226
- * render share ONE cell-building path. Two paths would drift, and the
1227
- * drift would show as a cell that renders differently after a live
1228
- * update than it did on load. */
1229
- _fillRowCells(tr, row, globalIdx, showRowNumbers) {
1230
- tr.replaceChildren();
1231
- if (showRowNumbers) {
1232
- const td = document.createElement("td");
1233
- td.className = "num";
1234
- td.textContent = String(globalIdx + 1);
1235
- tr.appendChild(td);
1236
- }
1237
- for (let colIdx = 0; colIdx < row.length; colIdx++) {
1238
- const td = document.createElement("td");
1239
- td.className = this._columnTypes[colIdx] || "text";
1240
- const value = row[colIdx];
1241
- if (this.config.renderCell) {
1242
- const handled = this.config.renderCell(td, value, colIdx, globalIdx, row);
1243
- if (!handled) {
1244
- td.textContent = this._formatValue(value, colIdx);
1245
- }
1246
- } else {
1247
- td.textContent = this._formatValue(value, colIdx);
1248
- }
1249
- if (this.config.onCellContextMenu) {
1250
- const cellColIdx = colIdx;
1251
- td.addEventListener("contextmenu", (ev) => {
1252
- this.config.onCellContextMenu(
1253
- cellColIdx,
1254
- globalIdx,
1255
- value,
1256
- td,
1257
- ev
1258
- );
1259
- });
1260
- }
1261
- tr.appendChild(td);
1262
- }
1263
- }
1264
- /** Re-render ONE row in place, preserving everything around it.
1265
- *
1266
- * `render()` rebuilds the entire `<tbody>`, which takes the scroll
1267
- * position, any open editor, the keyboard focus and the measured column
1268
- * widths with it. That is fine for a sort or a page change and wrong for
1269
- * a single-cell commit or a live update arriving over a socket — the
1270
- * common case in an editable grid, where a full rebuild once per keystroke
1271
- * is both visible and destructive.
1272
- *
1273
- * What survives, by construction:
1274
- * - scroll position, because the tbody is not replaced;
1275
- * - the separately-rendered thead/tbody column widths, because the
1276
- * explicit widths live on the header cells and on the FIRST body row,
1277
- * and all three of the properties `_setCellWidth` writes are re-applied
1278
- * here when that first row is the one being replaced;
1279
- * - selection, because the `selected` class is recomputed from the
1280
- * selection set rather than carried on the old element;
1281
- * - keyboard focus, because the focused element's position is recorded
1282
- * before the replace and restored after.
1283
- *
1284
- * @param {number} index row index as tracked by `tr.__rowIndex`
1285
- * @param {any[]} row the new cell values
1286
- * @returns {boolean} false when the row is not currently rendered
1287
- * (it is on another page, or outside the render
1288
- * window) — which is NOT an error: the caller has
1289
- * nothing to update on screen.
1290
- */
1291
- updateRow(index, row) {
1292
- const tbody = this._tbodyEl;
1293
- if (!tbody) return false;
1294
- let tr = null;
1295
- for (const candidate of tbody.children) {
1296
- if (candidate.__rowIndex === index) {
1297
- tr = candidate;
1298
- break;
1299
- }
1300
- }
1301
- if (!tr) return false;
1302
- if (Array.isArray(this.config.rows) && this.config.rows[index]) {
1303
- this.config.rows[index] = row;
1304
- }
1305
- const active = document.activeElement;
1306
- let focusedCol = -1;
1307
- if (active && tr.contains(active)) {
1308
- focusedCol = Array.prototype.indexOf.call(tr.children, active.closest("td"));
1309
- }
1310
- const isFirstRow = tr === tbody.firstElementChild;
1311
- const widths = isFirstRow ? Array.prototype.map.call(tr.children, (td) => ({
1312
- width: td.style.width,
1313
- minWidth: td.style.minWidth,
1314
- maxWidth: td.style.maxWidth
1315
- })) : null;
1316
- const showRowNumbers = this.config.showRowNumbers !== false && tr.firstElementChild?.classList.contains("num");
1317
- this._fillRowCells(tr, row, index, showRowNumbers);
1318
- if (widths) {
1319
- widths.forEach((saved, i) => {
1320
- const cell = tr.children[i];
1321
- if (!saved.width || !cell) return;
1322
- cell.style.width = saved.width;
1323
- cell.style.minWidth = saved.minWidth;
1324
- cell.style.maxWidth = saved.maxWidth;
1325
- });
1326
- }
1327
- tr.classList.toggle("selected", this._state?.selected?.has(index) === true);
1328
- if (focusedCol >= 0 && tr.children[focusedCol]) {
1329
- tr.children[focusedCol].focus?.();
1330
- }
1331
- return true;
1332
- }
1333
- /** Sync the (separate) header table's column widths to the body
1334
- * table's measured widths. Without this, the two tables compute
1335
- * widths independently and the columns drift apart. Locks both
1336
- * tables to `table-layout: fixed` and writes explicit width onto
1337
- * each header cell of every header row (header + filter row). */
1338
- /**
1339
- * Measure every column's NATURAL content width, in one transient reflow.
1340
- *
1341
- * Extracted from `_syncHeaderWidths` so that auto-size can ask the same
1342
- * question the fit pass asks, and get the same answer. Two measurement
1343
- * passes would drift, and the drift would show as a double-click that
1344
- * sized a column differently from the render that follows it.
1345
- *
1346
- * The pass ignores the CSS caps (the 150px-pinned first column, the 80px
1347
- * min on the rest) and the filter-row inputs: `twm-dt-measuring` flips both
1348
- * tables to `table-layout:auto; width:max-content` with those caps off (via
1349
- * `!important`) for a single reflow, then reverts before paint — it is
1350
- * never visible. Clearing inline widths first stops the last sync's forced
1351
- * widths from constraining the measure.
1352
- *
1353
- * @returns {number[]|null} width per DOM column index, or null when there
1354
- * is nothing laid out to measure.
1355
- */
1356
- _measureNaturalWidths() {
1357
- const headerTable = this._headerTableEl;
1358
- const bodyTable = this._tableEl;
1359
- if (!headerTable || !bodyTable) return null;
1360
- const firstRow = bodyTable.querySelector("tbody > tr");
1361
- if (!firstRow) return null;
1362
- const bodyCells = firstRow.children;
1363
- if (!bodyCells.length) return null;
1364
- headerTable.querySelectorAll("thead > tr").forEach((tr) => {
1365
- for (const th of tr.children) this._clearCellWidth(th);
1366
- });
1367
- for (const td of bodyCells) this._clearCellWidth(td);
1368
- headerTable.style.width = "";
1369
- bodyTable.style.width = "";
1370
- headerTable.classList.add("twm-dt-measuring");
1371
- bodyTable.classList.add("twm-dt-measuring");
1372
- bodyTable.offsetWidth;
1373
- const labelRow = headerTable.querySelector("thead > tr");
1374
- const cols = bodyCells.length;
1375
- const natural = new Array(cols);
1376
- const zoom = bodyTable.currentCSSZoom || 1;
1377
- for (let i = 0; i < cols; i++) {
1378
- const body = bodyCells[i].getBoundingClientRect().width / zoom;
1379
- const head = labelRow && labelRow.children[i] ? labelRow.children[i].getBoundingClientRect().width / zoom : 0;
1380
- natural[i] = Math.max(body, head);
1381
- }
1382
- headerTable.classList.remove("twm-dt-measuring");
1383
- bodyTable.classList.remove("twm-dt-measuring");
1384
- return natural;
1385
- }
1386
- /**
1387
- * Fit one column to its widest visible value and pin it there.
1388
- *
1389
- * The gesture is a double-click on the column's resize grip, which is where
1390
- * every spreadsheet has put it — and the grip is this component's, which is
1391
- * why the behaviour is too. A consumer that wanted this had no hook to hang
1392
- * it on: `_installColumnResizers` bound `mousedown` and a `click` that only
1393
- * suppressed the sort, and the measurement, the pin, the table-width
1394
- * invariant and the write-through to `stateStore` are all private here.
1395
- *
1396
- * @param {number} domIdx column index INCLUDING the row-number column when
1397
- * `showRowNumbers` is on — the same index space as `_colWidths`.
1398
- * @param {{maxWidth?: number}} [opts] ceiling; defaults to AUTOSIZE_MAX_PX.
1399
- * @returns {boolean} whether it had a layout to measure.
1400
- */
1401
- autoSizeColumn(domIdx, opts = {}) {
1402
- const natural = this._measureNaturalWidths();
1403
- if (!natural || domIdx < 0 || domIdx >= natural.length) return false;
1404
- this._colWidths[domIdx] = this._autoWidth(natural[domIdx], opts);
1405
- this._colWidthsSig = this._colSig();
1406
- this._savePersisted();
1407
- this._syncHeaderWidths();
1408
- return true;
1409
- }
1410
- /**
1411
- * The same for every column at once.
1412
- *
1413
- * It REPLACES existing drag overrides rather than sizing around them: "fit
1414
- * every column to its content" that quietly excepted the three columns you
1415
- * had dragged would be a button whose result depends on history nobody can
1416
- * see.
1417
- *
1418
- * The result may well be wider than the container — forty columns of real
1419
- * content usually are — and that is the intended answer, not a failure:
1420
- * `_syncHeaderWidths` honours overrides verbatim, sizes both tables to their
1421
- * total and the body wrap scrolls sideways with the header following it.
1422
- * Squeezing them to fit is what the automatic fit pass does on every render
1423
- * already, so a button that did that would be a button that does nothing.
1424
- */
1425
- autoSizeColumns(opts = {}) {
1426
- const natural = this._measureNaturalWidths();
1427
- if (!natural) return false;
1428
- this._colWidths = {};
1429
- for (let i = 0; i < natural.length; i++) {
1430
- this._colWidths[i] = this._autoWidth(natural[i], opts);
1431
- }
1432
- this._colWidthsSig = this._colSig();
1433
- this._savePersisted();
1434
- this._syncHeaderWidths();
1435
- return true;
1436
- }
1437
- /** One measured width, floored at the drag-resize minimum and capped, with
1438
- * the same sub-pixel pad `_fitColumnWidths` adds against ellipsis. */
1439
- _autoWidth(natural, opts = {}) {
1440
- const max = opts.maxWidth ?? AUTOSIZE_MAX_PX;
1441
- return Math.min(max, Math.max(40, Math.ceil((natural || 0) + 2)));
1442
- }
1443
- /**
1444
- * Does every one of the `n` DOM columns already carry an override taken
1445
- * against the column set that is on screen right now?
1446
- *
1447
- * The signature half is not belt-and-braces. `render()` drops `_colWidths`
1448
- * when `_colSig` changes, but `_syncHeaderWidths` is also reached straight
1449
- * from the ResizeObserver, which never goes through `render()` — so a set
1450
- * of overrides measured against the PREVIOUS headers can still be sitting
1451
- * in `_colWidths` when this is asked. Answering "pinned" then would skip
1452
- * the measure and hand `_fitColumnWidths` widths keyed to columns that are
1453
- * no longer there, each one landing on its neighbour.
1454
- *
1455
- * `== null` rather than a falsy test, to match `_fitColumnWidths`' own
1456
- * `overrides[i] != null`: a legitimately-stored 0 must be read the same way
1457
- * in both places or the two disagree about which columns are flexible.
1458
- */
1459
- _allColumnsPinned(n) {
1460
- if (!n || this._colWidthsSig !== this._colSig()) return false;
1461
- for (let i = 0; i < n; i++) {
1462
- if (this._colWidths[i] == null) return false;
1463
- }
1464
- return true;
1465
- }
1466
- /** Sync the (separate) header table's column widths to the body
1467
- * table's measured widths. Without this, the two tables compute
1468
- * widths independently and the columns drift apart. Locks both
1469
- * tables to `table-layout: fixed` and writes explicit width onto
1470
- * each header cell of every header row (header + filter row). */
1471
- _syncHeaderWidths() {
1472
- const headerTable = this._headerTableEl;
1473
- const bodyTable = this._tableEl;
1474
- if (!headerTable || !bodyTable) return;
1475
- const firstRow = bodyTable.querySelector("tbody > tr");
1476
- if (!firstRow) return;
1477
- const bodyCells = firstRow.children;
1478
- if (!bodyCells.length) return;
1479
- const headerRows = headerTable.querySelectorAll("thead > tr");
1480
- const natural = this._allColumnsPinned(bodyCells.length) ? new Array(bodyCells.length).fill(0) : this._measureNaturalWidths();
1481
- if (!natural) return;
1482
- const wrap = this._tableWrapEl;
1483
- const headerWrap = this._headerWrapEl;
1484
- const avail = wrap ? wrap.clientWidth : 0;
1485
- const firstIdx = this.config.showRowNumbers && bodyCells.length > 1 ? 1 : 0;
1486
- const widths = this._fitColumnWidths(natural, avail, {
1487
- firstIdx,
1488
- overrides: this._colWidths,
1489
- fit: this.config.columnFit
1490
- });
1491
- const total = widths.reduce((a, b) => a + b, 0);
1492
- headerRows.forEach((tr) => {
1493
- for (let i = 0; i < tr.children.length && i < widths.length; i++) {
1494
- this._setCellWidth(tr.children[i], widths[i]);
1495
- }
1496
- });
1497
- for (let i = 0; i < bodyCells.length && i < widths.length; i++) {
1498
- this._setCellWidth(bodyCells[i], widths[i]);
1499
- }
1500
- headerTable.style.tableLayout = "fixed";
1501
- bodyTable.style.tableLayout = "fixed";
1502
- const hasOverride = Object.keys(this._colWidths).length > 0;
1503
- if (hasOverride || total > avail + 1) {
1504
- headerTable.style.width = `${total}px`;
1505
- bodyTable.style.width = `${total}px`;
1506
- } else {
1507
- headerTable.style.width = "";
1508
- bodyTable.style.width = "";
1509
- }
1510
- if (wrap && headerWrap) {
1511
- const sbw = Math.max(0, wrap.offsetWidth - wrap.clientWidth);
1512
- headerWrap.style.paddingRight = sbw ? `${sbw}px` : "";
1513
- this._wrapperEl?.style?.setProperty("--twm-dt-gutter", `${sbw}px`);
1514
- }
1515
- this._syncHeaderScroll();
1516
- this._updateCellTooltips();
1517
- }
1518
- /**
1519
- * Convert measured natural content widths into final column widths.
1520
- *
1521
- * Goals (the "intelligent" sizing):
1522
- * - every column wants its content width (+a hair), clamped to a
1523
- * sane [floor, cap]; the FIRST content column gets a more generous
1524
- * cap so it shows its full value;
1525
- * - user-dragged columns (`overrides`) are pinned, never grown/shrunk;
1526
- * - if everything fits, grow the flexible columns evenly to fill the
1527
- * width (no dead gap on the right);
1528
- * - if it doesn't fit, protect the first column and water-fill-shrink
1529
- * the rest (trim the widest first) until it fits; only if even the
1530
- * floors overflow do we give up and let the body scroll sideways.
1531
- *
1532
- * Under `fit: 'content'` the first and last of those goals invert: each
1533
- * unpinned column asks for its own content width through the same
1534
- * `_autoWidth` the grip double-click uses, and an overflow is the answer
1535
- * rather than a problem — the caller wanted a table that scrolls sideways,
1536
- * not one squeezed toward the floor. The grow-to-fill branch is shared by
1537
- * both, because a table narrower than its container leaves a dead gap on
1538
- * the right under either reading.
1539
- *
1540
- * @param {number[]} natural measured content width per column (px)
1541
- * @param {number} avail usable width of the body wrap (px)
1542
- * @param {{firstIdx?:number, overrides?:Object, fit?:'container'|'content'}} [opts]
1543
- * @returns {number[]} final width per column (px)
1544
- */
1545
- _fitColumnWidths(natural, avail, opts = {}) {
1546
- const FLOOR = 40;
1547
- const CAP = 360;
1548
- const FIRST_CAP = 520;
1549
- const PAD = 2;
1550
- const n = natural.length;
1551
- const firstIdx = opts.firstIdx ?? 0;
1552
- const overrides = opts.overrides || {};
1553
- const toContent = opts.fit === "content";
1554
- const desired = new Array(n);
1555
- const pinned = new Array(n).fill(false);
1556
- for (let i = 0; i < n; i++) {
1557
- if (overrides[i] != null) {
1558
- desired[i] = Math.max(FLOOR, Math.round(overrides[i]));
1559
- pinned[i] = true;
1560
- continue;
1561
- }
1562
- if (toContent) {
1563
- desired[i] = this._autoWidth(natural[i]);
1564
- continue;
1565
- }
1566
- const cap = i === firstIdx ? FIRST_CAP : CAP;
1567
- desired[i] = Math.min(cap, Math.max(FLOOR, Math.ceil(natural[i] + PAD)));
1568
- }
1569
- const widths = desired.slice();
1570
- const sum = widths.reduce((a, b) => a + b, 0);
1571
- if (avail <= 1) return widths;
1572
- if (sum <= avail) {
1573
- const flex = [];
1574
- for (let i = 0; i < n; i++) if (!pinned[i]) flex.push(i);
1575
- const slack = avail - sum;
1576
- if (slack > 0) {
1577
- const targets = flex.length ? flex : [n - 1];
1578
- const per = Math.floor(slack / targets.length);
1579
- for (const i of targets) widths[i] += per;
1580
- widths[targets[targets.length - 1]] += slack - per * targets.length;
1581
- }
1582
- return widths;
1583
- }
1584
- if (toContent) return widths;
1585
- let protectedSum = 0;
1586
- const shrinkable = [];
1587
- for (let i = 0; i < n; i++) {
1588
- if (pinned[i] || i === firstIdx) protectedSum += widths[i];
1589
- else shrinkable.push(i);
1590
- }
1591
- const budget = avail - protectedSum;
1592
- if (budget < shrinkable.length * FLOOR) {
1593
- for (const i of shrinkable) widths[i] = FLOOR;
1594
- return widths;
1595
- }
1596
- shrinkable.sort((a, b) => desired[a] - desired[b]);
1597
- let remaining = budget;
1598
- for (let k = 0; k < shrinkable.length; k++) {
1599
- const colsLeft = shrinkable.length - k;
1600
- const fair = Math.floor(remaining / colsLeft);
1601
- const i = shrinkable[k];
1602
- if (desired[i] <= fair) {
1603
- widths[i] = desired[i];
1604
- remaining -= desired[i];
1605
- } else {
1606
- const level = Math.max(FLOOR, fair);
1607
- for (let j = k; j < shrinkable.length; j++) {
1608
- widths[shrinkable[j]] = level;
1609
- }
1610
- const leftover = remaining - colsLeft * level;
1611
- if (leftover > 0) widths[shrinkable[shrinkable.length - 1]] += leftover;
1612
- break;
1613
- }
1614
- }
1615
- return widths;
1616
- }
1617
- /** Set an explicit width on a table cell, clearing any CSS min/max
1618
- * width constraint so the width is honored exactly. The first
1619
- * ("time") column is pinned to 150px by `min/max-width` in CSS;
1620
- * without this, dragging it does nothing and the slack leaks to the
1621
- * other columns. */
1622
- _setCellWidth(cell, w) {
1623
- cell.style.width = `${w}px`;
1624
- cell.style.minWidth = "0";
1625
- cell.style.maxWidth = "none";
1626
- }
1627
- /** Undo `_setCellWidth` so a re-measure sees the cell's natural,
1628
- * CSS-constrained width again. */
1629
- _clearCellWidth(cell) {
1630
- cell.style.width = "";
1631
- cell.style.minWidth = "";
1632
- cell.style.maxWidth = "";
1633
- }
1634
- /** Wire the body wrap's horizontal scroll to the header. The header
1635
- * sits in an overflow:hidden wrap, so it can't scroll sideways on
1636
- * its own — `_syncHeaderScroll` scrolls it programmatically to the
1637
- * body's `scrollLeft`. Re-installed each render against the freshly-built
1638
- * wrap. */
1639
- _installHeaderScrollSync() {
1640
- const wrap = this._tableWrapEl;
1641
- if (!wrap) return;
1642
- if (this._onBodyScroll && this._scrollSyncEl) {
1643
- this._scrollSyncEl.removeEventListener("scroll", this._onBodyScroll);
1644
- }
1645
- this._onBodyScroll = () => this._syncHeaderScroll();
1646
- this._scrollSyncEl = wrap;
1647
- wrap.addEventListener("scroll", this._onBodyScroll, { passive: true });
1648
- this._syncHeaderScroll();
1649
- }
1650
- /**
1651
- * Scroll the header wrap to match the body's horizontal scroll so the
1652
- * columns stay aligned when the table overflows sideways.
1653
- *
1654
- * ══ C29. A TRANSFORM IS WHAT MAKES A STICKY COLUMN IMPOSSIBLE ═══════
1655
- *
1656
- * This wrote `headerTable.style.transform = translateX(-scrollLeft)`. It
1657
- * aligns the two tables perfectly and it forecloses `position: sticky`
1658
- * entirely, because the two halves of the table then pin against different
1659
- * things: a sticky cell in the BODY pins to the body wrap's scrollport,
1660
- * while its header counterpart is moved by a transform inside a box that
1661
- * never scrolls at all. Scroll sideways and the pinned body column stands
1662
- * still while its header slides away with everything else — so a table
1663
- * with a trailing verb column (the row-actions column Tables needs pinned
1664
- * to the right edge) can have a sticky body or an aligned header, never
1665
- * both. A transform also establishes a containing block for fixed/sticky
1666
- * descendants, which breaks the header cell independently of the offset.
1667
- *
1668
- * AN `overflow: hidden` BOX IS STILL A SCROLL CONTAINER. It has no
1669
- * scrollbar and no user affordance, and `scrollLeft` moves it exactly like
1670
- * any other. Scrolling the wrap rather than transforming its child gives
1671
- * the header a real scrollport at the same offset as the body's, which is
1672
- * the one arrangement in which a sticky cell on each side pins to the same
1673
- * place. Identical for ordinary content: same pixels, no transform, no new
1674
- * containing block.
1675
- *
1676
- * THE CLAMP IS ALREADY PAID FOR. A scroll container clamps `scrollLeft` to
1677
- * `scrollWidth - clientWidth`, and the body wrap carries a vertical
1678
- * scrollbar the header wrap does not — so the header would clamp short by
1679
- * the scrollbar width and desync at the far right. `_syncHeaderWidths`
1680
- * already pads the header wrap by exactly that gutter (`paddingRight`,
1681
- * :1905), and end padding counts toward `scrollWidth`, so the two maxima
1682
- * coincide. The residual below is the honest belt for the day some engine
1683
- * disagrees about that: it is zero in the ordinary case, so the transform
1684
- * is not set and sticky keeps working, and it is the difference rather
1685
- * than the whole offset if it is ever not.
1686
- */
1687
- _syncHeaderScroll() {
1688
- const wrap = this._tableWrapEl;
1689
- const headerTable = this._headerTableEl;
1690
- if (!wrap || !headerTable) return;
1691
- const x = wrap.scrollLeft;
1692
- const headerWrap = this._headerWrapEl;
1693
- if (!headerWrap) {
1694
- headerTable.style.transform = x ? `translateX(${-x}px)` : "";
1695
- return;
1696
- }
1697
- headerWrap.scrollLeft = x;
1698
- const residual = x - headerWrap.scrollLeft;
1699
- headerTable.style.transform = residual ? `translateX(${-residual}px)` : "";
1700
- }
1701
- /** Drag-to-resize: hang a thin grab handle off the right edge of
1702
- * every header cell. Dragging it writes a per-column width override
1703
- * (keyed by DOM index) that `_syncHeaderWidths` then honors. */
1704
- _installColumnResizers() {
1705
- const headerTable = this._headerTableEl;
1706
- if (!headerTable) return;
1707
- const headRow = headerTable.querySelector("thead > tr");
1708
- if (!headRow) return;
1709
- [...headRow.children].forEach((th, domIdx) => {
1710
- if (th.querySelector(".twm-dt-col-resizer")) return;
1711
- th.classList.add("twm-dt-col");
1712
- const grip = document.createElement("div");
1713
- grip.className = "twm-dt-col-resizer";
1714
- grip.addEventListener(
1715
- "mousedown",
1716
- (ev) => this._beginColResize(ev, domIdx)
1717
- );
1718
- grip.addEventListener("click", (ev) => ev.stopPropagation());
1719
- grip.addEventListener("dblclick", (ev) => {
1720
- ev.preventDefault();
1721
- ev.stopPropagation();
1722
- this.autoSizeColumn(domIdx);
1723
- });
1724
- th.appendChild(grip);
1725
- });
1726
- }
1727
- /**
1728
- * CSS px per screen px. Content inside a zoomed tile reports rects and
1729
- * pointer coordinates in screen pixels, while `style.width` is CSS pixels;
1730
- * mixing them widened every column by the zoom factor on a mere click.
1731
- */
1732
- _zoomFactor() {
1733
- const el = this._tableWrapEl || this._tableEl;
1734
- const css = el?.offsetWidth || 0;
1735
- const screen = el?.getBoundingClientRect?.().width || 0;
1736
- return css > 0 && screen > 0 ? screen / css : 1;
1737
- }
1738
- _beginColResize(ev, domIdx) {
1739
- if (ev.button != null && ev.button !== 0) return;
1740
- ev.preventDefault();
1741
- ev.stopPropagation();
1742
- const headerTable = this._headerTableEl;
1743
- const bodyTable = this._tableEl;
1744
- const headRow = headerTable && headerTable.querySelector("thead > tr");
1745
- if (!headRow) return;
1746
- const startX = ev.clientX;
1747
- const THRESHOLD = 3;
1748
- const MIN = 40;
1749
- let startWidths = null;
1750
- const begin = () => {
1751
- const z = this._zoomFactor();
1752
- startWidths = [...headRow.children].map((c) => {
1753
- const set = parseFloat(c.style.width);
1754
- return Number.isFinite(set) ? set : c.getBoundingClientRect().width / z;
1755
- });
1756
- headerTable.style.tableLayout = "fixed";
1757
- if (bodyTable) bodyTable.style.tableLayout = "fixed";
1758
- startWidths.forEach((w, i) => this._pinColumnWidth(i, w));
1759
- this._applyTableWidth();
1760
- document.body.classList.add("twm-dt-col-resizing");
1761
- };
1762
- const onMove = (mv) => {
1763
- const dx = mv.clientX - startX;
1764
- if (!startWidths) {
1765
- if (Math.abs(dx) < THRESHOLD) return;
1766
- begin();
1767
- }
1768
- const w = Math.max(MIN, Math.round(startWidths[domIdx] + dx / this._zoomFactor()));
1769
- this._pinColumnWidth(domIdx, w);
1770
- this._applyTableWidth(domIdx);
1771
- this._syncHeaderScroll();
1772
- };
1773
- const onUp = () => {
1774
- document.removeEventListener("mousemove", onMove);
1775
- document.removeEventListener("mouseup", onUp);
1776
- if (!startWidths) return;
1777
- document.body.classList.remove("twm-dt-col-resizing");
1778
- this._updateCellTooltips();
1779
- this._savePersisted();
1780
- };
1781
- document.addEventListener("mousemove", onMove);
1782
- document.addEventListener("mouseup", onUp);
1783
- }
1784
- /** Pin one column to an explicit width across BOTH tables (every
1785
- * header row + the body's first row) and record it as a user
1786
- * override so `_syncHeaderWidths` honors it on later renders. The
1787
- * single place that writes a column width during a drag / fill. */
1788
- _pinColumnWidth(i, w) {
1789
- const headerTable = this._headerTableEl;
1790
- const bodyTable = this._tableEl;
1791
- if (!headerTable) return;
1792
- this._colWidths[i] = w;
1793
- headerTable.querySelectorAll("thead > tr").forEach((tr) => {
1794
- if (tr.children[i]) this._setCellWidth(tr.children[i], w);
1795
- });
1796
- const bodyRow = bodyTable && bodyTable.querySelector("tbody > tr");
1797
- if (bodyRow && bodyRow.children[i]) this._setCellWidth(bodyRow.children[i], w);
1798
- }
1799
- /** Size both tables so a widened column grows the table (→ horizontal
1800
- * scroll) rather than stealing from its neighbours — while enforcing
1801
- * the TABLE WIDTH INVARIANT: the table is never narrower than its
1802
- * container. Any width freed by dragging a column in stretches a
1803
- * flexible column (the last one, stepping off the column being
1804
- * dragged) so the table always spans ≥100% of the wrap.
1805
- *
1806
- * @param {number|null} dragIdx column the user is actively dragging,
1807
- * so the fill lands on a DIFFERENT column and doesn't fight the
1808
- * drag. Null (non-drag callers) lets the last column flex. */
1809
- _applyTableWidth(dragIdx = null) {
1810
- const headerTable = this._headerTableEl;
1811
- const bodyTable = this._tableEl;
1812
- const headRow = headerTable && headerTable.querySelector("thead > tr");
1813
- if (!headRow) return;
1814
- const cells = [...headRow.children];
1815
- const cols = cells.length;
1816
- if (cols === 0) return;
1817
- const FLOOR = 40;
1818
- const widthOf = (th) => {
1819
- const px = parseFloat(th.style.width);
1820
- return Number.isFinite(px) ? px : th.getBoundingClientRect().width;
1821
- };
1822
- const cur = cells.map(widthOf);
1823
- const wrap = this._tableWrapEl;
1824
- const avail = wrap ? wrap.clientWidth : 0;
1825
- let flexIdx = cols - 1;
1826
- if (dragIdx != null && flexIdx === dragIdx) flexIdx -= 1;
1827
- if (avail > 1 && flexIdx >= 0 && flexIdx !== dragIdx) {
1828
- let rest = 0;
1829
- for (let i = 0; i < cols; i++) if (i !== flexIdx) rest += cur[i];
1830
- const fill = Math.max(FLOOR, Math.round(avail - rest));
1831
- if (Math.round(fill) !== Math.round(cur[flexIdx])) {
1832
- this._pinColumnWidth(flexIdx, fill);
1833
- cur[flexIdx] = fill;
1834
- }
1835
- }
1836
- let total = 0;
1837
- for (const w of cur) total += w;
1838
- total = Math.ceil(total);
1839
- headerTable.style.width = `${total}px`;
1840
- if (bodyTable) bodyTable.style.width = `${total}px`;
1841
- }
1842
- /** Add a native `title` tooltip to any body cell whose text is
1843
- * clipped by its column width; remove ours once it fits again.
1844
- * Leaves caller-supplied titles (e.g. from renderCell) untouched. */
1845
- _updateCellTooltips() {
1846
- const bodyTable = this._tableEl;
1847
- if (!bodyTable) return;
1848
- bodyTable.querySelectorAll("tbody td").forEach((td) => {
1849
- const clipped = td.scrollWidth > td.clientWidth + 1;
1850
- if (clipped) {
1851
- if (!td.title) td.title = td.textContent;
1852
- } else if (td.title && td.title === td.textContent) {
1853
- td.removeAttribute("title");
1854
- }
1855
- });
1856
- }
1857
- _installInteractions() {
1858
- if (!this._tbodyEl || !this._tableEl) return;
1859
- const { selectable, copyable } = this.config;
1860
- const tbody = this._tbodyEl;
1861
- const table = this._tableEl;
1862
- if (selectable) {
1863
- const handleRowClick = (event) => {
1864
- const rowEl = event.target.closest("tr");
1865
- if (!rowEl || rowEl.__rowIndex === void 0) return;
1866
- const idx = rowEl.__rowIndex;
1867
- const selected = this._state.selected;
1868
- if (event.shiftKey && this._state.anchorIndex != null) {
1869
- const start = Math.min(this._state.anchorIndex, idx);
1870
- const end = Math.max(this._state.anchorIndex, idx);
1871
- selected.clear();
1872
- for (let i = start; i <= end; i++) selected.add(i);
1873
- } else if (event.metaKey || event.ctrlKey) {
1874
- if (selected.has(idx)) selected.delete(idx);
1875
- else selected.add(idx);
1876
- this._state.anchorIndex = idx;
1877
- } else {
1878
- selected.clear();
1879
- selected.add(idx);
1880
- this._state.anchorIndex = idx;
1881
- }
1882
- this._updateRowSelection();
1883
- this._notifySelectionChange();
1884
- try {
1885
- table.focus({ preventScroll: true });
1886
- } catch (_) {
1887
- }
1888
- };
1889
- tbody.addEventListener("click", handleRowClick);
1890
- this._disposers.push(() => tbody.removeEventListener("click", handleRowClick));
1891
- }
1892
- if (copyable) {
1893
- const handleContextMenu = (event) => {
1894
- const rowEl = event.target.closest("tr");
1895
- if (!rowEl) {
1896
- this._hideContextMenu();
1897
- return;
1898
- }
1899
- event.preventDefault();
1900
- event.stopPropagation();
1901
- const idx = rowEl.__rowIndex;
1902
- if (idx !== void 0 && !this._state.selected.has(idx)) {
1903
- this._state.selected.clear();
1904
- this._state.selected.add(idx);
1905
- this._state.anchorIndex = idx;
1906
- this._updateRowSelection();
1907
- this._notifySelectionChange();
1908
- }
1909
- try {
1910
- table.focus({ preventScroll: true });
1911
- } catch (_) {
1912
- }
1913
- setTimeout(() => this._showContextMenu(event.clientX, event.clientY), 0);
1914
- };
1915
- tbody.addEventListener("contextmenu", handleContextMenu);
1916
- this._disposers.push(() => tbody.removeEventListener("contextmenu", handleContextMenu));
1917
- const handleKeyDown = (event) => {
1918
- const ctrlLike = event.ctrlKey || event.metaKey;
1919
- if (ctrlLike && !event.altKey) {
1920
- const key = String(event.key || "").toLowerCase();
1921
- if (key === "a" && selectable) {
1922
- event.preventDefault();
1923
- this._state.selected.clear();
1924
- const indexMap = this._processedIndexMap;
1925
- if (indexMap) {
1926
- for (const originalIdx of indexMap) {
1927
- this._state.selected.add(originalIdx);
1928
- }
1929
- } else {
1930
- for (let i = 0; i < this.config.rows.length; i++) {
1931
- this._state.selected.add(i);
1932
- }
1933
- }
1934
- this._state.anchorIndex = this.config.rows.length - 1;
1935
- this._updateRowSelection();
1936
- this._notifySelectionChange();
1937
- } else if (key === "c") {
1938
- event.preventDefault();
1939
- this.copyToClipboard(event.shiftKey ? "csv" : "tsv");
1940
- }
1941
- } else if (event.key === "Escape") {
1942
- if (this._state.selected.size) {
1943
- this.clearSelection();
1944
- }
1945
- this._hideContextMenu();
1946
- }
1947
- };
1948
- table.addEventListener("keydown", handleKeyDown);
1949
- this._disposers.push(() => table.removeEventListener("keydown", handleKeyDown));
1950
- }
1951
- }
1952
- _updateRowSelection() {
1953
- if (!this._tbodyEl) return;
1954
- this._tbodyEl.querySelectorAll("tr").forEach((tr) => {
1955
- const idx = tr.__rowIndex;
1956
- if (idx !== void 0) {
1957
- tr.classList.toggle("selected", this._state.selected.has(idx));
1958
- }
1959
- });
1960
- }
1961
- _notifySelectionChange() {
1962
- this.config.onSelectionChange?.(this.getSelection());
1963
- }
1964
- _ensureContextMenu() {
1965
- if (this._contextMenuEl) return this._contextMenuEl;
1966
- const menu = document.createElement("div");
1967
- menu.className = "twm-context-menu data-context-menu";
1968
- menu.style.display = "none";
1969
- menu.style.position = "fixed";
1970
- menu.style.zIndex = "10001";
1971
- menu.innerHTML = `
1972
- <div class="twm-context-menu-item" data-action="copy-tsv">
1973
- <span class="material-symbols-outlined">content_copy</span>
1974
- <span class="label">Copy Row(s)</span>
1975
- </div>
1976
- <div class="twm-context-menu-item" data-action="copy-csv">
1977
- <span class="material-symbols-outlined">table</span>
1978
- <span class="label">Copy as CSV</span>
1979
- </div>
1980
- `;
1981
- document.body.appendChild(menu);
1982
- menu.addEventListener("click", (event) => {
1983
- const item = event.target.closest(".twm-context-menu-item");
1984
- if (!item || item.classList.contains("disabled")) return;
1985
- const action = item.getAttribute("data-action");
1986
- if (action === "copy-tsv") this.copyToClipboard("tsv");
1987
- else if (action === "copy-csv") this.copyToClipboard("csv");
1988
- event.stopPropagation();
1989
- event.preventDefault();
1990
- }, { capture: true });
1991
- menu.addEventListener("contextmenu", (e) => {
1992
- e.preventDefault();
1993
- e.stopPropagation();
1994
- });
1995
- const hideOnGlobal = (event) => {
1996
- if (event?.target && menu.contains(event.target)) return;
1997
- this._hideContextMenu();
1998
- };
1999
- const hideOnEscape = (event) => {
2000
- if (event.key === "Escape") this._hideContextMenu();
2001
- };
2002
- const hideOnResize = () => this._hideContextMenu();
2003
- document.addEventListener("click", hideOnGlobal, true);
2004
- document.addEventListener("scroll", hideOnGlobal, true);
2005
- window.addEventListener("resize", hideOnResize);
2006
- document.addEventListener("keydown", hideOnEscape);
2007
- this._contextMenuGlobals.push(() => document.removeEventListener("click", hideOnGlobal, true));
2008
- this._contextMenuGlobals.push(() => document.removeEventListener("scroll", hideOnGlobal, true));
2009
- this._contextMenuGlobals.push(() => document.removeEventListener("keydown", hideOnEscape));
2010
- this._contextMenuGlobals.push(() => window.removeEventListener("resize", hideOnResize));
2011
- this._contextMenuEl = menu;
2012
- return menu;
2013
- }
2014
- _hideContextMenu() {
2015
- if (this._contextMenuEl) {
2016
- this._contextMenuEl.style.display = "none";
2017
- }
2018
- }
2019
- _showContextMenu(clientX, clientY) {
2020
- const menu = this._ensureContextMenu();
2021
- const hasSelection = this._state.selected.size > 0;
2022
- menu.querySelectorAll(".twm-context-menu-item").forEach((item) => {
2023
- item.classList.toggle("disabled", !hasSelection);
2024
- item.style.pointerEvents = hasSelection ? "" : "none";
2025
- });
2026
- menu.style.display = "block";
2027
- const rect = menu.getBoundingClientRect();
2028
- let left = clientX;
2029
- let top = clientY;
2030
- if (left + rect.width > window.innerWidth) {
2031
- left = window.innerWidth - rect.width - 8;
2032
- }
2033
- if (top + rect.height > window.innerHeight) {
2034
- top = window.innerHeight - rect.height - 8;
2035
- }
2036
- menu.style.left = `${left}px`;
2037
- menu.style.top = `${top}px`;
2038
- }
2039
- _teardownContextMenu() {
2040
- this._contextMenuGlobals.forEach((dispose) => {
2041
- try {
2042
- dispose?.();
2043
- } catch (_) {
2044
- }
2045
- });
2046
- this._contextMenuGlobals = [];
2047
- if (this._contextMenuEl?.parentNode) {
2048
- this._contextMenuEl.parentNode.removeChild(this._contextMenuEl);
2049
- }
2050
- this._contextMenuEl = null;
2051
- }
2052
- _formatValue(value, colIndex) {
2053
- if (this.config.formatValue) {
2054
- return this.config.formatValue(value, colIndex);
2055
- }
2056
- if (value === void 0 || value === null) return "-";
2057
- if (typeof value === "number") {
2058
- if (!Number.isFinite(value)) return "-";
2059
- return Number(value.toFixed(4)).toString();
2060
- }
2061
- return String(value);
2062
- }
2063
- _csvEscape(value) {
2064
- if (value == null) return "";
2065
- const str = String(value);
2066
- if (/[",\n]/.test(str)) {
2067
- return `"${str.replace(/"/g, '""')}"`;
2068
- }
2069
- return str;
2070
- }
2071
- _fallbackCopy(text) {
2072
- try {
2073
- const textarea = document.createElement("textarea");
2074
- textarea.value = text;
2075
- textarea.setAttribute("readonly", "");
2076
- textarea.style.position = "absolute";
2077
- textarea.style.left = "-9999px";
2078
- document.body.appendChild(textarea);
2079
- textarea.select();
2080
- const ok = document.execCommand("copy");
2081
- document.body.removeChild(textarea);
2082
- return ok;
2083
- } catch (_) {
2084
- return false;
2085
- }
2086
- }
2087
- _notify(title, message, type = "info") {
2088
- this.config.services?.eventBus?.emit?.("toast:show", { title, message, type });
2089
- }
2090
- };
2091
-
2092
- export {
2093
- createRafResizeObserver,
2094
- DataTable
2095
- };
2096
- //# sourceMappingURL=chunk-BIAOGX6K.js.map
1
+ // src/ui/utils/raf_resize_observer.js
2
+ function createRafResizeObserver(callback) {
3
+ let scheduled = false;
4
+ let latestEntries = [];
5
+ return new ResizeObserver((entries) => {
6
+ latestEntries = entries;
7
+ if (scheduled) return;
8
+ scheduled = true;
9
+ requestAnimationFrame(() => {
10
+ scheduled = false;
11
+ callback(latestEntries);
12
+ });
13
+ });
14
+ }
15
+
16
+ // src/ui/components/data_table.js
17
+ var DEFAULT_PAGE_SIZE = 100;
18
+ var AUTOSIZE_MAX_PX = 520;
19
+ var VIRTUAL_FALLBACK_VIEWPORT_PX = 1e3;
20
+ var DataTable = class {
21
+ /**
22
+ * Create a DataTable instance
23
+ * @param {HTMLElement} container - Container element to render into
24
+ * @param {DataTableConfig} config - Configuration options
25
+ */
26
+ constructor(container, config = {}) {
27
+ this.container = container;
28
+ this.config = {
29
+ headers: [],
30
+ rows: [],
31
+ pageSize: DEFAULT_PAGE_SIZE,
32
+ pagination: true,
33
+ selectable: true,
34
+ copyable: true,
35
+ sortable: false,
36
+ filterable: false,
37
+ readonly: false,
38
+ showRowNumbers: false,
39
+ emptyMessage: "No data",
40
+ onSort: null,
41
+ // OFF unless asked for, for `columnFit`'s reason below: a sort kept
42
+ // the client-side page before this key existed, so turning the
43
+ // reset on for everybody would change every paged table that never
44
+ // asked. See the typedef.
45
+ resetPageOnSort: false,
46
+ onSelectionChange: null,
47
+ formatValue: null,
48
+ getHeaderIcon: null,
49
+ getColumnType: null,
50
+ services: null,
51
+ // Server-side pagination
52
+ totalCount: null,
53
+ offset: 0,
54
+ onPageChange: null,
55
+ onJumpToRow: null,
56
+ // Custom cell rendering
57
+ renderCell: null,
58
+ // Rendering density + bottom-panel hooks (P2)
59
+ mode: "normal",
60
+ onRowClick: null,
61
+ onRowContextMenu: null,
62
+ onCellContextMenu: null,
63
+ showExportButton: false,
64
+ // The host port. TOP LEVEL, deliberately — NOT inside `services`,
65
+ // which most call sites never pass. Supplies the native save
66
+ // dialog for downloadCSV(); absent => Blob fallback.
67
+ host: null,
68
+ // Opt-in persistence: when set, this table's sort, filters, and
69
+ // column widths survive teardown/reload, keyed by this string
70
+ // inside the injected `stateStore`. Omit it and the table stays
71
+ // ephemeral. WHERE the store persists is the embedder's business.
72
+ persistKey: null,
73
+ stateStore: null,
74
+ // How the automatic fit pass sizes an undragged column — see the
75
+ // typedef. 'container' is exactly what every consumer got before
76
+ // this key existed, so the default is not a preference: it is the
77
+ // promise that adding the key changed no existing layout by a
78
+ // pixel. Only a consumer that asks for 'content' sees anything new.
79
+ columnFit: "container",
80
+ // C10. Off unless asked for — see the typedef.
81
+ virtualize: false,
82
+ virtualRowHeight: null,
83
+ onRowsRendered: null,
84
+ ...config
85
+ };
86
+ this._virt = null;
87
+ this._virtBase = null;
88
+ if (this.config.persistKey && !this.config.stateStore) {
89
+ throw new Error(`DataTable: persistKey "${this.config.persistKey}" requires { stateStore }`);
90
+ }
91
+ this._state = {
92
+ offset: 0,
93
+ selected: /* @__PURE__ */ new Set(),
94
+ anchorIndex: null,
95
+ sortColumn: null,
96
+ sortAscending: true,
97
+ filters: /* @__PURE__ */ new Map()
98
+ };
99
+ this._columnTypes = [];
100
+ this._processedRows = null;
101
+ this._processedIndexMap = null;
102
+ this._filterDebounceTimer = null;
103
+ this._wrapperEl = null;
104
+ this._paginationEl = null;
105
+ this._tableEl = null;
106
+ this._tbodyEl = null;
107
+ this._contextMenuEl = null;
108
+ this._filterDropdownEl = null;
109
+ this._filterDropdownCleanup = null;
110
+ this._colWidths = {};
111
+ this._colWidthsSig = null;
112
+ this._disposers = [];
113
+ this._contextMenuGlobals = [];
114
+ if (this.config.persistKey) {
115
+ const store = this.config.stateStore;
116
+ this._restorePersisted(store.get(this.config.persistKey));
117
+ store.ready().then(() => {
118
+ const late = store.get(this.config.persistKey);
119
+ if (late && this._restorePersisted(late) && this._wrapperEl) {
120
+ this._invalidateProcessedCache?.();
121
+ this.render();
122
+ }
123
+ });
124
+ }
125
+ }
126
+ /** Apply a persisted blob ({sort, asc, filters, widths}) onto this
127
+ * table's live state. Returns true if anything was applied. */
128
+ _restorePersisted(blob) {
129
+ if (!blob) return false;
130
+ let applied = false;
131
+ if (typeof blob.sortColumn === "number" || blob.sortColumn === null) {
132
+ this._state.sortColumn = blob.sortColumn;
133
+ this._state.sortAscending = blob.sortAscending !== false;
134
+ applied = true;
135
+ }
136
+ if (Array.isArray(blob.filters)) {
137
+ this._state.filters = new Map(blob.filters);
138
+ applied = true;
139
+ }
140
+ if (blob.colWidths && typeof blob.colWidths === "object") {
141
+ this._colWidths = {};
142
+ for (const [k, v] of Object.entries(blob.colWidths)) {
143
+ this._colWidths[Number(k)] = v;
144
+ }
145
+ this._colWidthsSig = this._colSig();
146
+ applied = true;
147
+ }
148
+ return applied;
149
+ }
150
+ /** Identity of the current column set — restored widths/sort are
151
+ * keyed by position, so a schema change invalidates them. */
152
+ _colSig() {
153
+ return (this.config.headers || []).join("") + (this.config.showRowNumbers ? "|#" : "");
154
+ }
155
+ /** Snapshot the persistable slice of state to the project store.
156
+ * No-op unless `persistKey` is set. Debounced inside the store. */
157
+ _savePersisted() {
158
+ if (!this.config.persistKey) return;
159
+ this.config.stateStore.set(this.config.persistKey, {
160
+ sortColumn: this._state.sortColumn,
161
+ sortAscending: this._state.sortAscending,
162
+ filters: [...this._state.filters.entries()],
163
+ colWidths: { ...this._colWidths }
164
+ });
165
+ }
166
+ /**
167
+ * Update data and re-render
168
+ * @param {Object} updates - Partial config updates (headers, rows, etc.)
169
+ */
170
+ setData(updates) {
171
+ if (updates.headers && this._state.filters.size > 0) {
172
+ const oldHeaders = this.config.headers;
173
+ const newHeaders = updates.headers;
174
+ if (oldHeaders.length !== newHeaders.length || oldHeaders.some((h, i) => h !== newHeaders[i])) {
175
+ this._state.filters.clear();
176
+ }
177
+ }
178
+ Object.assign(this.config, updates);
179
+ if (updates.rows) {
180
+ this._state.selected.clear();
181
+ this._state.anchorIndex = null;
182
+ if (this._state.offset >= updates.rows.length) {
183
+ this._state.offset = 0;
184
+ }
185
+ this._columnTypes = this._detectColumnTypes();
186
+ }
187
+ this._processedRows = null;
188
+ this._processedIndexMap = null;
189
+ this.render();
190
+ }
191
+ /**
192
+ * Show / hide a translucent loading overlay over the table body.
193
+ * Used by server-side consumers between firing a fetch and
194
+ * receiving the new rows so the UI doesn't appear frozen.
195
+ * Callers either:
196
+ * - call `setLoading(true)` before their fetch, `setLoading(false)` after, OR
197
+ * - return a Promise from `onPageChange` / `onSort` callbacks
198
+ * — the overlay auto-shows during the await.
199
+ * @param {boolean} on
200
+ */
201
+ setLoading(on) {
202
+ if (!this._wrapperEl) return;
203
+ let overlay = this._wrapperEl.querySelector(".twm-data-table__loading");
204
+ if (on) {
205
+ if (!overlay) {
206
+ overlay = document.createElement("div");
207
+ overlay.className = "twm-data-table__loading";
208
+ overlay.innerHTML = '<div class="twm-data-table__spinner"></div>';
209
+ if (!this._wrapperEl.style.position) {
210
+ this._wrapperEl.style.position = "relative";
211
+ }
212
+ this._wrapperEl.appendChild(overlay);
213
+ }
214
+ } else if (overlay) {
215
+ overlay.remove();
216
+ }
217
+ }
218
+ /**
219
+ * Get current selection indices
220
+ * @returns {number[]} Array of selected row indices
221
+ */
222
+ getSelection() {
223
+ return Array.from(this._state.selected).sort((a, b) => a - b);
224
+ }
225
+ /**
226
+ * Set selection programmatically
227
+ * @param {number[]} indices - Row indices to select
228
+ */
229
+ setSelection(indices) {
230
+ this._state.selected.clear();
231
+ for (const idx of indices) {
232
+ if (idx >= 0 && idx < this.config.rows.length) {
233
+ this._state.selected.add(idx);
234
+ }
235
+ }
236
+ this._updateRowSelection();
237
+ this._notifySelectionChange();
238
+ }
239
+ /**
240
+ * Clear selection
241
+ */
242
+ clearSelection() {
243
+ this._state.selected.clear();
244
+ this._state.anchorIndex = null;
245
+ this._updateRowSelection();
246
+ this._notifySelectionChange();
247
+ }
248
+ /**
249
+ * Go to specific page
250
+ * @param {number} page - Page number (0-indexed)
251
+ */
252
+ goToPage(page) {
253
+ const rowCount = this._getProcessedRows().length;
254
+ const maxPage = Math.ceil(rowCount / this.config.pageSize) - 1;
255
+ this._state.offset = Math.max(0, Math.min(page, maxPage)) * this.config.pageSize;
256
+ this.render();
257
+ }
258
+ /**
259
+ * Go to specific row
260
+ * @param {number} rowIndex - Row index
261
+ */
262
+ goToRow(rowIndex) {
263
+ const rowCount = this._getProcessedRows().length;
264
+ const idx = Math.max(0, Math.min(rowIndex, rowCount - 1));
265
+ this._state.offset = Math.floor(idx / this.config.pageSize) * this.config.pageSize;
266
+ this.render();
267
+ }
268
+ /**
269
+ * Sort by column
270
+ * @param {number} colIndex - Column index
271
+ * @param {boolean} [ascending] - Sort direction (toggles if same column)
272
+ */
273
+ sortBy(colIndex, ascending) {
274
+ if (this._state.sortColumn === colIndex && ascending === void 0) {
275
+ this._state.sortAscending = !this._state.sortAscending;
276
+ } else {
277
+ this._state.sortColumn = colIndex;
278
+ this._state.sortAscending = ascending ?? true;
279
+ }
280
+ this._processedRows = null;
281
+ this._processedIndexMap = null;
282
+ if (this.config.resetPageOnSort) this._state.offset = 0;
283
+ const ret = this.config.onSort?.(colIndex, this._state.sortAscending);
284
+ this._awaitWithSpinner(ret);
285
+ this._savePersisted();
286
+ this.render();
287
+ }
288
+ /** Internal: if `maybePromise` is a Promise (or thenable),
289
+ * toggle the loading overlay around it. No-op otherwise. */
290
+ _awaitWithSpinner(maybePromise) {
291
+ if (!maybePromise || typeof maybePromise.then !== "function") return;
292
+ this.setLoading(true);
293
+ Promise.resolve(maybePromise).finally(() => this.setLoading(false));
294
+ }
295
+ /**
296
+ * Clear all column filters
297
+ */
298
+ clearFilters() {
299
+ this._state.filters.clear();
300
+ this._processedRows = null;
301
+ this._processedIndexMap = null;
302
+ this._state.offset = 0;
303
+ this._savePersisted();
304
+ this.render();
305
+ }
306
+ /**
307
+ * Get the number of rows after filtering
308
+ * @returns {number}
309
+ */
310
+ getFilteredRowCount() {
311
+ return this._getProcessedRows().length;
312
+ }
313
+ /**
314
+ * Render the table
315
+ */
316
+ render() {
317
+ const activeFilterColIdx = this._getActiveFilterColIdx();
318
+ const doc = this.container.ownerDocument;
319
+ const hadTableFocus = !!this._tableEl && doc?.activeElement === this._tableEl;
320
+ const prevWrap = this._tableWrapEl;
321
+ const prevScroll = prevWrap?.isConnected && this._renderedOffset === this._state.offset ? { top: prevWrap.scrollTop, left: prevWrap.scrollLeft } : null;
322
+ this._cleanup();
323
+ this.container.innerHTML = "";
324
+ this._virt = null;
325
+ const { rows, pagination, emptyMessage } = this.config;
326
+ const colSig = this._colSig();
327
+ if (this._colWidthsSig !== colSig) {
328
+ this._colWidths = {};
329
+ this._colWidthsSig = colSig;
330
+ }
331
+ if (this._columnTypes.length === 0) {
332
+ this._columnTypes = this._detectColumnTypes();
333
+ }
334
+ this._wrapperEl = document.createElement("div");
335
+ this._wrapperEl.className = "twm-data-table-component" + (this.config.mode === "compact" ? " twm-data-table-component--compact" : "");
336
+ this._wrapperEl.style.cssText = "display:flex; flex-direction:column; height:100%; min-height:0;";
337
+ if (pagination && rows.length > 0) {
338
+ this._paginationEl = this._createPagination();
339
+ if (this._paginationEl) this._wrapperEl.appendChild(this._paginationEl);
340
+ }
341
+ const tableWrap = document.createElement("div");
342
+ tableWrap.className = this.config.readonly ? "twm-preview-table-wrap twm-preview-table-wrap--wizard" : "twm-preview-table-wrap";
343
+ tableWrap.style.cssText = "flex:1 1 0; min-height:0; min-width:0; overflow:auto;";
344
+ if (rows.length === 0) {
345
+ const { headers, showRowNumbers } = this.config;
346
+ const headerWrap = document.createElement("div");
347
+ headerWrap.className = "twm-preview-table-header-wrap";
348
+ headerWrap.style.cssText = "flex:0 0 auto; overflow:hidden; min-width:0;";
349
+ const headerTable = document.createElement("table");
350
+ headerTable.className = this.config.readonly ? "twm-preview-table twm-preview-table--readonly" : "twm-preview-table";
351
+ const thead = document.createElement("thead");
352
+ const tr = document.createElement("tr");
353
+ if (showRowNumbers) {
354
+ const th = document.createElement("th");
355
+ th.className = "num";
356
+ th.textContent = "#";
357
+ tr.appendChild(th);
358
+ }
359
+ headers.forEach((h, colIdx) => {
360
+ const th = document.createElement("th");
361
+ th.className = this._columnTypes[colIdx] || "text";
362
+ th.textContent = h;
363
+ tr.appendChild(th);
364
+ });
365
+ thead.appendChild(tr);
366
+ headerTable.appendChild(thead);
367
+ headerWrap.appendChild(headerTable);
368
+ this._wrapperEl.appendChild(headerWrap);
369
+ this._headerWrapEl = headerWrap;
370
+ this._headerTableEl = headerTable;
371
+ const bodyTable = document.createElement("table");
372
+ bodyTable.className = headerTable.className;
373
+ const tbody = document.createElement("tbody");
374
+ const emptyTr = document.createElement("tr");
375
+ emptyTr.className = "data-preview-row data-table__empty-row";
376
+ const colCount = headers.length + (showRowNumbers ? 1 : 0);
377
+ const emptyTd = document.createElement("td");
378
+ emptyTd.colSpan = colCount;
379
+ emptyTd.style.cssText = "text-align:center; font-style:italic; color:#888; padding:16px;";
380
+ emptyTd.textContent = emptyMessage;
381
+ emptyTr.appendChild(emptyTd);
382
+ tbody.appendChild(emptyTr);
383
+ bodyTable.appendChild(tbody);
384
+ tableWrap.appendChild(bodyTable);
385
+ this._wrapperEl.appendChild(tableWrap);
386
+ this._tableEl = bodyTable;
387
+ this._tableWrapEl = tableWrap;
388
+ } else {
389
+ const fullTable = this._createTable();
390
+ this._tableEl = fullTable;
391
+ const thead = fullTable.querySelector("thead");
392
+ if (thead) {
393
+ const headerWrap = document.createElement("div");
394
+ headerWrap.className = "twm-preview-table-header-wrap";
395
+ headerWrap.style.cssText = "flex:0 0 auto; overflow:hidden; min-width:0;";
396
+ const headerTable = document.createElement("table");
397
+ headerTable.className = fullTable.className;
398
+ headerTable.appendChild(thead);
399
+ headerWrap.appendChild(headerTable);
400
+ this._wrapperEl.appendChild(headerWrap);
401
+ this._headerTableEl = headerTable;
402
+ this._headerWrapEl = headerWrap;
403
+ } else {
404
+ this._headerTableEl = null;
405
+ }
406
+ tableWrap.appendChild(fullTable);
407
+ this._wrapperEl.appendChild(tableWrap);
408
+ }
409
+ this._tableWrapEl = tableWrap;
410
+ this.container.appendChild(this._wrapperEl);
411
+ if (prevScroll && this._state.offset === this._renderedOffset) {
412
+ tableWrap.scrollTop = prevScroll.top;
413
+ tableWrap.scrollLeft = prevScroll.left;
414
+ }
415
+ this._renderedOffset = this._state.offset;
416
+ if (this._virt) {
417
+ const onScroll = () => this._virtUpdate();
418
+ tableWrap.addEventListener("scroll", onScroll, { passive: true });
419
+ this._disposers.push(() => tableWrap.removeEventListener("scroll", onScroll));
420
+ this._virtMeasure();
421
+ this._virtUpdate({ force: true, notify: false });
422
+ }
423
+ if (this._headerTableEl && this._tableEl) {
424
+ requestAnimationFrame(() => this._syncHeaderWidths());
425
+ if (this._resizeObserver) {
426
+ try {
427
+ this._resizeObserver.disconnect();
428
+ } catch (_) {
429
+ }
430
+ }
431
+ if (typeof ResizeObserver !== "undefined") {
432
+ this._resizeObserver = createRafResizeObserver(() => {
433
+ this._syncHeaderWidths();
434
+ this._virtUpdate();
435
+ });
436
+ this._resizeObserver.observe(this._wrapperEl);
437
+ }
438
+ this._installHeaderScrollSync();
439
+ if (rows.length > 0) this._installColumnResizers();
440
+ }
441
+ if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly)) {
442
+ this._installInteractions();
443
+ }
444
+ if (activeFilterColIdx !== null) {
445
+ this._restoreFilterFocus(activeFilterColIdx);
446
+ } else if (hadTableFocus && this._tableEl) {
447
+ try {
448
+ this._tableEl.focus({ preventScroll: true });
449
+ } catch (_) {
450
+ }
451
+ }
452
+ }
453
+ /**
454
+ * Copy selected rows to clipboard
455
+ * @param {'tsv'|'csv'} [format='tsv'] - Output format
456
+ */
457
+ async copyToClipboard(format = "tsv") {
458
+ const indices = this.getSelection();
459
+ if (indices.length === 0) {
460
+ this._notify("Copy", "No rows selected.", "warn");
461
+ return;
462
+ }
463
+ const { headers, rows } = this.config;
464
+ const matrix = [headers];
465
+ for (const idx of indices) {
466
+ const row = rows[idx];
467
+ if (row) {
468
+ matrix.push(row.map((val, colIdx) => this._formatValue(val, colIdx)));
469
+ }
470
+ }
471
+ const separator = format === "csv" ? "," : " ";
472
+ const text = format === "csv" ? matrix.map((row) => row.map((cell) => this._csvEscape(cell)).join(separator)).join("\n") : matrix.map((row) => row.join(separator)).join("\n");
473
+ let ok = false;
474
+ try {
475
+ await navigator.clipboard.writeText(text);
476
+ ok = true;
477
+ } catch (_) {
478
+ ok = this._fallbackCopy(text);
479
+ }
480
+ if (ok) {
481
+ const desc = indices.length === 1 ? "row" : "rows";
482
+ const suffix = format === "csv" ? " as CSV" : "";
483
+ this._notify("Copy", `Copied ${indices.length} ${desc}${suffix}.`, "info");
484
+ } else {
485
+ this._notify("Copy", "Clipboard unavailable.", "warn");
486
+ }
487
+ this._hideContextMenu();
488
+ }
489
+ /**
490
+ * Download all data as CSV
491
+ * @param {string} [filename='data.csv'] - Filename for download
492
+ */
493
+ async downloadCSV(filename = "data.csv") {
494
+ const { headers, rows } = this.config;
495
+ if (rows.length === 0) {
496
+ this._notify("Download", "No data to download.", "warn");
497
+ return;
498
+ }
499
+ const lines = [headers.join(",")];
500
+ for (const row of rows) {
501
+ lines.push(row.map(
502
+ (val, colIdx) => this._csvEscape(this._formatValue(val, colIdx))
503
+ ).join(","));
504
+ }
505
+ const csv = lines.join("\n");
506
+ const dialogs = this.config.host?.dialogs;
507
+ let result = null;
508
+ if (dialogs) {
509
+ try {
510
+ result = await dialogs.saveFile({ data: csv, filename, kind: "csv" });
511
+ } catch (err) {
512
+ console.error("[DataTable] CSV download failed:", err);
513
+ this._notify("Download", "Failed to save file", "error");
514
+ return;
515
+ }
516
+ }
517
+ if (result) {
518
+ if (result.ok) {
519
+ this._notify("Download", `Saved ${rows.length} rows to ${result.path}`, "success");
520
+ } else if (!result.cancelled) {
521
+ this._notify("Download", result.error || "Failed to save file", "error");
522
+ }
523
+ return;
524
+ }
525
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
526
+ const url = URL.createObjectURL(blob);
527
+ const link = document.createElement("a");
528
+ link.href = url;
529
+ link.download = filename;
530
+ link.click();
531
+ URL.revokeObjectURL(url);
532
+ this._notify("Download", "Download started", "info");
533
+ }
534
+ /**
535
+ * Dispose and cleanup
536
+ */
537
+ dispose() {
538
+ this._cleanup();
539
+ this._closeFilterDropdown();
540
+ this._teardownContextMenu();
541
+ if (this._resizeObserver) {
542
+ try {
543
+ this._resizeObserver.disconnect();
544
+ } catch (_) {
545
+ }
546
+ this._resizeObserver = null;
547
+ }
548
+ if (this._onBodyScroll && this._scrollSyncEl) {
549
+ try {
550
+ this._scrollSyncEl.removeEventListener("scroll", this._onBodyScroll);
551
+ } catch (_) {
552
+ }
553
+ this._onBodyScroll = null;
554
+ this._scrollSyncEl = null;
555
+ }
556
+ this.container.innerHTML = "";
557
+ this._wrapperEl = null;
558
+ this._paginationEl = null;
559
+ this._tableEl = null;
560
+ this._tbodyEl = null;
561
+ this._headerTableEl = null;
562
+ this._headerWrapEl = null;
563
+ this._tableWrapEl = null;
564
+ this._processedRows = null;
565
+ this._processedIndexMap = null;
566
+ this._virt = null;
567
+ this._colgroupEl = null;
568
+ }
569
+ // ─────────────────────────────────────────────────────────────────
570
+ // Processed rows pipeline (filter → sort → cache)
571
+ // ─────────────────────────────────────────────────────────────────
572
+ _getProcessedRows() {
573
+ if (this._processedRows !== null) return this._processedRows;
574
+ const isServerSide = typeof this.config.onPageChange === "function";
575
+ let rows = this.config.rows;
576
+ let indexMap = rows.map((_, i) => i);
577
+ if (!isServerSide && this._state.filters.size > 0) {
578
+ const result = this._applyFilters(rows, indexMap);
579
+ rows = result.rows;
580
+ indexMap = result.indexMap;
581
+ }
582
+ if (!isServerSide && this.config.sortable && this._state.sortColumn !== null && !this.config.onSort) {
583
+ const result = this._applySorting(rows, indexMap);
584
+ rows = result.rows;
585
+ indexMap = result.indexMap;
586
+ }
587
+ this._processedRows = rows;
588
+ this._processedIndexMap = indexMap;
589
+ return rows;
590
+ }
591
+ _applyFilters(rows, indexMap) {
592
+ const filters = this._state.filters;
593
+ const filteredRows = [];
594
+ const filteredMap = [];
595
+ for (let i = 0; i < rows.length; i++) {
596
+ const row = rows[i];
597
+ let pass = true;
598
+ for (const [colIdx, filterText] of filters) {
599
+ if (!filterText) continue;
600
+ const value = row[colIdx];
601
+ const colType = this._columnTypes[colIdx];
602
+ if (colType === "num") {
603
+ if (!this._matchNumericFilter(value, filterText)) {
604
+ pass = false;
605
+ break;
606
+ }
607
+ } else {
608
+ if (!this._matchTextFilter(value, filterText)) {
609
+ pass = false;
610
+ break;
611
+ }
612
+ }
613
+ }
614
+ if (pass) {
615
+ filteredRows.push(row);
616
+ filteredMap.push(indexMap[i]);
617
+ }
618
+ }
619
+ return { rows: filteredRows, indexMap: filteredMap };
620
+ }
621
+ _applySorting(rows, indexMap) {
622
+ const colIdx = this._state.sortColumn;
623
+ const asc = this._state.sortAscending;
624
+ const paired = rows.map((row, i) => ({ row, origIdx: indexMap[i] }));
625
+ const isNumeric = this._columnTypes[colIdx] === "num";
626
+ paired.sort((a, b) => {
627
+ let valA = a.row[colIdx];
628
+ let valB = b.row[colIdx];
629
+ if (valA == null && valB == null) return 0;
630
+ if (valA == null) return 1;
631
+ if (valB == null) return -1;
632
+ if (isNumeric) {
633
+ valA = typeof valA === "number" ? valA : parseFloat(valA);
634
+ valB = typeof valB === "number" ? valB : parseFloat(valB);
635
+ if (!Number.isFinite(valA)) return 1;
636
+ if (!Number.isFinite(valB)) return -1;
637
+ } else {
638
+ valA = String(valA).toLowerCase();
639
+ valB = String(valB).toLowerCase();
640
+ }
641
+ let cmp = 0;
642
+ if (valA < valB) cmp = -1;
643
+ else if (valA > valB) cmp = 1;
644
+ return asc ? cmp : -cmp;
645
+ });
646
+ return {
647
+ rows: paired.map((p) => p.row),
648
+ indexMap: paired.map((p) => p.origIdx)
649
+ };
650
+ }
651
+ _matchNumericFilter(value, filterText) {
652
+ const text = filterText.trim();
653
+ if (!text) return true;
654
+ const numVal = typeof value === "number" ? value : parseFloat(value);
655
+ if (!Number.isFinite(numVal)) return false;
656
+ const rangeMatch = text.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
657
+ if (rangeMatch) {
658
+ const lo = parseFloat(rangeMatch[1]);
659
+ const hi = parseFloat(rangeMatch[2]);
660
+ return numVal >= lo && numVal <= hi;
661
+ }
662
+ const opMatch = text.match(/^(>=|<=|!=|>|<|=)\s*(-?[\d.]+)$/);
663
+ if (opMatch) {
664
+ const op = opMatch[1];
665
+ const target = parseFloat(opMatch[2]);
666
+ if (!Number.isFinite(target)) return true;
667
+ switch (op) {
668
+ case ">":
669
+ return numVal > target;
670
+ case "<":
671
+ return numVal < target;
672
+ case ">=":
673
+ return numVal >= target;
674
+ case "<=":
675
+ return numVal <= target;
676
+ case "!=":
677
+ return Math.abs(numVal - target) > 1e-9;
678
+ case "=":
679
+ return Math.abs(numVal - target) <= 1e-9;
680
+ }
681
+ }
682
+ const plain = parseFloat(text);
683
+ if (Number.isFinite(plain)) {
684
+ return Math.abs(numVal - plain) <= 1e-9;
685
+ }
686
+ return true;
687
+ }
688
+ _matchTextFilter(value, filterText) {
689
+ if (!filterText) return true;
690
+ const haystack = (value == null ? "" : String(value)).toLowerCase();
691
+ const text = filterText.trim();
692
+ if (text.startsWith('="') && text.endsWith('"')) {
693
+ return haystack === text.slice(2, -1).toLowerCase();
694
+ }
695
+ if (text.startsWith("!")) {
696
+ return !haystack.includes(text.slice(1).toLowerCase());
697
+ }
698
+ if (text.startsWith("^")) {
699
+ return haystack.startsWith(text.slice(1).toLowerCase());
700
+ }
701
+ if (text.endsWith("$")) {
702
+ return haystack.endsWith(text.slice(0, -1).toLowerCase());
703
+ }
704
+ return haystack.includes(text.toLowerCase());
705
+ }
706
+ _invalidateProcessedCache() {
707
+ this._processedRows = null;
708
+ this._processedIndexMap = null;
709
+ }
710
+ // ─────────────────────────────────────────────────────────────────
711
+ // Filter row
712
+ // ─────────────────────────────────────────────────────────────────
713
+ _createFilterRow(headers) {
714
+ const { showRowNumbers } = this.config;
715
+ const tr = document.createElement("tr");
716
+ tr.className = "twm-data-table__filter-row";
717
+ if (showRowNumbers) {
718
+ const th = document.createElement("th");
719
+ th.className = "data-table__filter-cell data-table__filter-cell--empty";
720
+ tr.appendChild(th);
721
+ }
722
+ headers.forEach((header, colIdx) => {
723
+ const th = document.createElement("th");
724
+ th.className = "data-table__filter-cell";
725
+ const wrapper = document.createElement("div");
726
+ wrapper.className = "twm-data-table__filter-wrapper";
727
+ const input = document.createElement("input");
728
+ input.type = "text";
729
+ input.className = "twm-data-table__filter-input";
730
+ const isNumeric = this._columnTypes[colIdx] === "num";
731
+ input.placeholder = isNumeric ? "e.g. >100" : "Filter...";
732
+ const existingFilter = this._state.filters.get(colIdx);
733
+ if (existingFilter) {
734
+ input.value = existingFilter;
735
+ }
736
+ const dropdownBtn = document.createElement("button");
737
+ dropdownBtn.type = "button";
738
+ dropdownBtn.className = "twm-data-table__filter-dropdown-btn";
739
+ dropdownBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:14px;">tune</span>';
740
+ dropdownBtn.addEventListener("click", (e) => {
741
+ e.stopPropagation();
742
+ this._openFilterDropdown(colIdx, th, input);
743
+ });
744
+ const clearBtn = document.createElement("button");
745
+ clearBtn.type = "button";
746
+ clearBtn.className = "twm-data-table__filter-clear";
747
+ clearBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:12px;">close</span>';
748
+ clearBtn.style.display = existingFilter ? "" : "none";
749
+ clearBtn.addEventListener("click", (e) => {
750
+ e.stopPropagation();
751
+ input.value = "";
752
+ this._onFilterInput(colIdx, "");
753
+ clearBtn.style.display = "none";
754
+ input.focus();
755
+ });
756
+ input.addEventListener("input", () => {
757
+ clearBtn.style.display = input.value ? "" : "none";
758
+ this._onFilterInputDebounced(colIdx, input.value);
759
+ });
760
+ th.addEventListener("click", (e) => e.stopPropagation());
761
+ wrapper.appendChild(input);
762
+ wrapper.appendChild(dropdownBtn);
763
+ wrapper.appendChild(clearBtn);
764
+ th.appendChild(wrapper);
765
+ tr.appendChild(th);
766
+ });
767
+ return tr;
768
+ }
769
+ // ─────────────────────────────────────────────────────────────────
770
+ // Filter dropdown
771
+ // ─────────────────────────────────────────────────────────────────
772
+ _openFilterDropdown(colIdx, anchorEl, filterInput) {
773
+ this._closeFilterDropdown();
774
+ const isNumeric = this._columnTypes[colIdx] === "num";
775
+ const currentFilter = this._state.filters.get(colIdx) || "";
776
+ const parsed = this._parseFilterForDropdown(currentFilter, isNumeric);
777
+ const panel = document.createElement("div");
778
+ panel.className = "twm-data-table__filter-dropdown";
779
+ const selectLabel = document.createElement("label");
780
+ selectLabel.className = "twm-data-table__filter-dropdown-label";
781
+ selectLabel.textContent = isNumeric ? "Operator" : "Mode";
782
+ const select = document.createElement("select");
783
+ select.className = "twm-data-table__filter-dropdown-select";
784
+ const options = isNumeric ? [
785
+ { value: "=", label: "Equals" },
786
+ { value: "!=", label: "Not equals" },
787
+ { value: ">", label: "Greater than" },
788
+ { value: ">=", label: "Greater or equal" },
789
+ { value: "<", label: "Less than" },
790
+ { value: "<=", label: "Less or equal" },
791
+ { value: "..", label: "Between" }
792
+ ] : [
793
+ { value: "contains", label: "Contains" },
794
+ { value: "equals", label: "Equals" },
795
+ { value: "starts", label: "Starts with" },
796
+ { value: "ends", label: "Ends with" },
797
+ { value: "not", label: "Not contains" }
798
+ ];
799
+ for (const opt of options) {
800
+ const optEl = document.createElement("option");
801
+ optEl.value = opt.value;
802
+ optEl.textContent = opt.label;
803
+ if (opt.value === parsed.operator) optEl.selected = true;
804
+ select.appendChild(optEl);
805
+ }
806
+ const valueLabel = document.createElement("label");
807
+ valueLabel.className = "twm-data-table__filter-dropdown-label";
808
+ valueLabel.textContent = "Value";
809
+ const valueInput = document.createElement("input");
810
+ valueInput.type = isNumeric ? "number" : "text";
811
+ valueInput.className = "twm-data-table__filter-dropdown-input";
812
+ valueInput.placeholder = isNumeric ? "Number..." : "Text...";
813
+ valueInput.value = parsed.value;
814
+ const value2Label = document.createElement("label");
815
+ value2Label.className = "twm-data-table__filter-dropdown-label";
816
+ value2Label.textContent = "And";
817
+ const value2Input = document.createElement("input");
818
+ value2Input.type = "number";
819
+ value2Input.className = "twm-data-table__filter-dropdown-input";
820
+ value2Input.placeholder = "Number...";
821
+ value2Input.value = parsed.value2;
822
+ const value2Container = document.createElement("div");
823
+ value2Container.className = "twm-data-table__filter-dropdown-between";
824
+ value2Container.style.display = isNumeric && parsed.operator === ".." ? "" : "none";
825
+ value2Container.appendChild(value2Label);
826
+ value2Container.appendChild(value2Input);
827
+ select.addEventListener("change", () => {
828
+ value2Container.style.display = isNumeric && select.value === ".." ? "" : "none";
829
+ });
830
+ const actions = document.createElement("div");
831
+ actions.className = "twm-data-table__filter-dropdown-actions";
832
+ const clearBtn = document.createElement("button");
833
+ clearBtn.type = "button";
834
+ clearBtn.className = "twm-data-table__filter-dropdown-btn-action twm-data-table__filter-dropdown-btn-action--clear";
835
+ clearBtn.textContent = "Clear";
836
+ clearBtn.addEventListener("click", (e) => {
837
+ e.stopPropagation();
838
+ filterInput.value = "";
839
+ this._onFilterInput(colIdx, "");
840
+ this._closeFilterDropdown();
841
+ });
842
+ const applyBtn = document.createElement("button");
843
+ applyBtn.type = "button";
844
+ applyBtn.className = "twm-data-table__filter-dropdown-btn-action twm-data-table__filter-dropdown-btn-action--apply";
845
+ applyBtn.textContent = "Apply";
846
+ applyBtn.addEventListener("click", (e) => {
847
+ e.stopPropagation();
848
+ const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, isNumeric);
849
+ filterInput.value = composed;
850
+ this._onFilterInput(colIdx, composed);
851
+ this._closeFilterDropdown();
852
+ });
853
+ actions.appendChild(clearBtn);
854
+ actions.appendChild(applyBtn);
855
+ panel.appendChild(selectLabel);
856
+ panel.appendChild(select);
857
+ panel.appendChild(valueLabel);
858
+ panel.appendChild(valueInput);
859
+ panel.appendChild(value2Container);
860
+ panel.appendChild(actions);
861
+ document.body.appendChild(panel);
862
+ const anchorRect = anchorEl.getBoundingClientRect();
863
+ let left = anchorRect.left;
864
+ let top = anchorRect.bottom + 4;
865
+ const panelRect = panel.getBoundingClientRect();
866
+ if (left + panelRect.width > window.innerWidth) {
867
+ left = window.innerWidth - panelRect.width - 8;
868
+ }
869
+ if (top + panelRect.height > window.innerHeight) {
870
+ top = anchorRect.top - panelRect.height - 4;
871
+ }
872
+ panel.style.left = `${left}px`;
873
+ panel.style.top = `${top}px`;
874
+ requestAnimationFrame(() => valueInput.focus());
875
+ const handleOutsideClick = (e) => {
876
+ if (!panel.contains(e.target)) {
877
+ this._closeFilterDropdown();
878
+ }
879
+ };
880
+ const handleEscape = (e) => {
881
+ if (e.key === "Escape") {
882
+ this._closeFilterDropdown();
883
+ }
884
+ };
885
+ const handleEnter = (e) => {
886
+ if (e.key === "Enter") {
887
+ e.preventDefault();
888
+ const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, isNumeric);
889
+ filterInput.value = composed;
890
+ this._onFilterInput(colIdx, composed);
891
+ this._closeFilterDropdown();
892
+ }
893
+ };
894
+ setTimeout(() => {
895
+ document.addEventListener("click", handleOutsideClick, true);
896
+ }, 0);
897
+ document.addEventListener("keydown", handleEscape);
898
+ panel.addEventListener("keydown", handleEnter);
899
+ this._filterDropdownEl = panel;
900
+ this._filterDropdownCleanup = () => {
901
+ document.removeEventListener("click", handleOutsideClick, true);
902
+ document.removeEventListener("keydown", handleEscape);
903
+ };
904
+ }
905
+ _closeFilterDropdown() {
906
+ if (this._filterDropdownEl?.parentNode) {
907
+ this._filterDropdownEl.parentNode.removeChild(this._filterDropdownEl);
908
+ }
909
+ this._filterDropdownCleanup?.();
910
+ this._filterDropdownEl = null;
911
+ this._filterDropdownCleanup = null;
912
+ }
913
+ _parseFilterForDropdown(filterText, isNumeric) {
914
+ const text = (filterText || "").trim();
915
+ if (!text) {
916
+ return { operator: isNumeric ? "=" : "contains", value: "", value2: "" };
917
+ }
918
+ if (isNumeric) {
919
+ const rangeMatch = text.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
920
+ if (rangeMatch) {
921
+ return { operator: "..", value: rangeMatch[1], value2: rangeMatch[2] };
922
+ }
923
+ const opMatch = text.match(/^(>=|<=|!=|>|<|=)\s*(-?[\d.]+)$/);
924
+ if (opMatch) {
925
+ return { operator: opMatch[1], value: opMatch[2], value2: "" };
926
+ }
927
+ return { operator: "=", value: text, value2: "" };
928
+ }
929
+ if (text.startsWith('="') && text.endsWith('"')) {
930
+ return { operator: "equals", value: text.slice(2, -1), value2: "" };
931
+ }
932
+ if (text.startsWith("!")) {
933
+ return { operator: "not", value: text.slice(1), value2: "" };
934
+ }
935
+ if (text.startsWith("^")) {
936
+ return { operator: "starts", value: text.slice(1), value2: "" };
937
+ }
938
+ if (text.endsWith("$")) {
939
+ return { operator: "ends", value: text.slice(0, -1), value2: "" };
940
+ }
941
+ return { operator: "contains", value: text, value2: "" };
942
+ }
943
+ _composeFilterString(operator, value, value2, isNumeric) {
944
+ if (!value && operator !== "..") return "";
945
+ if (isNumeric) {
946
+ if (operator === "..") {
947
+ return value && value2 ? `${value}..${value2}` : "";
948
+ }
949
+ if (operator === "=") return value;
950
+ return `${operator}${value}`;
951
+ }
952
+ switch (operator) {
953
+ case "contains":
954
+ return value;
955
+ case "equals":
956
+ return value ? `="${value}"` : "";
957
+ case "starts":
958
+ return value ? `^${value}` : "";
959
+ case "ends":
960
+ return value ? `${value}$` : "";
961
+ case "not":
962
+ return value ? `!${value}` : "";
963
+ default:
964
+ return value;
965
+ }
966
+ }
967
+ _onFilterInputDebounced(colIdx, value) {
968
+ if (this._filterDebounceTimer) {
969
+ clearTimeout(this._filterDebounceTimer);
970
+ }
971
+ this._filterDebounceTimer = setTimeout(() => {
972
+ this._onFilterInput(colIdx, value);
973
+ }, 200);
974
+ }
975
+ _onFilterInput(colIdx, value) {
976
+ if (value) {
977
+ this._state.filters.set(colIdx, value);
978
+ } else {
979
+ this._state.filters.delete(colIdx);
980
+ }
981
+ this._invalidateProcessedCache();
982
+ this._state.offset = 0;
983
+ this._state.selected.clear();
984
+ this._state.anchorIndex = null;
985
+ this._savePersisted();
986
+ this.render();
987
+ }
988
+ _getActiveFilterColIdx() {
989
+ const active = document.activeElement;
990
+ if (!active || !active.classList.contains("twm-data-table__filter-input")) return null;
991
+ const cell = active.closest(".data-table__filter-cell");
992
+ if (!cell) return null;
993
+ const row = cell.parentElement;
994
+ if (!row) return null;
995
+ const cells = Array.from(row.children);
996
+ const idx = cells.indexOf(cell);
997
+ return this.config.showRowNumbers ? idx - 1 : idx;
998
+ }
999
+ _restoreFilterFocus(colIdx) {
1000
+ const filterRow = this._wrapperEl?.querySelector(".twm-data-table__filter-row");
1001
+ if (!filterRow) return;
1002
+ const cellIdx = this.config.showRowNumbers ? colIdx + 1 : colIdx;
1003
+ const cell = filterRow.children[cellIdx];
1004
+ const input = cell?.querySelector(".twm-data-table__filter-input");
1005
+ if (input) {
1006
+ input.focus();
1007
+ input.setSelectionRange(input.value.length, input.value.length);
1008
+ }
1009
+ }
1010
+ // ─────────────────────────────────────────────────────────────────
1011
+ // Private methods
1012
+ // ─────────────────────────────────────────────────────────────────
1013
+ _cleanup() {
1014
+ this._disposers.forEach((dispose) => {
1015
+ try {
1016
+ dispose?.();
1017
+ } catch (_) {
1018
+ }
1019
+ });
1020
+ this._disposers = [];
1021
+ this._closeFilterDropdown();
1022
+ if (this._filterDebounceTimer) {
1023
+ clearTimeout(this._filterDebounceTimer);
1024
+ this._filterDebounceTimer = null;
1025
+ }
1026
+ }
1027
+ _detectColumnTypes() {
1028
+ const { headers, rows, getColumnType } = this.config;
1029
+ const types = [];
1030
+ for (let colIdx = 0; colIdx < headers.length; colIdx++) {
1031
+ if (getColumnType) {
1032
+ types.push(getColumnType(colIdx, rows));
1033
+ continue;
1034
+ }
1035
+ let numericCount = 0;
1036
+ let sampleCount = 0;
1037
+ const maxSamples = Math.min(10, rows.length);
1038
+ for (let i = 0; i < maxSamples; i++) {
1039
+ const value = rows[i]?.[colIdx];
1040
+ if (value != null && value !== "") {
1041
+ sampleCount++;
1042
+ if (typeof value === "number" || /^-?[\d,.]+%?$/.test(String(value).trim())) {
1043
+ numericCount++;
1044
+ }
1045
+ }
1046
+ }
1047
+ types.push(sampleCount > 0 && numericCount / sampleCount > 0.5 ? "num" : "text");
1048
+ }
1049
+ return types;
1050
+ }
1051
+ _createPagination() {
1052
+ const { rows, pageSize, totalCount: configTotalCount, offset: configOffset, onPageChange, onJumpToRow } = this.config;
1053
+ const isServerSide = typeof onPageChange === "function";
1054
+ const offset = isServerSide ? configOffset || 0 : this._state.offset;
1055
+ const processedRows = isServerSide ? rows : this._getProcessedRows();
1056
+ const effectiveTotal = isServerSide ? configTotalCount || rows.length : processedRows.length;
1057
+ const unfilteredTotal = isServerSide ? configTotalCount || rows.length : rows.length;
1058
+ const isFiltered = !isServerSide && this._state.filters.size > 0;
1059
+ const displayedRows = isServerSide ? rows.length : Math.min(pageSize, effectiveTotal - offset);
1060
+ const endRow = Math.min(offset + displayedRows, effectiveTotal);
1061
+ const totalPages = Math.max(1, Math.ceil(effectiveTotal / pageSize));
1062
+ if (totalPages <= 1 && !isFiltered) return null;
1063
+ const currentPage = Math.floor(offset / pageSize) + 1;
1064
+ const hasPrev = offset > 0;
1065
+ const hasNext = offset + pageSize < effectiveTotal;
1066
+ const el = document.createElement("div");
1067
+ el.className = "twm-pagination-controls";
1068
+ el.style.cssText = "display:flex; align-items:center; gap:6px; padding:2px 8px; border-bottom:1px solid #2a2a2a; font-size:11px;";
1069
+ const info = document.createElement("span");
1070
+ info.style.cssText = "color:#aaa; white-space:nowrap;";
1071
+ if (effectiveTotal === 0) {
1072
+ info.textContent = isFiltered ? `0 of ${unfilteredTotal.toLocaleString()} rows match` : "No rows";
1073
+ } else if (isFiltered) {
1074
+ info.textContent = `Rows ${offset + 1}\u2013${endRow} of ${effectiveTotal.toLocaleString()} (${unfilteredTotal.toLocaleString()} total)`;
1075
+ } else {
1076
+ info.textContent = `Rows ${offset + 1}\u2013${endRow} of ${effectiveTotal.toLocaleString()}`;
1077
+ }
1078
+ const spacer = document.createElement("div");
1079
+ spacer.style.flex = "1";
1080
+ const handlePageChange = (newOffset) => {
1081
+ if (isServerSide) {
1082
+ this._awaitWithSpinner(onPageChange(newOffset, pageSize));
1083
+ } else {
1084
+ this._state.offset = newOffset;
1085
+ this.render();
1086
+ }
1087
+ };
1088
+ const firstBtn = this._createPaginationBtn("first_page", "First page", !hasPrev, () => {
1089
+ handlePageChange(0);
1090
+ });
1091
+ const prevBtn = this._createPaginationBtn("chevron_left", "Previous page", !hasPrev, () => {
1092
+ handlePageChange(Math.max(0, offset - pageSize));
1093
+ });
1094
+ const pageInfo = document.createElement("span");
1095
+ pageInfo.style.cssText = "color:#aaa; font-size:10px; min-width:72px; text-align:center;";
1096
+ pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
1097
+ const nextBtn = this._createPaginationBtn("chevron_right", "Next page", !hasNext, () => {
1098
+ handlePageChange(offset + pageSize);
1099
+ });
1100
+ const lastBtn = this._createPaginationBtn("last_page", "Last page", !hasNext, () => {
1101
+ handlePageChange(Math.floor((effectiveTotal - 1) / pageSize) * pageSize);
1102
+ });
1103
+ const jumpInput = document.createElement("input");
1104
+ jumpInput.type = "number";
1105
+ jumpInput.className = "twm-pagination-input";
1106
+ jumpInput.style.cssText = "width:54px; padding:1px 4px; border:1px solid #444; background:#2a2a2a; color:#ccc; font-size:10px;";
1107
+ jumpInput.min = "1";
1108
+ jumpInput.max = String(effectiveTotal);
1109
+ jumpInput.placeholder = "Row #";
1110
+ const jumpBtn = this._createPaginationBtn(null, "Go to row", false, () => {
1111
+ const target = Number(jumpInput.value);
1112
+ if (Number.isFinite(target) && target >= 1) {
1113
+ if (isServerSide && onJumpToRow) {
1114
+ onJumpToRow(target - 1);
1115
+ } else if (isServerSide) {
1116
+ const newOffset = Math.floor((target - 1) / pageSize) * pageSize;
1117
+ handlePageChange(newOffset);
1118
+ } else {
1119
+ this.goToRow(target - 1);
1120
+ }
1121
+ }
1122
+ }, "Go");
1123
+ el.appendChild(info);
1124
+ el.appendChild(spacer);
1125
+ el.appendChild(firstBtn);
1126
+ el.appendChild(prevBtn);
1127
+ el.appendChild(pageInfo);
1128
+ el.appendChild(nextBtn);
1129
+ el.appendChild(lastBtn);
1130
+ el.appendChild(jumpInput);
1131
+ el.appendChild(jumpBtn);
1132
+ if (this.config.showExportButton) {
1133
+ const exportBtn = this._createPaginationBtn(
1134
+ "download",
1135
+ "Download visible rows as CSV",
1136
+ false,
1137
+ () => this.downloadCSV("data.csv")
1138
+ );
1139
+ el.appendChild(exportBtn);
1140
+ }
1141
+ return el;
1142
+ }
1143
+ _createPaginationBtn(icon, tooltip, disabled, onClick, textLabel = null) {
1144
+ const btn = document.createElement("button");
1145
+ btn.type = "button";
1146
+ btn.className = "hoverbutton twm-pagination-btn twm-has-tooltip";
1147
+ btn.setAttribute("data-tooltip", tooltip);
1148
+ btn.disabled = disabled;
1149
+ btn.style.cssText = "padding:1px 3px; background:transparent; border:1px solid #444; color:#aaa; cursor:pointer; display:flex; align-items:center; justify-content:center; line-height:1;";
1150
+ if (disabled) {
1151
+ btn.style.opacity = "0.4";
1152
+ btn.style.cursor = "not-allowed";
1153
+ }
1154
+ if (icon) {
1155
+ const iconEl = document.createElement("span");
1156
+ iconEl.className = "material-symbols-outlined";
1157
+ iconEl.style.fontSize = "14px";
1158
+ iconEl.textContent = icon;
1159
+ btn.appendChild(iconEl);
1160
+ } else if (textLabel) {
1161
+ btn.textContent = textLabel;
1162
+ btn.style.fontSize = "10px";
1163
+ btn.style.padding = "1px 6px";
1164
+ }
1165
+ btn.addEventListener("click", onClick);
1166
+ return btn;
1167
+ }
1168
+ _createTable() {
1169
+ const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
1170
+ const { sortColumn, sortAscending } = this._state;
1171
+ const isServerSide = typeof onPageChange === "function";
1172
+ const processedRows = isServerSide ? rows : this._getProcessedRows();
1173
+ const offset = isServerSide ? configOffset || 0 : this._state.offset;
1174
+ const pageRows = isServerSide || !this.config.pagination ? processedRows : processedRows.slice(offset, offset + pageSize);
1175
+ const table = document.createElement("table");
1176
+ table.className = readonly ? "twm-preview-table twm-preview-table--readonly" : "twm-preview-table";
1177
+ table.tabIndex = 0;
1178
+ const thead = document.createElement("thead");
1179
+ const headerRow = document.createElement("tr");
1180
+ if (showRowNumbers) {
1181
+ const th = document.createElement("th");
1182
+ th.className = "num";
1183
+ th.textContent = "#";
1184
+ headerRow.appendChild(th);
1185
+ }
1186
+ headers.forEach((header, colIdx) => {
1187
+ const th = document.createElement("th");
1188
+ th.className = this._columnTypes[colIdx] || "text";
1189
+ if (sortable) {
1190
+ th.classList.add("sortable");
1191
+ th.style.cursor = "pointer";
1192
+ }
1193
+ if (getHeaderIcon) {
1194
+ const iconInfo = getHeaderIcon(header, colIdx);
1195
+ if (iconInfo) {
1196
+ const iconSpan = document.createElement("span");
1197
+ iconSpan.className = "material-symbols-outlined twm-header-icon twm-has-tooltip";
1198
+ iconSpan.setAttribute("data-tooltip", iconInfo.title || "");
1199
+ iconSpan.style.cssText = "font-size:16px; vertical-align:middle; margin-right:4px; opacity:0.7;";
1200
+ iconSpan.textContent = iconInfo.icon;
1201
+ th.appendChild(iconSpan);
1202
+ }
1203
+ }
1204
+ th.appendChild(document.createTextNode(header));
1205
+ if (sortable) {
1206
+ const sortIcon = document.createElement("span");
1207
+ sortIcon.className = "material-symbols-outlined twm-sort-icon";
1208
+ sortIcon.style.cssText = "font-size:14px; margin-left:4px; opacity:0.5;";
1209
+ if (sortColumn === colIdx) {
1210
+ sortIcon.textContent = sortAscending ? "arrow_upward" : "arrow_downward";
1211
+ sortIcon.style.opacity = "1";
1212
+ } else {
1213
+ sortIcon.textContent = "unfold_more";
1214
+ }
1215
+ th.appendChild(sortIcon);
1216
+ th.addEventListener("click", () => this.sortBy(colIdx));
1217
+ }
1218
+ th.title = header;
1219
+ headerRow.appendChild(th);
1220
+ });
1221
+ thead.appendChild(headerRow);
1222
+ if (filterable) {
1223
+ const filterRow = this._createFilterRow(headers);
1224
+ thead.appendChild(filterRow);
1225
+ }
1226
+ const virtual = this._isVirtual();
1227
+ if (virtual) {
1228
+ table.classList.add("twm-dt--virtual");
1229
+ table.appendChild(this._createColgroup());
1230
+ }
1231
+ table.appendChild(thead);
1232
+ const globalOf = (localIdx) => {
1233
+ const processedIdx = offset + localIdx;
1234
+ return isServerSide ? (configOffset || 0) + localIdx : this._processedIndexMap?.[processedIdx] ?? processedIdx;
1235
+ };
1236
+ const tbody = document.createElement("tbody");
1237
+ table.appendChild(tbody);
1238
+ this._tbodyEl = tbody;
1239
+ if (virtual) {
1240
+ this._virtInit(tbody, pageRows, globalOf, {
1241
+ serverSide: isServerSide,
1242
+ offset,
1243
+ configOffset: configOffset || 0
1244
+ });
1245
+ } else {
1246
+ pageRows.forEach((row, localIdx) => {
1247
+ tbody.appendChild(this._buildBodyRow(row, globalOf(localIdx), localIdx));
1248
+ });
1249
+ }
1250
+ return table;
1251
+ }
1252
+ /** One body row, fully built. The ONE place a `<tr>` for a row is made, so
1253
+ * the full render and the virtual window cannot drift apart: a row drawn
1254
+ * on scroll is the same row a render would have drawn. */
1255
+ _buildBodyRow(row, globalIdx, localIdx) {
1256
+ const { showRowNumbers } = this.config;
1257
+ const tr = document.createElement("tr");
1258
+ tr.__rowIndex = globalIdx;
1259
+ tr.className = "data-preview-row";
1260
+ if (this._state.selected.has(globalIdx)) {
1261
+ tr.classList.add("selected");
1262
+ }
1263
+ if (this._virt) tr.classList.toggle("twm-dt-row--alt", localIdx % 2 === 1);
1264
+ this._fillRowCells(tr, row, globalIdx, showRowNumbers);
1265
+ if (this.config.onRowClick) {
1266
+ tr.addEventListener("click", (ev) => {
1267
+ this.config.onRowClick(globalIdx, row, ev);
1268
+ });
1269
+ }
1270
+ if (this.config.onRowContextMenu) {
1271
+ tr.addEventListener("contextmenu", (ev) => {
1272
+ this.config.onRowContextMenu(globalIdx, row, ev);
1273
+ });
1274
+ }
1275
+ return tr;
1276
+ }
1277
+ // ─────────────────────────────────────────────────────────────────
1278
+ // C10 — the virtual window
1279
+ // ─────────────────────────────────────────────────────────────────
1280
+ //
1281
+ // ══ WHAT IT IS ═══════════════════════════════════════════════════
1282
+ //
1283
+ // Only the rows near the viewport are in the DOM: a TOP SPACER row whose
1284
+ // height is every row above the window, the window's rows, and a BOTTOM
1285
+ // SPACER for everything below it. The scrollbar is therefore sized to the
1286
+ // whole table and every row is reachable, while the DOM stays bounded by
1287
+ // the viewport — a thousand-row page (or fifty of them) costs the same
1288
+ // few dozen `<tr>`s as one screenful.
1289
+ //
1290
+ // ══ WHAT IT PROMISES ═════════════════════════════════════════════
1291
+ //
1292
+ // - A row that stays in the window is NEVER MOVED OR REBUILT when the
1293
+ // window slides. Rows leave from the edges and arrive at the edges; the
1294
+ // ones in the middle are the same nodes. An open editor, a focused
1295
+ // cell, a hover — anything living in a row that is still on screen —
1296
+ // survives a scroll. (Moving a node that holds focus blurs it, which in
1297
+ // an editable grid is a commit nobody asked for.)
1298
+ // - Column widths come from a `<colgroup>`, so the first row changing
1299
+ // moves nothing (see `_createTable`).
1300
+ // - A spacer is in the DOM only while it has a height. A table that fits
1301
+ // in its window has exactly the DOM it had without `virtualize`.
1302
+ // - `onRowsRendered` hears about every row added after `render()`, and
1303
+ // every row taken out.
1304
+ //
1305
+ // ══ WHAT IT ASSUMES ══════════════════════════════════════════════
1306
+ //
1307
+ // That a row's height is knowable without drawing it: `baseHeight`,
1308
+ // measured from a real row, or what `virtualRowHeight` says. A row that
1309
+ // wraps to two lines is drawn correctly and is simply mis-counted in the
1310
+ // spacer arithmetic by the difference — the overscan absorbs that.
1311
+ _isVirtual() {
1312
+ return !!this.config.virtualize;
1313
+ }
1314
+ _virtOptions() {
1315
+ const v = this.config.virtualize;
1316
+ const o = v && typeof v === "object" ? v : {};
1317
+ return {
1318
+ overscan: Number.isFinite(o.overscan) && o.overscan >= 0 ? Math.floor(o.overscan) : 20,
1319
+ rowHeight: Number.isFinite(o.rowHeight) && o.rowHeight > 0 ? o.rowHeight : 24
1320
+ };
1321
+ }
1322
+ /** One `<col>` per DOM column (the row-number column included), carrying
1323
+ * any width already pinned for it, so a re-render starts at the widths it
1324
+ * had rather than at a content-sized frame that then snaps. */
1325
+ _createColgroup() {
1326
+ const n = (this.config.headers?.length || 0) + (this.config.showRowNumbers ? 1 : 0);
1327
+ const colgroup = document.createElement("colgroup");
1328
+ for (let i = 0; i < n; i++) {
1329
+ const col = document.createElement("col");
1330
+ const w = this._colWidths?.[i];
1331
+ if (w != null && this._colWidthsSig === this._colSig()) col.style.width = `${w}px`;
1332
+ colgroup.appendChild(col);
1333
+ }
1334
+ this._colgroupEl = colgroup;
1335
+ return colgroup;
1336
+ }
1337
+ /** The `<col>`s, when this table has them. */
1338
+ _cols() {
1339
+ return this._virt && this._colgroupEl ? this._colgroupEl.children : null;
1340
+ }
1341
+ _virtInit(tbody, rows, globalOf, { serverSide, offset, configOffset }) {
1342
+ const { overscan, rowHeight } = this._virtOptions();
1343
+ const spacer = (edge) => {
1344
+ const tr = document.createElement("tr");
1345
+ tr.className = `twm-dt-spacer twm-dt-spacer--${edge}`;
1346
+ tr.setAttribute("aria-hidden", "true");
1347
+ const td = document.createElement("td");
1348
+ td.colSpan = Math.max(1, (this.config.headers?.length || 0) + (this.config.showRowNumbers ? 1 : 0));
1349
+ td.style.cssText = "padding:0;border:0;height:0;min-width:0;max-width:none;";
1350
+ tr.appendChild(td);
1351
+ return tr;
1352
+ };
1353
+ const filler = document.createElement("tr");
1354
+ filler.className = "twm-dt-spacer twm-dt-spacer--parity";
1355
+ filler.setAttribute("aria-hidden", "true");
1356
+ filler.hidden = true;
1357
+ this._virt = {
1358
+ tbody,
1359
+ rows,
1360
+ globalOf,
1361
+ serverSide,
1362
+ offset,
1363
+ configOffset,
1364
+ overscan,
1365
+ base: this._virtBase || rowHeight,
1366
+ heights: null,
1367
+ offsets: null,
1368
+ start: 0,
1369
+ end: 0,
1370
+ map: /* @__PURE__ */ new Map(),
1371
+ top: spacer("top"),
1372
+ bottom: spacer("bottom"),
1373
+ filler
1374
+ };
1375
+ this._virtComputeOffsets();
1376
+ const guess = this.container?.clientHeight || 0;
1377
+ this._virtUpdate({ force: true, notify: false, viewport: guess || void 0, top: 0 });
1378
+ }
1379
+ /** Heights and their running sum. `offsets[i]` is where row `i` starts;
1380
+ * `offsets[n]` is the whole table. O(n), and rerun only when a height can
1381
+ * have changed — a new measurement, or `refreshVirtualLayout()`. */
1382
+ _virtComputeOffsets() {
1383
+ const v = this._virt;
1384
+ if (!v) return;
1385
+ const n = v.rows.length;
1386
+ const heights = new Float64Array(n);
1387
+ const offsets = new Float64Array(n + 1);
1388
+ const fn = typeof this.config.virtualRowHeight === "function" ? this.config.virtualRowHeight : null;
1389
+ let y = 0;
1390
+ for (let i = 0; i < n; i++) {
1391
+ let h = v.base;
1392
+ if (fn) {
1393
+ const asked = fn(v.globalOf(i), v.base);
1394
+ h = Number.isFinite(asked) && asked > 0 ? asked : 0;
1395
+ }
1396
+ heights[i] = h;
1397
+ offsets[i] = y;
1398
+ y += h;
1399
+ }
1400
+ offsets[n] = y;
1401
+ v.heights = heights;
1402
+ v.offsets = offsets;
1403
+ }
1404
+ /** The row whose span contains `y`: the LAST `i` with `offsets[i] <= y`,
1405
+ * which steps over rows of zero height at the same offset. */
1406
+ _virtIndexAt(y) {
1407
+ const v = this._virt;
1408
+ const n = v.rows.length;
1409
+ if (n === 0) return 0;
1410
+ let lo = 0;
1411
+ let hi = n - 1;
1412
+ while (lo < hi) {
1413
+ const mid = lo + hi + 1 >> 1;
1414
+ if (v.offsets[mid] <= y) lo = mid;
1415
+ else hi = mid - 1;
1416
+ }
1417
+ return lo;
1418
+ }
1419
+ /** Measure one real row. Client pixels, so the zoom is divided back out —
1420
+ * `_measureNaturalWidths` explains why — and NOT `offsetHeight`, which is
1421
+ * rounded: 21 for a 21.19px row is a 1,100px error over 6,000 rows. */
1422
+ _virtMeasure() {
1423
+ const v = this._virt;
1424
+ if (!v) return false;
1425
+ for (const tr of v.map.values()) {
1426
+ if (!tr.isConnected || tr.hidden) continue;
1427
+ const zoom = this._tableEl?.currentCSSZoom || 1;
1428
+ const h = tr.getBoundingClientRect().height / zoom;
1429
+ if (!(h > 0)) return false;
1430
+ if (Math.abs(h - v.base) > 0.01) {
1431
+ v.base = h;
1432
+ this._virtBase = h;
1433
+ this._virtComputeOffsets();
1434
+ return true;
1435
+ }
1436
+ return false;
1437
+ }
1438
+ return false;
1439
+ }
1440
+ /**
1441
+ * Slide the window to wherever the scroll box now is.
1442
+ *
1443
+ * HYSTERESIS, so a scroll of one pixel does not rebuild anything: the
1444
+ * window is redrawn only when the visible rows come within a quarter of the
1445
+ * overscan of either end of what is drawn, and it is then redrawn with the
1446
+ * full overscan on both sides.
1447
+ *
1448
+ * @param {object} [o]
1449
+ * @param {boolean} [o.force] redraw even inside the hysteresis band.
1450
+ * @param {boolean} [o.notify] report added and removed rows to `onRowsRendered`.
1451
+ * @param {number} [o.top] the scroll offset to draw for, instead of
1452
+ * reading one back — `scrollToRow` passes the offset it has just set,
1453
+ * which a DOM with no layout would read back as 0.
1454
+ * @param {number} [o.viewport] the viewport height, likewise.
1455
+ * @returns {HTMLElement[]} the rows added.
1456
+ */
1457
+ _virtUpdate({ force = false, notify = true, top = null, viewport = null } = {}) {
1458
+ const v = this._virt;
1459
+ if (!v || !v.offsets) return [];
1460
+ const n = v.rows.length;
1461
+ const wrap = this._tableWrapEl;
1462
+ const view = viewport || wrap?.clientHeight || VIRTUAL_FALLBACK_VIEWPORT_PX;
1463
+ const scrollTop = top ?? (wrap?.scrollTop || 0);
1464
+ const vs = this._virtIndexAt(scrollTop);
1465
+ const ve = Math.min(n, this._virtIndexAt(scrollTop + view) + 1);
1466
+ if (!force && v.end > v.start) {
1467
+ const margin = Math.max(1, Math.floor(v.overscan / 4));
1468
+ const lowOk = v.start === 0 || vs >= v.start + margin;
1469
+ const highOk = v.end === n || ve <= v.end - margin;
1470
+ if (lowOk && highOk) return [];
1471
+ }
1472
+ const start = Math.max(0, vs - v.overscan);
1473
+ const end = Math.min(n, ve + v.overscan);
1474
+ const removed = [];
1475
+ const added = this._virtRender(start, end, removed);
1476
+ if (added.length || removed.length) {
1477
+ if (added[0]?.isConnected && this._tableEl?.style.tableLayout === "fixed") {
1478
+ this._updateCellTooltips(added);
1479
+ }
1480
+ if (notify) {
1481
+ try {
1482
+ this.config.onRowsRendered?.({ rows: added, removed });
1483
+ } catch (err) {
1484
+ console.error("[DataTable] onRowsRendered threw", err);
1485
+ }
1486
+ }
1487
+ }
1488
+ return added;
1489
+ }
1490
+ /** Make the DOM hold exactly the drawable rows of `[start, end)`, without
1491
+ * moving a row that is already there. See the promises above. */
1492
+ _virtRender(start, end, removed = null) {
1493
+ const v = this._virt;
1494
+ const tbody = v.tbody;
1495
+ const want = [];
1496
+ for (let i = start; i < end; i++) if (v.heights[i] > 0) want.push(i);
1497
+ const wanted = new Set(want);
1498
+ for (const [i, tr] of v.map) {
1499
+ if (!wanted.has(i)) {
1500
+ tr.remove();
1501
+ v.map.delete(i);
1502
+ removed?.push(tr);
1503
+ }
1504
+ }
1505
+ v.start = start;
1506
+ v.end = end;
1507
+ this._virtSpacers();
1508
+ const added = [];
1509
+ let ref = v.bottom.isConnected ? v.bottom : null;
1510
+ for (let k = want.length - 1; k >= 0; k--) {
1511
+ const i = want[k];
1512
+ const have = v.map.get(i);
1513
+ if (have) {
1514
+ ref = have;
1515
+ continue;
1516
+ }
1517
+ const tr = this._buildBodyRow(v.rows[i], v.globalOf(i), i);
1518
+ v.map.set(i, tr);
1519
+ tbody.insertBefore(tr, ref);
1520
+ ref = tr;
1521
+ added.push(tr);
1522
+ }
1523
+ added.reverse();
1524
+ return added;
1525
+ }
1526
+ /**
1527
+ * Size the two spacers, and put each in the DOM only while it has a height.
1528
+ *
1529
+ * AND KEEP THE STRIPE. `tr:nth-child(even)` stripes by POSITION, and in a
1530
+ * full render row `i` is child `i + 1`. Here the first drawn row is preceded
1531
+ * by the top spacer, so its position is off by one — and by a different one
1532
+ * each time the window's start changes parity, which strobes every stripe
1533
+ * on the screen as you scroll. A hidden filler row after the spacer, present
1534
+ * exactly when `start` is even, puts the parity back: a `display: none` row
1535
+ * is still a child, and so still counted by `nth-child`.
1536
+ */
1537
+ _virtSpacers() {
1538
+ const v = this._virt;
1539
+ const n = v.rows.length;
1540
+ const above = v.offsets[v.start];
1541
+ const below = v.offsets[n] - v.offsets[v.end];
1542
+ const tbody = v.tbody;
1543
+ if (above > 0) {
1544
+ v.top.firstChild.style.height = `${above}px`;
1545
+ if (tbody.firstChild !== v.top) tbody.insertBefore(v.top, tbody.firstChild);
1546
+ const needFiller = v.start % 2 === 0;
1547
+ if (needFiller && v.top.nextSibling !== v.filler) v.top.after(v.filler);
1548
+ else if (!needFiller) v.filler.remove();
1549
+ } else {
1550
+ v.top.remove();
1551
+ v.filler.remove();
1552
+ }
1553
+ if (below > 0) {
1554
+ v.bottom.firstChild.style.height = `${below}px`;
1555
+ if (tbody.lastChild !== v.bottom) tbody.appendChild(v.bottom);
1556
+ } else {
1557
+ v.bottom.remove();
1558
+ }
1559
+ }
1560
+ /**
1561
+ * Recompute every row's height and redraw the window. For an embedder whose
1562
+ * `virtualRowHeight` answer has changed — a group folded, a header added.
1563
+ * A no-op on a table that is not virtual.
1564
+ */
1565
+ refreshVirtualLayout() {
1566
+ if (!this._virt) return;
1567
+ this._virtMeasure();
1568
+ this._virtComputeOffsets();
1569
+ this._virtUpdate({ force: true });
1570
+ }
1571
+ /** The element that scrolls the body. Read-only; the table owns it. */
1572
+ get scrollElement() {
1573
+ return this._tableWrapEl || null;
1574
+ }
1575
+ /** The `<tr>` drawing row `rowIndex` (the `__rowIndex` space), or null when
1576
+ * it is not in the DOM — outside the window, on another page, or hidden. */
1577
+ getRowElement(rowIndex) {
1578
+ const v = this._virt;
1579
+ if (v) {
1580
+ const local = this._virtLocalOf(rowIndex);
1581
+ return local < 0 ? null : v.map.get(local) || null;
1582
+ }
1583
+ const tbody = this._tbodyEl;
1584
+ if (!tbody) return null;
1585
+ for (const tr of tbody.children) if (tr.__rowIndex === rowIndex) return tr;
1586
+ return null;
1587
+ }
1588
+ /** Keep the window's row list and `config.rows` in step for one row. They
1589
+ * are the same array in server-side mode; in client mode the window reads
1590
+ * the filtered-and-sorted copy, which holds its own reference. */
1591
+ _virtSetRow(rowIndex, row) {
1592
+ const v = this._virt;
1593
+ if (!v) return;
1594
+ if (Array.isArray(this.config.rows) && rowIndex >= 0 && rowIndex < this.config.rows.length) {
1595
+ this.config.rows[rowIndex] = row;
1596
+ }
1597
+ const local = this._virtLocalOf(rowIndex);
1598
+ if (local >= 0) v.rows[local] = row;
1599
+ }
1600
+ /** `__rowIndex` → position in the window's row list, or -1. */
1601
+ _virtLocalOf(rowIndex) {
1602
+ const v = this._virt;
1603
+ if (!v) return -1;
1604
+ let local;
1605
+ if (v.serverSide) local = rowIndex - v.configOffset;
1606
+ else {
1607
+ const map = this._processedIndexMap;
1608
+ const processed = map ? map.indexOf(rowIndex) : rowIndex;
1609
+ local = processed < 0 ? -1 : processed - v.offset;
1610
+ }
1611
+ return local >= 0 && local < v.rows.length ? local : -1;
1612
+ }
1613
+ /**
1614
+ * Scroll the body so row `rowIndex` is on screen, drawing it if it was not,
1615
+ * and return its `<tr>` (null when it cannot be shown: another page, or a
1616
+ * row the embedder hides). Works with or without `virtualize`.
1617
+ *
1618
+ * @param {number} rowIndex in the `__rowIndex` space.
1619
+ * @param {{block?: 'nearest'|'center'|'start'}} [opts]
1620
+ */
1621
+ scrollToRow(rowIndex, { block = "nearest" } = {}) {
1622
+ const wrap = this._tableWrapEl;
1623
+ const v = this._virt;
1624
+ if (!v) {
1625
+ const tr = this.getRowElement(rowIndex);
1626
+ if (!tr || !wrap) return tr;
1627
+ const zoom = this._tableEl?.currentCSSZoom || 1;
1628
+ const box = wrap.getBoundingClientRect();
1629
+ const r = tr.getBoundingClientRect();
1630
+ const rowTop = (r.top - box.top) / zoom + wrap.scrollTop;
1631
+ const rowH = r.height / zoom;
1632
+ const target2 = this._scrollTarget(
1633
+ rowTop,
1634
+ rowH,
1635
+ wrap.scrollTop,
1636
+ wrap.clientHeight,
1637
+ block
1638
+ );
1639
+ if (target2 !== wrap.scrollTop) wrap.scrollTop = target2;
1640
+ return tr;
1641
+ }
1642
+ const local = this._virtLocalOf(rowIndex);
1643
+ if (local < 0 || !(v.heights[local] > 0)) return null;
1644
+ const view = wrap?.clientHeight || VIRTUAL_FALLBACK_VIEWPORT_PX;
1645
+ const current = wrap?.scrollTop || 0;
1646
+ let target = this._scrollTarget(
1647
+ v.offsets[local],
1648
+ v.heights[local],
1649
+ current,
1650
+ view,
1651
+ block
1652
+ );
1653
+ target = Math.max(0, Math.min(target, Math.max(0, v.offsets[v.rows.length] - view)));
1654
+ if (wrap && Math.abs(target - current) >= 1) wrap.scrollTop = target;
1655
+ this._virtUpdate({ top: Math.abs(target - current) >= 1 ? target : current });
1656
+ return v.map.get(local) || null;
1657
+ }
1658
+ _scrollTarget(rowTop, rowH, current, view, block) {
1659
+ if (block === "start") return rowTop;
1660
+ if (block === "center") return rowTop - Math.max(0, (view - rowH) / 2);
1661
+ if (rowTop < current) return rowTop;
1662
+ if (rowTop + rowH > current + view) return rowTop + rowH - view;
1663
+ return current;
1664
+ }
1665
+ /** The first body row that draws a row of data — never a spacer, and never
1666
+ * a row an embedder injected (a group header spans every column with ONE
1667
+ * cell, so measuring it would report one column). */
1668
+ _firstBodyRow() {
1669
+ const tbody = this._tableEl?.querySelector?.("tbody");
1670
+ if (!tbody) return null;
1671
+ for (const tr of tbody.children) {
1672
+ if (tr.__rowIndex !== void 0) return tr;
1673
+ }
1674
+ return null;
1675
+ }
1676
+ /** Build (or rebuild) one row's cells in place.
1677
+ *
1678
+ * Extracted from the body loop so that `updateRow` and the initial
1679
+ * render share ONE cell-building path. Two paths would drift, and the
1680
+ * drift would show as a cell that renders differently after a live
1681
+ * update than it did on load. */
1682
+ _fillRowCells(tr, row, globalIdx, showRowNumbers) {
1683
+ tr.replaceChildren();
1684
+ if (showRowNumbers) {
1685
+ const td = document.createElement("td");
1686
+ td.className = "num";
1687
+ td.textContent = String(globalIdx + 1);
1688
+ tr.appendChild(td);
1689
+ }
1690
+ for (let colIdx = 0; colIdx < row.length; colIdx++) {
1691
+ const td = document.createElement("td");
1692
+ td.className = this._columnTypes[colIdx] || "text";
1693
+ const value = row[colIdx];
1694
+ if (this.config.renderCell) {
1695
+ const handled = this.config.renderCell(td, value, colIdx, globalIdx, row);
1696
+ if (!handled) {
1697
+ td.textContent = this._formatValue(value, colIdx);
1698
+ }
1699
+ } else {
1700
+ td.textContent = this._formatValue(value, colIdx);
1701
+ }
1702
+ if (this.config.onCellContextMenu) {
1703
+ const cellColIdx = colIdx;
1704
+ td.addEventListener("contextmenu", (ev) => {
1705
+ this.config.onCellContextMenu(
1706
+ cellColIdx,
1707
+ globalIdx,
1708
+ value,
1709
+ td,
1710
+ ev
1711
+ );
1712
+ });
1713
+ }
1714
+ tr.appendChild(td);
1715
+ }
1716
+ }
1717
+ /** Re-render ONE row in place, preserving everything around it.
1718
+ *
1719
+ * `render()` rebuilds the entire `<tbody>`, which takes the scroll
1720
+ * position, any open editor, the keyboard focus and the measured column
1721
+ * widths with it. That is fine for a sort or a page change and wrong for
1722
+ * a single-cell commit or a live update arriving over a socket — the
1723
+ * common case in an editable grid, where a full rebuild once per keystroke
1724
+ * is both visible and destructive.
1725
+ *
1726
+ * What survives, by construction:
1727
+ * - scroll position, because the tbody is not replaced;
1728
+ * - the separately-rendered thead/tbody column widths, because the
1729
+ * explicit widths live on the header cells and on the FIRST body row,
1730
+ * and all three of the properties `_setCellWidth` writes are re-applied
1731
+ * here when that first row is the one being replaced;
1732
+ * - selection, because the `selected` class is recomputed from the
1733
+ * selection set rather than carried on the old element;
1734
+ * - keyboard focus, because the focused element's position is recorded
1735
+ * before the replace and restored after.
1736
+ *
1737
+ * @param {number} index row index as tracked by `tr.__rowIndex`
1738
+ * @param {any[]} row the new cell values
1739
+ * @returns {boolean} false when the row is not currently rendered
1740
+ * (it is on another page, or outside the render
1741
+ * window) — which is NOT an error: the caller has
1742
+ * nothing to update on screen.
1743
+ */
1744
+ updateRow(index, row) {
1745
+ const tbody = this._tbodyEl;
1746
+ if (!tbody) return false;
1747
+ let tr = null;
1748
+ for (const candidate of tbody.children) {
1749
+ if (candidate.__rowIndex === index) {
1750
+ tr = candidate;
1751
+ break;
1752
+ }
1753
+ }
1754
+ if (!tr) {
1755
+ if (this._virt) this._virtSetRow(index, row);
1756
+ return false;
1757
+ }
1758
+ if (Array.isArray(this.config.rows) && this.config.rows[index]) {
1759
+ this.config.rows[index] = row;
1760
+ }
1761
+ if (this._virt) this._virtSetRow(index, row);
1762
+ const active = document.activeElement;
1763
+ let focusedCol = -1;
1764
+ if (active && tr.contains(active)) {
1765
+ focusedCol = Array.prototype.indexOf.call(tr.children, active.closest("td"));
1766
+ }
1767
+ const isFirstRow = tr === tbody.firstElementChild;
1768
+ const widths = isFirstRow ? Array.prototype.map.call(tr.children, (td) => ({
1769
+ width: td.style.width,
1770
+ minWidth: td.style.minWidth,
1771
+ maxWidth: td.style.maxWidth
1772
+ })) : null;
1773
+ const showRowNumbers = this.config.showRowNumbers !== false && tr.firstElementChild?.classList.contains("num");
1774
+ this._fillRowCells(tr, row, index, showRowNumbers);
1775
+ if (widths) {
1776
+ widths.forEach((saved, i) => {
1777
+ const cell = tr.children[i];
1778
+ if (!saved.width || !cell) return;
1779
+ cell.style.width = saved.width;
1780
+ cell.style.minWidth = saved.minWidth;
1781
+ cell.style.maxWidth = saved.maxWidth;
1782
+ });
1783
+ }
1784
+ tr.classList.toggle("selected", this._state?.selected?.has(index) === true);
1785
+ if (focusedCol >= 0 && tr.children[focusedCol]) {
1786
+ tr.children[focusedCol].focus?.();
1787
+ }
1788
+ return true;
1789
+ }
1790
+ /** Sync the (separate) header table's column widths to the body
1791
+ * table's measured widths. Without this, the two tables compute
1792
+ * widths independently and the columns drift apart. Locks both
1793
+ * tables to `table-layout: fixed` and writes explicit width onto
1794
+ * each header cell of every header row (header + filter row). */
1795
+ /**
1796
+ * Measure every column's NATURAL content width, in one transient reflow.
1797
+ *
1798
+ * Extracted from `_syncHeaderWidths` so that auto-size can ask the same
1799
+ * question the fit pass asks, and get the same answer. Two measurement
1800
+ * passes would drift, and the drift would show as a double-click that
1801
+ * sized a column differently from the render that follows it.
1802
+ *
1803
+ * The pass ignores the CSS caps (the 150px-pinned first column, the 80px
1804
+ * min on the rest) and the filter-row inputs: `twm-dt-measuring` flips both
1805
+ * tables to `table-layout:auto; width:max-content` with those caps off (via
1806
+ * `!important`) for a single reflow, then reverts before paint — it is
1807
+ * never visible. Clearing inline widths first stops the last sync's forced
1808
+ * widths from constraining the measure.
1809
+ *
1810
+ * C10 — UNDER `virtualize` ONLY THE WINDOW IS IN THE DOM, so `max-content`
1811
+ * spans the eighty-odd rows drawn rather than every row loaded. The fit
1812
+ * pass that runs on every render is content with that (it is fitting what
1813
+ * is on screen). An EXPLICIT auto-size is not: it is a promise to fit the
1814
+ * column's content, and the longest value in a 6,000-row table is usually
1815
+ * not in the window. So `sample` adds, for the length of this one reflow,
1816
+ * the row holding each column's longest value — see `_virtSampleRows`.
1817
+ *
1818
+ * @param {{sample?: boolean}} [o]
1819
+ * @returns {number[]|null} width per DOM column index, or null when there
1820
+ * is nothing laid out to measure.
1821
+ */
1822
+ _measureNaturalWidths({ sample = false } = {}) {
1823
+ const headerTable = this._headerTableEl;
1824
+ const bodyTable = this._tableEl;
1825
+ if (!headerTable || !bodyTable) return null;
1826
+ const firstRow = this._firstBodyRow();
1827
+ if (!firstRow) return null;
1828
+ const bodyCells = firstRow.children;
1829
+ if (!bodyCells.length) return null;
1830
+ headerTable.querySelectorAll("thead > tr").forEach((tr) => {
1831
+ for (const th of tr.children) this._clearCellWidth(th);
1832
+ });
1833
+ for (const td of bodyCells) this._clearCellWidth(td);
1834
+ for (const col of this._cols() || []) col.style.width = "";
1835
+ headerTable.style.width = "";
1836
+ bodyTable.style.width = "";
1837
+ headerTable.classList.add("twm-dt-measuring");
1838
+ bodyTable.classList.add("twm-dt-measuring");
1839
+ const sampled = sample ? this._virtSampleRows() : [];
1840
+ for (const tr of sampled) this._virt.tbody.appendChild(tr);
1841
+ bodyTable.offsetWidth;
1842
+ const labelRow = headerTable.querySelector("thead > tr");
1843
+ const cols = bodyCells.length;
1844
+ const natural = new Array(cols);
1845
+ const zoom = bodyTable.currentCSSZoom || 1;
1846
+ for (let i = 0; i < cols; i++) {
1847
+ const body = bodyCells[i].getBoundingClientRect().width / zoom;
1848
+ const head = labelRow && labelRow.children[i] ? labelRow.children[i].getBoundingClientRect().width / zoom : 0;
1849
+ natural[i] = Math.max(body, head);
1850
+ }
1851
+ for (const tr of sampled) tr.remove();
1852
+ headerTable.classList.remove("twm-dt-measuring");
1853
+ bodyTable.classList.remove("twm-dt-measuring");
1854
+ return natural;
1855
+ }
1856
+ /**
1857
+ * The rows an explicit auto-size must measure that the window has not
1858
+ * drawn: for each column, the drawable row whose value is LONGEST as a
1859
+ * string, plus the last row (the widest row number). Built with
1860
+ * `_buildBodyRow` — so a consumer's `renderCell` draws them exactly as it
1861
+ * would on screen — and handed back detached, for `_measureNaturalWidths`
1862
+ * to put in and take out around its one reflow.
1863
+ *
1864
+ * The string length is a proxy, not a measurement: in a proportional font
1865
+ * forty `i`s are narrower than thirty `W`s. It is the proxy a spreadsheet's
1866
+ * own autofit falls short by, and it is right about the case that matters
1867
+ * here — a value five times longer than anything in the window. One pass
1868
+ * over the loaded rows, on a click and never on a scroll or a resize.
1869
+ *
1870
+ * @returns {HTMLTableRowElement[]}
1871
+ */
1872
+ _virtSampleRows() {
1873
+ const v = this._virt;
1874
+ if (!v || !v.rows.length) return [];
1875
+ const longest = [];
1876
+ const at = [];
1877
+ let last = -1;
1878
+ for (let i = 0; i < v.rows.length; i++) {
1879
+ if (!(v.heights[i] > 0)) continue;
1880
+ last = i;
1881
+ const row = v.rows[i];
1882
+ if (!Array.isArray(row)) continue;
1883
+ for (let c = 0; c < row.length; c++) {
1884
+ const value = row[c];
1885
+ const len = value == null ? 0 : String(value).length;
1886
+ if (len > (longest[c] ?? -1)) {
1887
+ longest[c] = len;
1888
+ at[c] = i;
1889
+ }
1890
+ }
1891
+ }
1892
+ const want = new Set(at.filter((i) => i !== void 0));
1893
+ if (last >= 0) want.add(last);
1894
+ const rows = [];
1895
+ for (const i of want) {
1896
+ if (v.map.has(i)) continue;
1897
+ const tr = this._buildBodyRow(v.rows[i], v.globalOf(i), i);
1898
+ tr.setAttribute("aria-hidden", "true");
1899
+ rows.push(tr);
1900
+ }
1901
+ return rows;
1902
+ }
1903
+ /**
1904
+ * Fit one column to its widest value and pin it there — every loaded
1905
+ * row's, under `virtualize` (see `_virtSampleRows`), not only the window's.
1906
+ *
1907
+ * The gesture is a double-click on the column's resize grip, which is where
1908
+ * every spreadsheet has put it — and the grip is this component's, which is
1909
+ * why the behaviour is too. A consumer that wanted this had no hook to hang
1910
+ * it on: `_installColumnResizers` bound `mousedown` and a `click` that only
1911
+ * suppressed the sort, and the measurement, the pin, the table-width
1912
+ * invariant and the write-through to `stateStore` are all private here.
1913
+ *
1914
+ * @param {number} domIdx column index INCLUDING the row-number column when
1915
+ * `showRowNumbers` is on — the same index space as `_colWidths`.
1916
+ * @param {{maxWidth?: number}} [opts] ceiling; defaults to AUTOSIZE_MAX_PX.
1917
+ * @returns {boolean} whether it had a layout to measure.
1918
+ */
1919
+ autoSizeColumn(domIdx, opts = {}) {
1920
+ const natural = this._measureNaturalWidths({ sample: true });
1921
+ if (!natural || domIdx < 0 || domIdx >= natural.length) return false;
1922
+ this._colWidths[domIdx] = this._autoWidth(natural[domIdx], opts);
1923
+ this._colWidthsSig = this._colSig();
1924
+ this._savePersisted();
1925
+ this._syncHeaderWidths();
1926
+ return true;
1927
+ }
1928
+ /**
1929
+ * The same for every column at once.
1930
+ *
1931
+ * It REPLACES existing drag overrides rather than sizing around them: "fit
1932
+ * every column to its content" that quietly excepted the three columns you
1933
+ * had dragged would be a button whose result depends on history nobody can
1934
+ * see.
1935
+ *
1936
+ * The result may well be wider than the container — forty columns of real
1937
+ * content usually are — and that is the intended answer, not a failure:
1938
+ * `_syncHeaderWidths` honours overrides verbatim, sizes both tables to their
1939
+ * total and the body wrap scrolls sideways with the header following it.
1940
+ * Squeezing them to fit is what the automatic fit pass does on every render
1941
+ * already, so a button that did that would be a button that does nothing.
1942
+ */
1943
+ autoSizeColumns(opts = {}) {
1944
+ const natural = this._measureNaturalWidths({ sample: true });
1945
+ if (!natural) return false;
1946
+ this._colWidths = {};
1947
+ for (let i = 0; i < natural.length; i++) {
1948
+ this._colWidths[i] = this._autoWidth(natural[i], opts);
1949
+ }
1950
+ this._colWidthsSig = this._colSig();
1951
+ this._savePersisted();
1952
+ this._syncHeaderWidths();
1953
+ return true;
1954
+ }
1955
+ /** One measured width, floored at the drag-resize minimum and capped, with
1956
+ * the same sub-pixel pad `_fitColumnWidths` adds against ellipsis. */
1957
+ _autoWidth(natural, opts = {}) {
1958
+ const max = opts.maxWidth ?? AUTOSIZE_MAX_PX;
1959
+ return Math.min(max, Math.max(40, Math.ceil((natural || 0) + 2)));
1960
+ }
1961
+ /**
1962
+ * Does every one of the `n` DOM columns already carry an override taken
1963
+ * against the column set that is on screen right now?
1964
+ *
1965
+ * The signature half is not belt-and-braces. `render()` drops `_colWidths`
1966
+ * when `_colSig` changes, but `_syncHeaderWidths` is also reached straight
1967
+ * from the ResizeObserver, which never goes through `render()` — so a set
1968
+ * of overrides measured against the PREVIOUS headers can still be sitting
1969
+ * in `_colWidths` when this is asked. Answering "pinned" then would skip
1970
+ * the measure and hand `_fitColumnWidths` widths keyed to columns that are
1971
+ * no longer there, each one landing on its neighbour.
1972
+ *
1973
+ * `== null` rather than a falsy test, to match `_fitColumnWidths`' own
1974
+ * `overrides[i] != null`: a legitimately-stored 0 must be read the same way
1975
+ * in both places or the two disagree about which columns are flexible.
1976
+ */
1977
+ _allColumnsPinned(n) {
1978
+ if (!n || this._colWidthsSig !== this._colSig()) return false;
1979
+ for (let i = 0; i < n; i++) {
1980
+ if (this._colWidths[i] == null) return false;
1981
+ }
1982
+ return true;
1983
+ }
1984
+ /** Sync the (separate) header table's column widths to the body
1985
+ * table's measured widths. Without this, the two tables compute
1986
+ * widths independently and the columns drift apart. Locks both
1987
+ * tables to `table-layout: fixed` and writes explicit width onto
1988
+ * each header cell of every header row (header + filter row). */
1989
+ _syncHeaderWidths() {
1990
+ const headerTable = this._headerTableEl;
1991
+ const bodyTable = this._tableEl;
1992
+ if (!headerTable || !bodyTable) return;
1993
+ const cols = this._cols();
1994
+ const firstRow = this._firstBodyRow();
1995
+ const bodyCells = cols ? [] : firstRow?.children;
1996
+ const n = cols ? cols.length : bodyCells?.length || 0;
1997
+ if (!n) return;
1998
+ const headerRows = headerTable.querySelectorAll("thead > tr");
1999
+ const natural = this._allColumnsPinned(n) ? new Array(n).fill(0) : this._measureNaturalWidths();
2000
+ if (!natural) return;
2001
+ const wrap = this._tableWrapEl;
2002
+ const headerWrap = this._headerWrapEl;
2003
+ const avail = wrap ? wrap.clientWidth : 0;
2004
+ const firstIdx = this.config.showRowNumbers && n > 1 ? 1 : 0;
2005
+ const widths = this._fitColumnWidths(natural, avail, {
2006
+ firstIdx,
2007
+ overrides: this._colWidths,
2008
+ fit: this.config.columnFit
2009
+ });
2010
+ const total = widths.reduce((a, b) => a + b, 0);
2011
+ headerRows.forEach((tr) => {
2012
+ for (let i = 0; i < tr.children.length && i < widths.length; i++) {
2013
+ this._setCellWidth(tr.children[i], widths[i]);
2014
+ }
2015
+ });
2016
+ if (cols) {
2017
+ for (let i = 0; i < cols.length && i < widths.length; i++) {
2018
+ cols[i].style.width = `${widths[i]}px`;
2019
+ }
2020
+ } else {
2021
+ for (let i = 0; i < bodyCells.length && i < widths.length; i++) {
2022
+ this._setCellWidth(bodyCells[i], widths[i]);
2023
+ }
2024
+ }
2025
+ headerTable.style.tableLayout = "fixed";
2026
+ bodyTable.style.tableLayout = "fixed";
2027
+ const hasOverride = Object.keys(this._colWidths).length > 0;
2028
+ if (hasOverride || total > avail + 1) {
2029
+ headerTable.style.width = `${total}px`;
2030
+ bodyTable.style.width = `${total}px`;
2031
+ } else {
2032
+ headerTable.style.width = "";
2033
+ bodyTable.style.width = "";
2034
+ }
2035
+ if (wrap && headerWrap) {
2036
+ const sbw = Math.max(0, wrap.offsetWidth - wrap.clientWidth);
2037
+ headerWrap.style.paddingRight = sbw ? `${sbw}px` : "";
2038
+ this._wrapperEl?.style?.setProperty("--twm-dt-gutter", `${sbw}px`);
2039
+ }
2040
+ this._syncHeaderScroll();
2041
+ this._updateCellTooltips();
2042
+ }
2043
+ /**
2044
+ * Convert measured natural content widths into final column widths.
2045
+ *
2046
+ * Goals (the "intelligent" sizing):
2047
+ * - every column wants its content width (+a hair), clamped to a
2048
+ * sane [floor, cap]; the FIRST content column gets a more generous
2049
+ * cap so it shows its full value;
2050
+ * - user-dragged columns (`overrides`) are pinned, never grown/shrunk;
2051
+ * - if everything fits, grow the flexible columns evenly to fill the
2052
+ * width (no dead gap on the right);
2053
+ * - if it doesn't fit, protect the first column and water-fill-shrink
2054
+ * the rest (trim the widest first) until it fits; only if even the
2055
+ * floors overflow do we give up and let the body scroll sideways.
2056
+ *
2057
+ * Under `fit: 'content'` the first and last of those goals invert: each
2058
+ * unpinned column asks for its own content width through the same
2059
+ * `_autoWidth` the grip double-click uses, and an overflow is the answer
2060
+ * rather than a problem — the caller wanted a table that scrolls sideways,
2061
+ * not one squeezed toward the floor. The grow-to-fill branch is shared by
2062
+ * both, because a table narrower than its container leaves a dead gap on
2063
+ * the right under either reading.
2064
+ *
2065
+ * @param {number[]} natural measured content width per column (px)
2066
+ * @param {number} avail usable width of the body wrap (px)
2067
+ * @param {{firstIdx?:number, overrides?:Object, fit?:'container'|'content'}} [opts]
2068
+ * @returns {number[]} final width per column (px)
2069
+ */
2070
+ _fitColumnWidths(natural, avail, opts = {}) {
2071
+ const FLOOR = 40;
2072
+ const CAP = 360;
2073
+ const FIRST_CAP = 520;
2074
+ const PAD = 2;
2075
+ const n = natural.length;
2076
+ const firstIdx = opts.firstIdx ?? 0;
2077
+ const overrides = opts.overrides || {};
2078
+ const toContent = opts.fit === "content";
2079
+ const desired = new Array(n);
2080
+ const pinned = new Array(n).fill(false);
2081
+ for (let i = 0; i < n; i++) {
2082
+ if (overrides[i] != null) {
2083
+ desired[i] = Math.max(FLOOR, Math.round(overrides[i]));
2084
+ pinned[i] = true;
2085
+ continue;
2086
+ }
2087
+ if (toContent) {
2088
+ desired[i] = this._autoWidth(natural[i]);
2089
+ continue;
2090
+ }
2091
+ const cap = i === firstIdx ? FIRST_CAP : CAP;
2092
+ desired[i] = Math.min(cap, Math.max(FLOOR, Math.ceil(natural[i] + PAD)));
2093
+ }
2094
+ const widths = desired.slice();
2095
+ const sum = widths.reduce((a, b) => a + b, 0);
2096
+ if (avail <= 1) return widths;
2097
+ if (sum <= avail) {
2098
+ const flex = [];
2099
+ for (let i = 0; i < n; i++) if (!pinned[i]) flex.push(i);
2100
+ const slack = avail - sum;
2101
+ if (slack > 0) {
2102
+ const targets = flex.length ? flex : [n - 1];
2103
+ const per = Math.floor(slack / targets.length);
2104
+ for (const i of targets) widths[i] += per;
2105
+ widths[targets[targets.length - 1]] += slack - per * targets.length;
2106
+ }
2107
+ return widths;
2108
+ }
2109
+ if (toContent) return widths;
2110
+ let protectedSum = 0;
2111
+ const shrinkable = [];
2112
+ for (let i = 0; i < n; i++) {
2113
+ if (pinned[i] || i === firstIdx) protectedSum += widths[i];
2114
+ else shrinkable.push(i);
2115
+ }
2116
+ const budget = avail - protectedSum;
2117
+ if (budget < shrinkable.length * FLOOR) {
2118
+ for (const i of shrinkable) widths[i] = FLOOR;
2119
+ return widths;
2120
+ }
2121
+ shrinkable.sort((a, b) => desired[a] - desired[b]);
2122
+ let remaining = budget;
2123
+ for (let k = 0; k < shrinkable.length; k++) {
2124
+ const colsLeft = shrinkable.length - k;
2125
+ const fair = Math.floor(remaining / colsLeft);
2126
+ const i = shrinkable[k];
2127
+ if (desired[i] <= fair) {
2128
+ widths[i] = desired[i];
2129
+ remaining -= desired[i];
2130
+ } else {
2131
+ const level = Math.max(FLOOR, fair);
2132
+ for (let j = k; j < shrinkable.length; j++) {
2133
+ widths[shrinkable[j]] = level;
2134
+ }
2135
+ const leftover = remaining - colsLeft * level;
2136
+ if (leftover > 0) widths[shrinkable[shrinkable.length - 1]] += leftover;
2137
+ break;
2138
+ }
2139
+ }
2140
+ return widths;
2141
+ }
2142
+ /** Set an explicit width on a table cell, clearing any CSS min/max
2143
+ * width constraint so the width is honored exactly. The first
2144
+ * ("time") column is pinned to 150px by `min/max-width` in CSS;
2145
+ * without this, dragging it does nothing and the slack leaks to the
2146
+ * other columns. */
2147
+ _setCellWidth(cell, w) {
2148
+ cell.style.width = `${w}px`;
2149
+ cell.style.minWidth = "0";
2150
+ cell.style.maxWidth = "none";
2151
+ }
2152
+ /** Undo `_setCellWidth` so a re-measure sees the cell's natural,
2153
+ * CSS-constrained width again. */
2154
+ _clearCellWidth(cell) {
2155
+ cell.style.width = "";
2156
+ cell.style.minWidth = "";
2157
+ cell.style.maxWidth = "";
2158
+ }
2159
+ /** Wire the body wrap's horizontal scroll to the header. The header
2160
+ * sits in an overflow:hidden wrap, so it can't scroll sideways on
2161
+ * its own — `_syncHeaderScroll` scrolls it programmatically to the
2162
+ * body's `scrollLeft`. Re-installed each render against the freshly-built
2163
+ * wrap. */
2164
+ _installHeaderScrollSync() {
2165
+ const wrap = this._tableWrapEl;
2166
+ if (!wrap) return;
2167
+ if (this._onBodyScroll && this._scrollSyncEl) {
2168
+ this._scrollSyncEl.removeEventListener("scroll", this._onBodyScroll);
2169
+ }
2170
+ this._onBodyScroll = () => this._syncHeaderScroll();
2171
+ this._scrollSyncEl = wrap;
2172
+ wrap.addEventListener("scroll", this._onBodyScroll, { passive: true });
2173
+ this._syncHeaderScroll();
2174
+ }
2175
+ /**
2176
+ * Scroll the header wrap to match the body's horizontal scroll so the
2177
+ * columns stay aligned when the table overflows sideways.
2178
+ *
2179
+ * ══ C29. A TRANSFORM IS WHAT MAKES A STICKY COLUMN IMPOSSIBLE ═══════
2180
+ *
2181
+ * This wrote `headerTable.style.transform = translateX(-scrollLeft)`. It
2182
+ * aligns the two tables perfectly and it forecloses `position: sticky`
2183
+ * entirely, because the two halves of the table then pin against different
2184
+ * things: a sticky cell in the BODY pins to the body wrap's scrollport,
2185
+ * while its header counterpart is moved by a transform inside a box that
2186
+ * never scrolls at all. Scroll sideways and the pinned body column stands
2187
+ * still while its header slides away with everything else — so a table
2188
+ * with a trailing verb column (the row-actions column Tables needs pinned
2189
+ * to the right edge) can have a sticky body or an aligned header, never
2190
+ * both. A transform also establishes a containing block for fixed/sticky
2191
+ * descendants, which breaks the header cell independently of the offset.
2192
+ *
2193
+ * AN `overflow: hidden` BOX IS STILL A SCROLL CONTAINER. It has no
2194
+ * scrollbar and no user affordance, and `scrollLeft` moves it exactly like
2195
+ * any other. Scrolling the wrap rather than transforming its child gives
2196
+ * the header a real scrollport at the same offset as the body's, which is
2197
+ * the one arrangement in which a sticky cell on each side pins to the same
2198
+ * place. Identical for ordinary content: same pixels, no transform, no new
2199
+ * containing block.
2200
+ *
2201
+ * THE CLAMP IS ALREADY PAID FOR. A scroll container clamps `scrollLeft` to
2202
+ * `scrollWidth - clientWidth`, and the body wrap carries a vertical
2203
+ * scrollbar the header wrap does not — so the header would clamp short by
2204
+ * the scrollbar width and desync at the far right. `_syncHeaderWidths`
2205
+ * already pads the header wrap by exactly that gutter (`paddingRight`,
2206
+ * :1905), and end padding counts toward `scrollWidth`, so the two maxima
2207
+ * coincide. The residual below is the honest belt for the day some engine
2208
+ * disagrees about that: it is zero in the ordinary case, so the transform
2209
+ * is not set and sticky keeps working, and it is the difference rather
2210
+ * than the whole offset if it is ever not.
2211
+ */
2212
+ _syncHeaderScroll() {
2213
+ const wrap = this._tableWrapEl;
2214
+ const headerTable = this._headerTableEl;
2215
+ if (!wrap || !headerTable) return;
2216
+ const x = wrap.scrollLeft;
2217
+ const headerWrap = this._headerWrapEl;
2218
+ if (!headerWrap) {
2219
+ headerTable.style.transform = x ? `translateX(${-x}px)` : "";
2220
+ return;
2221
+ }
2222
+ headerWrap.scrollLeft = x;
2223
+ const residual = x - headerWrap.scrollLeft;
2224
+ headerTable.style.transform = residual ? `translateX(${-residual}px)` : "";
2225
+ }
2226
+ /** Drag-to-resize: hang a thin grab handle off the right edge of
2227
+ * every header cell. Dragging it writes a per-column width override
2228
+ * (keyed by DOM index) that `_syncHeaderWidths` then honors. */
2229
+ _installColumnResizers() {
2230
+ const headerTable = this._headerTableEl;
2231
+ if (!headerTable) return;
2232
+ const headRow = headerTable.querySelector("thead > tr");
2233
+ if (!headRow) return;
2234
+ [...headRow.children].forEach((th, domIdx) => {
2235
+ if (th.querySelector(".twm-dt-col-resizer")) return;
2236
+ th.classList.add("twm-dt-col");
2237
+ const grip = document.createElement("div");
2238
+ grip.className = "twm-dt-col-resizer";
2239
+ grip.addEventListener(
2240
+ "mousedown",
2241
+ (ev) => this._beginColResize(ev, domIdx)
2242
+ );
2243
+ grip.addEventListener("click", (ev) => ev.stopPropagation());
2244
+ grip.addEventListener("dblclick", (ev) => {
2245
+ ev.preventDefault();
2246
+ ev.stopPropagation();
2247
+ this.autoSizeColumn(domIdx);
2248
+ });
2249
+ th.appendChild(grip);
2250
+ });
2251
+ }
2252
+ /**
2253
+ * CSS px per screen px. Content inside a zoomed tile reports rects and
2254
+ * pointer coordinates in screen pixels, while `style.width` is CSS pixels;
2255
+ * mixing them widened every column by the zoom factor on a mere click.
2256
+ */
2257
+ _zoomFactor() {
2258
+ const el = this._tableWrapEl || this._tableEl;
2259
+ const css = el?.offsetWidth || 0;
2260
+ const screen = el?.getBoundingClientRect?.().width || 0;
2261
+ return css > 0 && screen > 0 ? screen / css : 1;
2262
+ }
2263
+ _beginColResize(ev, domIdx) {
2264
+ if (ev.button != null && ev.button !== 0) return;
2265
+ ev.preventDefault();
2266
+ ev.stopPropagation();
2267
+ const headerTable = this._headerTableEl;
2268
+ const bodyTable = this._tableEl;
2269
+ const headRow = headerTable && headerTable.querySelector("thead > tr");
2270
+ if (!headRow) return;
2271
+ const startX = ev.clientX;
2272
+ const THRESHOLD = 3;
2273
+ const MIN = 40;
2274
+ let startWidths = null;
2275
+ const begin = () => {
2276
+ const z = this._zoomFactor();
2277
+ startWidths = [...headRow.children].map((c) => {
2278
+ const set = parseFloat(c.style.width);
2279
+ return Number.isFinite(set) ? set : c.getBoundingClientRect().width / z;
2280
+ });
2281
+ headerTable.style.tableLayout = "fixed";
2282
+ if (bodyTable) bodyTable.style.tableLayout = "fixed";
2283
+ startWidths.forEach((w, i) => this._pinColumnWidth(i, w));
2284
+ this._applyTableWidth();
2285
+ document.body.classList.add("twm-dt-col-resizing");
2286
+ };
2287
+ const onMove = (mv) => {
2288
+ const dx = mv.clientX - startX;
2289
+ if (!startWidths) {
2290
+ if (Math.abs(dx) < THRESHOLD) return;
2291
+ begin();
2292
+ }
2293
+ const w = Math.max(MIN, Math.round(startWidths[domIdx] + dx / this._zoomFactor()));
2294
+ this._pinColumnWidth(domIdx, w);
2295
+ this._applyTableWidth(domIdx);
2296
+ this._syncHeaderScroll();
2297
+ };
2298
+ const onUp = () => {
2299
+ document.removeEventListener("mousemove", onMove);
2300
+ document.removeEventListener("mouseup", onUp);
2301
+ if (!startWidths) return;
2302
+ document.body.classList.remove("twm-dt-col-resizing");
2303
+ this._updateCellTooltips();
2304
+ this._savePersisted();
2305
+ };
2306
+ document.addEventListener("mousemove", onMove);
2307
+ document.addEventListener("mouseup", onUp);
2308
+ }
2309
+ /** Pin one column to an explicit width across BOTH tables (every
2310
+ * header row + the body's first row) and record it as a user
2311
+ * override so `_syncHeaderWidths` honors it on later renders. The
2312
+ * single place that writes a column width during a drag / fill. */
2313
+ _pinColumnWidth(i, w) {
2314
+ const headerTable = this._headerTableEl;
2315
+ const bodyTable = this._tableEl;
2316
+ if (!headerTable) return;
2317
+ this._colWidths[i] = w;
2318
+ headerTable.querySelectorAll("thead > tr").forEach((tr) => {
2319
+ if (tr.children[i]) this._setCellWidth(tr.children[i], w);
2320
+ });
2321
+ const cols = this._cols();
2322
+ if (cols) {
2323
+ if (cols[i]) cols[i].style.width = `${w}px`;
2324
+ return;
2325
+ }
2326
+ const bodyRow = bodyTable && this._firstBodyRow();
2327
+ if (bodyRow && bodyRow.children[i]) this._setCellWidth(bodyRow.children[i], w);
2328
+ }
2329
+ /** Size both tables so a widened column grows the table (→ horizontal
2330
+ * scroll) rather than stealing from its neighbours — while enforcing
2331
+ * the TABLE WIDTH INVARIANT: the table is never narrower than its
2332
+ * container. Any width freed by dragging a column in stretches a
2333
+ * flexible column (the last one, stepping off the column being
2334
+ * dragged) so the table always spans ≥100% of the wrap.
2335
+ *
2336
+ * @param {number|null} dragIdx column the user is actively dragging,
2337
+ * so the fill lands on a DIFFERENT column and doesn't fight the
2338
+ * drag. Null (non-drag callers) lets the last column flex. */
2339
+ _applyTableWidth(dragIdx = null) {
2340
+ const headerTable = this._headerTableEl;
2341
+ const bodyTable = this._tableEl;
2342
+ const headRow = headerTable && headerTable.querySelector("thead > tr");
2343
+ if (!headRow) return;
2344
+ const cells = [...headRow.children];
2345
+ const cols = cells.length;
2346
+ if (cols === 0) return;
2347
+ const FLOOR = 40;
2348
+ const widthOf = (th) => {
2349
+ const px = parseFloat(th.style.width);
2350
+ return Number.isFinite(px) ? px : th.getBoundingClientRect().width;
2351
+ };
2352
+ const cur = cells.map(widthOf);
2353
+ const wrap = this._tableWrapEl;
2354
+ const avail = wrap ? wrap.clientWidth : 0;
2355
+ let flexIdx = cols - 1;
2356
+ if (dragIdx != null && flexIdx === dragIdx) flexIdx -= 1;
2357
+ if (avail > 1 && flexIdx >= 0 && flexIdx !== dragIdx) {
2358
+ let rest = 0;
2359
+ for (let i = 0; i < cols; i++) if (i !== flexIdx) rest += cur[i];
2360
+ const fill = Math.max(FLOOR, Math.round(avail - rest));
2361
+ if (Math.round(fill) !== Math.round(cur[flexIdx])) {
2362
+ this._pinColumnWidth(flexIdx, fill);
2363
+ cur[flexIdx] = fill;
2364
+ }
2365
+ }
2366
+ let total = 0;
2367
+ for (const w of cur) total += w;
2368
+ total = Math.ceil(total);
2369
+ headerTable.style.width = `${total}px`;
2370
+ if (bodyTable) bodyTable.style.width = `${total}px`;
2371
+ }
2372
+ /** Add a native `title` tooltip to any body cell whose text is
2373
+ * clipped by its column width; remove ours once it fits again.
2374
+ * Leaves caller-supplied titles (e.g. from renderCell) untouched. */
2375
+ _updateCellTooltips(rows = null) {
2376
+ const bodyTable = this._tableEl;
2377
+ if (!bodyTable) return;
2378
+ const cells = rows ? rows.flatMap((tr) => [...tr.children]) : bodyTable.querySelectorAll("tbody td");
2379
+ cells.forEach((td) => {
2380
+ if (td.parentElement?.classList.contains("twm-dt-spacer")) return;
2381
+ const clipped = td.scrollWidth > td.clientWidth + 1;
2382
+ if (clipped) {
2383
+ if (!td.title) td.title = td.textContent;
2384
+ } else if (td.title && td.title === td.textContent) {
2385
+ td.removeAttribute("title");
2386
+ }
2387
+ });
2388
+ }
2389
+ _installInteractions() {
2390
+ if (!this._tbodyEl || !this._tableEl) return;
2391
+ const { selectable, copyable } = this.config;
2392
+ const tbody = this._tbodyEl;
2393
+ const table = this._tableEl;
2394
+ if (selectable) {
2395
+ const handleRowClick = (event) => {
2396
+ const rowEl = event.target.closest("tr");
2397
+ if (!rowEl || rowEl.__rowIndex === void 0) return;
2398
+ const idx = rowEl.__rowIndex;
2399
+ const selected = this._state.selected;
2400
+ if (event.shiftKey && this._state.anchorIndex != null) {
2401
+ const start = Math.min(this._state.anchorIndex, idx);
2402
+ const end = Math.max(this._state.anchorIndex, idx);
2403
+ selected.clear();
2404
+ for (let i = start; i <= end; i++) selected.add(i);
2405
+ } else if (event.metaKey || event.ctrlKey) {
2406
+ if (selected.has(idx)) selected.delete(idx);
2407
+ else selected.add(idx);
2408
+ this._state.anchorIndex = idx;
2409
+ } else {
2410
+ selected.clear();
2411
+ selected.add(idx);
2412
+ this._state.anchorIndex = idx;
2413
+ }
2414
+ this._updateRowSelection();
2415
+ this._notifySelectionChange();
2416
+ try {
2417
+ table.focus({ preventScroll: true });
2418
+ } catch (_) {
2419
+ }
2420
+ };
2421
+ tbody.addEventListener("click", handleRowClick);
2422
+ this._disposers.push(() => tbody.removeEventListener("click", handleRowClick));
2423
+ }
2424
+ if (copyable) {
2425
+ const handleContextMenu = (event) => {
2426
+ const rowEl = event.target.closest("tr");
2427
+ if (!rowEl) {
2428
+ this._hideContextMenu();
2429
+ return;
2430
+ }
2431
+ event.preventDefault();
2432
+ event.stopPropagation();
2433
+ const idx = rowEl.__rowIndex;
2434
+ if (idx !== void 0 && !this._state.selected.has(idx)) {
2435
+ this._state.selected.clear();
2436
+ this._state.selected.add(idx);
2437
+ this._state.anchorIndex = idx;
2438
+ this._updateRowSelection();
2439
+ this._notifySelectionChange();
2440
+ }
2441
+ try {
2442
+ table.focus({ preventScroll: true });
2443
+ } catch (_) {
2444
+ }
2445
+ setTimeout(() => this._showContextMenu(event.clientX, event.clientY), 0);
2446
+ };
2447
+ tbody.addEventListener("contextmenu", handleContextMenu);
2448
+ this._disposers.push(() => tbody.removeEventListener("contextmenu", handleContextMenu));
2449
+ const handleKeyDown = (event) => {
2450
+ const ctrlLike = event.ctrlKey || event.metaKey;
2451
+ if (ctrlLike && !event.altKey) {
2452
+ const key = String(event.key || "").toLowerCase();
2453
+ if (key === "a" && selectable) {
2454
+ event.preventDefault();
2455
+ this._state.selected.clear();
2456
+ const indexMap = this._processedIndexMap;
2457
+ if (indexMap) {
2458
+ for (const originalIdx of indexMap) {
2459
+ this._state.selected.add(originalIdx);
2460
+ }
2461
+ } else {
2462
+ for (let i = 0; i < this.config.rows.length; i++) {
2463
+ this._state.selected.add(i);
2464
+ }
2465
+ }
2466
+ this._state.anchorIndex = this.config.rows.length - 1;
2467
+ this._updateRowSelection();
2468
+ this._notifySelectionChange();
2469
+ } else if (key === "c") {
2470
+ event.preventDefault();
2471
+ this.copyToClipboard(event.shiftKey ? "csv" : "tsv");
2472
+ }
2473
+ } else if (event.key === "Escape") {
2474
+ if (this._state.selected.size) {
2475
+ this.clearSelection();
2476
+ }
2477
+ this._hideContextMenu();
2478
+ }
2479
+ };
2480
+ table.addEventListener("keydown", handleKeyDown);
2481
+ this._disposers.push(() => table.removeEventListener("keydown", handleKeyDown));
2482
+ }
2483
+ }
2484
+ _updateRowSelection() {
2485
+ if (!this._tbodyEl) return;
2486
+ this._tbodyEl.querySelectorAll("tr").forEach((tr) => {
2487
+ const idx = tr.__rowIndex;
2488
+ if (idx !== void 0) {
2489
+ tr.classList.toggle("selected", this._state.selected.has(idx));
2490
+ }
2491
+ });
2492
+ }
2493
+ _notifySelectionChange() {
2494
+ this.config.onSelectionChange?.(this.getSelection());
2495
+ }
2496
+ _ensureContextMenu() {
2497
+ if (this._contextMenuEl) return this._contextMenuEl;
2498
+ const menu = document.createElement("div");
2499
+ menu.className = "twm-context-menu data-context-menu";
2500
+ menu.style.display = "none";
2501
+ menu.style.position = "fixed";
2502
+ menu.style.zIndex = "10001";
2503
+ menu.innerHTML = `
2504
+ <div class="twm-context-menu-item" data-action="copy-tsv">
2505
+ <span class="material-symbols-outlined">content_copy</span>
2506
+ <span class="label">Copy Row(s)</span>
2507
+ </div>
2508
+ <div class="twm-context-menu-item" data-action="copy-csv">
2509
+ <span class="material-symbols-outlined">table</span>
2510
+ <span class="label">Copy as CSV</span>
2511
+ </div>
2512
+ `;
2513
+ document.body.appendChild(menu);
2514
+ menu.addEventListener("click", (event) => {
2515
+ const item = event.target.closest(".twm-context-menu-item");
2516
+ if (!item || item.classList.contains("disabled")) return;
2517
+ const action = item.getAttribute("data-action");
2518
+ if (action === "copy-tsv") this.copyToClipboard("tsv");
2519
+ else if (action === "copy-csv") this.copyToClipboard("csv");
2520
+ event.stopPropagation();
2521
+ event.preventDefault();
2522
+ }, { capture: true });
2523
+ menu.addEventListener("contextmenu", (e) => {
2524
+ e.preventDefault();
2525
+ e.stopPropagation();
2526
+ });
2527
+ const hideOnGlobal = (event) => {
2528
+ if (event?.target && menu.contains(event.target)) return;
2529
+ this._hideContextMenu();
2530
+ };
2531
+ const hideOnEscape = (event) => {
2532
+ if (event.key === "Escape") this._hideContextMenu();
2533
+ };
2534
+ const hideOnResize = () => this._hideContextMenu();
2535
+ document.addEventListener("click", hideOnGlobal, true);
2536
+ document.addEventListener("scroll", hideOnGlobal, true);
2537
+ window.addEventListener("resize", hideOnResize);
2538
+ document.addEventListener("keydown", hideOnEscape);
2539
+ this._contextMenuGlobals.push(() => document.removeEventListener("click", hideOnGlobal, true));
2540
+ this._contextMenuGlobals.push(() => document.removeEventListener("scroll", hideOnGlobal, true));
2541
+ this._contextMenuGlobals.push(() => document.removeEventListener("keydown", hideOnEscape));
2542
+ this._contextMenuGlobals.push(() => window.removeEventListener("resize", hideOnResize));
2543
+ this._contextMenuEl = menu;
2544
+ return menu;
2545
+ }
2546
+ _hideContextMenu() {
2547
+ if (this._contextMenuEl) {
2548
+ this._contextMenuEl.style.display = "none";
2549
+ }
2550
+ }
2551
+ _showContextMenu(clientX, clientY) {
2552
+ const menu = this._ensureContextMenu();
2553
+ const hasSelection = this._state.selected.size > 0;
2554
+ menu.querySelectorAll(".twm-context-menu-item").forEach((item) => {
2555
+ item.classList.toggle("disabled", !hasSelection);
2556
+ item.style.pointerEvents = hasSelection ? "" : "none";
2557
+ });
2558
+ menu.style.display = "block";
2559
+ const rect = menu.getBoundingClientRect();
2560
+ let left = clientX;
2561
+ let top = clientY;
2562
+ if (left + rect.width > window.innerWidth) {
2563
+ left = window.innerWidth - rect.width - 8;
2564
+ }
2565
+ if (top + rect.height > window.innerHeight) {
2566
+ top = window.innerHeight - rect.height - 8;
2567
+ }
2568
+ menu.style.left = `${left}px`;
2569
+ menu.style.top = `${top}px`;
2570
+ }
2571
+ _teardownContextMenu() {
2572
+ this._contextMenuGlobals.forEach((dispose) => {
2573
+ try {
2574
+ dispose?.();
2575
+ } catch (_) {
2576
+ }
2577
+ });
2578
+ this._contextMenuGlobals = [];
2579
+ if (this._contextMenuEl?.parentNode) {
2580
+ this._contextMenuEl.parentNode.removeChild(this._contextMenuEl);
2581
+ }
2582
+ this._contextMenuEl = null;
2583
+ }
2584
+ _formatValue(value, colIndex) {
2585
+ if (this.config.formatValue) {
2586
+ return this.config.formatValue(value, colIndex);
2587
+ }
2588
+ if (value === void 0 || value === null) return "-";
2589
+ if (typeof value === "number") {
2590
+ if (!Number.isFinite(value)) return "-";
2591
+ return Number(value.toFixed(4)).toString();
2592
+ }
2593
+ return String(value);
2594
+ }
2595
+ _csvEscape(value) {
2596
+ if (value == null) return "";
2597
+ const str = String(value);
2598
+ if (/[",\n]/.test(str)) {
2599
+ return `"${str.replace(/"/g, '""')}"`;
2600
+ }
2601
+ return str;
2602
+ }
2603
+ _fallbackCopy(text) {
2604
+ try {
2605
+ const textarea = document.createElement("textarea");
2606
+ textarea.value = text;
2607
+ textarea.setAttribute("readonly", "");
2608
+ textarea.style.position = "absolute";
2609
+ textarea.style.left = "-9999px";
2610
+ document.body.appendChild(textarea);
2611
+ textarea.select();
2612
+ const ok = document.execCommand("copy");
2613
+ document.body.removeChild(textarea);
2614
+ return ok;
2615
+ } catch (_) {
2616
+ return false;
2617
+ }
2618
+ }
2619
+ _notify(title, message, type = "info") {
2620
+ this.config.services?.eventBus?.emit?.("toast:show", { title, message, type });
2621
+ }
2622
+ };
2623
+
2624
+ export {
2625
+ createRafResizeObserver,
2626
+ DataTable
2627
+ };
2628
+ //# sourceMappingURL=chunk-WMGOJ3I6.js.map