flexdesk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,2228 @@
1
+ /**
2
+ * data_table.js
3
+ *
4
+ * Unified, reusable data table component with support for:
5
+ * - Pagination (first/prev/next/last/jump to row)
6
+ * - Row selection (single, range with Shift, toggle with Ctrl/Cmd)
7
+ * - Keyboard shortcuts (Ctrl+A, Ctrl+C, Shift+Ctrl+C, Esc)
8
+ * - Context menu (copy as TSV/CSV)
9
+ * - Column sorting (internal or external via callback)
10
+ * - Per-column filtering (numeric operators, text substring)
11
+ * - Column type detection (numeric/text alignment)
12
+ * - Header icons
13
+ * - Read-only mode
14
+ * - CSV download through the host port (native save dialog), with a
15
+ * Blob + <a download> fallback when no host is supplied
16
+ *
17
+ * Used by: DataPage, PlotPopoutWindow, StatisticsTable, Import Wizard
18
+ */
19
+
20
+ import { createRafResizeObserver } from '../utils/raf_resize_observer.js';
21
+
22
+ const DEFAULT_PAGE_SIZE = 100;
23
+
24
+ /**
25
+ * Configuration options for DataTable
26
+ * @typedef {Object} DataTableConfig
27
+ * @property {string[]} headers - Column headers
28
+ * @property {Array<Array>} rows - Row data (array of arrays)
29
+ * @property {number} [pageSize=100] - Rows per page
30
+ * @property {boolean} [pagination=true] - Enable pagination controls
31
+ * @property {boolean} [selectable=true] - Enable row selection
32
+ * @property {boolean} [copyable=true] - Enable copy shortcuts and context menu
33
+ * @property {boolean} [sortable=false] - Enable column sorting
34
+ * @property {boolean} [filterable=false] - Enable per-column filter inputs
35
+ * @property {boolean} [readonly=false] - Read-only mode (no selection/hover effects)
36
+ * @property {boolean} [showRowNumbers=false] - Show row number column
37
+ * @property {string} [emptyMessage='No data'] - Message when no data
38
+ * @property {Function} [onSort] - Callback when sort changes: (column, ascending) => void
39
+ * @property {Function} [onSelectionChange] - Callback when selection changes: (selectedIndices) => void
40
+ * @property {Function} [formatValue] - Custom value formatter: (value, colIndex) => string
41
+ * @property {Function} [getHeaderIcon] - Get icon for header: (header, colIndex) => {icon, title}
42
+ * @property {Function} [getColumnType] - Get column type: (colIndex, rows) => 'num'|'text'
43
+ * @property {Object} [services] - App services { eventBus, logger } for notifications
44
+ * @property {number} [totalCount] - Total row count for server-side pagination (when rows only contains current page)
45
+ * @property {number} [offset=0] - Current offset for server-side pagination
46
+ * @property {Function} [onPageChange] - Callback for server-side pagination: (offset, limit) => void
47
+ * @property {Function} [onJumpToRow] - Callback for jump to row: (rowIndex) => void
48
+ * @property {Function} [renderCell] - Custom cell renderer: (td, value, colIdx, rowIdx, row) => boolean (return true if handled)
49
+ * @property {'normal'|'compact'} [mode='normal'] - Rendering density. 'compact' adds `data-table-component--compact` to the wrapper (tighter padding + smaller font for the bottom-panel use case).
50
+ * @property {Function} [onRowClick] - Row click handler: (rowIdx, row, ev) => void. Receives the original (unfiltered) row index.
51
+ * @property {Function} [onRowContextMenu] - Row right-click handler: (rowIdx, row, ev) => void. Fires before the default context menu; call ev.preventDefault() to suppress the default.
52
+ * @property {Function} [onCellContextMenu] - Cell right-click handler: (colIdx, rowIdx, value, td, ev) => void. Fires before onRowContextMenu; same suppression semantics.
53
+ * @property {boolean} [showExportButton=false] - Adds a "CSV" download button to the pagination strip that invokes `downloadCSV()`.
54
+ * @property {Object} [host] - A Host (see ui/js/host/host.js). Its `dialogs` capability backs `downloadCSV()`'s native save dialog; without it CSV export falls back to a Blob download.
55
+ * @property {Object} [stateStore] - `{ ready(), get(key), set(key, state) }` — REQUIRED when `persistKey` is set. Built by `createTableStateStore()`.
56
+ */
57
+
58
+ export class DataTable {
59
+ /**
60
+ * Create a DataTable instance
61
+ * @param {HTMLElement} container - Container element to render into
62
+ * @param {DataTableConfig} config - Configuration options
63
+ */
64
+ constructor(container, config = {}) {
65
+ this.container = container;
66
+ this.config = {
67
+ headers: [],
68
+ rows: [],
69
+ pageSize: DEFAULT_PAGE_SIZE,
70
+ pagination: true,
71
+ selectable: true,
72
+ copyable: true,
73
+ sortable: false,
74
+ filterable: false,
75
+ readonly: false,
76
+ showRowNumbers: false,
77
+ emptyMessage: 'No data',
78
+ onSort: null,
79
+ onSelectionChange: null,
80
+ formatValue: null,
81
+ getHeaderIcon: null,
82
+ getColumnType: null,
83
+ services: null,
84
+ // Server-side pagination
85
+ totalCount: null,
86
+ offset: 0,
87
+ onPageChange: null,
88
+ onJumpToRow: null,
89
+ // Custom cell rendering
90
+ renderCell: null,
91
+ // Rendering density + bottom-panel hooks (P2)
92
+ mode: 'normal',
93
+ onRowClick: null,
94
+ onRowContextMenu: null,
95
+ onCellContextMenu: null,
96
+ showExportButton: false,
97
+ // The host port. TOP LEVEL, deliberately — NOT inside `services`,
98
+ // which most call sites never pass. Supplies the native save
99
+ // dialog for downloadCSV(); absent => Blob fallback.
100
+ host: null,
101
+ // Opt-in persistence: when set, this table's sort, filters, and
102
+ // column widths survive teardown/reload, keyed by this string
103
+ // inside the injected `stateStore`. Omit it and the table stays
104
+ // ephemeral. WHERE the store persists is the embedder's business.
105
+ persistKey: null,
106
+ stateStore: null,
107
+ ...config,
108
+ };
109
+
110
+ // A persistKey with nowhere to persist is a wiring bug, not a
111
+ // degraded mode. Fail loud at construction: a silent no-op here
112
+ // reads as "my filters randomly stopped sticking" months later.
113
+ if (this.config.persistKey && !this.config.stateStore) {
114
+ throw new Error(`DataTable: persistKey "${this.config.persistKey}" requires { stateStore }`);
115
+ }
116
+
117
+ // State
118
+ this._state = {
119
+ offset: 0,
120
+ selected: new Set(),
121
+ anchorIndex: null,
122
+ sortColumn: null,
123
+ sortAscending: true,
124
+ filters: new Map(),
125
+ };
126
+
127
+ // Column types cache
128
+ this._columnTypes = [];
129
+
130
+ // Processed rows cache (filtered + sorted)
131
+ this._processedRows = null;
132
+ this._processedIndexMap = null;
133
+ this._filterDebounceTimer = null;
134
+
135
+ // DOM references
136
+ this._wrapperEl = null;
137
+ this._paginationEl = null;
138
+ this._tableEl = null;
139
+ this._tbodyEl = null;
140
+ this._contextMenuEl = null;
141
+ this._filterDropdownEl = null;
142
+ this._filterDropdownCleanup = null;
143
+
144
+ // User column-resize overrides, keyed by DOM column index (the
145
+ // row-number column, when shown, is index 0). Persists across
146
+ // re-renders; reset when the header signature changes.
147
+ this._colWidths = {};
148
+ this._colWidthsSig = null;
149
+
150
+ // Event cleanup
151
+ this._disposers = [];
152
+ this._contextMenuGlobals = [];
153
+
154
+ // Persistence: seed from whatever's already cached, then — once
155
+ // the on-disk blob has loaded — re-apply and re-render if state
156
+ // arrived after this table first painted.
157
+ if (this.config.persistKey) {
158
+ const store = this.config.stateStore;
159
+ this._restorePersisted(store.get(this.config.persistKey));
160
+ store.ready().then(() => {
161
+ const late = store.get(this.config.persistKey);
162
+ if (late && this._restorePersisted(late) && this._wrapperEl) {
163
+ this._invalidateProcessedCache?.();
164
+ this.render();
165
+ }
166
+ });
167
+ }
168
+ }
169
+
170
+ /** Apply a persisted blob ({sort, asc, filters, widths}) onto this
171
+ * table's live state. Returns true if anything was applied. */
172
+ _restorePersisted(blob) {
173
+ if (!blob) return false;
174
+ let applied = false;
175
+ if (typeof blob.sortColumn === 'number' || blob.sortColumn === null) {
176
+ this._state.sortColumn = blob.sortColumn;
177
+ this._state.sortAscending = blob.sortAscending !== false;
178
+ applied = true;
179
+ }
180
+ if (Array.isArray(blob.filters)) {
181
+ this._state.filters = new Map(blob.filters);
182
+ applied = true;
183
+ }
184
+ if (blob.colWidths && typeof blob.colWidths === 'object') {
185
+ // Stored under string keys (JSON) → coerce back to ints.
186
+ this._colWidths = {};
187
+ for (const [k, v] of Object.entries(blob.colWidths)) {
188
+ this._colWidths[Number(k)] = v;
189
+ }
190
+ // Stamp the matching signature so the next render keeps these
191
+ // widths instead of treating them as stale.
192
+ this._colWidthsSig = this._colSig();
193
+ applied = true;
194
+ }
195
+ return applied;
196
+ }
197
+
198
+ /** Identity of the current column set — restored widths/sort are
199
+ * keyed by position, so a schema change invalidates them. */
200
+ _colSig() {
201
+ return (this.config.headers || []).join('\x01')
202
+ + (this.config.showRowNumbers ? '|#' : '');
203
+ }
204
+
205
+ /** Snapshot the persistable slice of state to the project store.
206
+ * No-op unless `persistKey` is set. Debounced inside the store. */
207
+ _savePersisted() {
208
+ if (!this.config.persistKey) return;
209
+ this.config.stateStore.set(this.config.persistKey, {
210
+ sortColumn: this._state.sortColumn,
211
+ sortAscending: this._state.sortAscending,
212
+ filters: [...this._state.filters.entries()],
213
+ colWidths: { ...this._colWidths },
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Update data and re-render
219
+ * @param {Object} updates - Partial config updates (headers, rows, etc.)
220
+ */
221
+ setData(updates) {
222
+ // Clear filters if headers changed
223
+ if (updates.headers && this._state.filters.size > 0) {
224
+ const oldHeaders = this.config.headers;
225
+ const newHeaders = updates.headers;
226
+ if (oldHeaders.length !== newHeaders.length ||
227
+ oldHeaders.some((h, i) => h !== newHeaders[i])) {
228
+ this._state.filters.clear();
229
+ }
230
+ }
231
+
232
+ Object.assign(this.config, updates);
233
+
234
+ // Reset state if rows changed
235
+ if (updates.rows) {
236
+ this._state.selected.clear();
237
+ this._state.anchorIndex = null;
238
+ if (this._state.offset >= updates.rows.length) {
239
+ this._state.offset = 0;
240
+ }
241
+ this._columnTypes = this._detectColumnTypes();
242
+ }
243
+
244
+ // Invalidate processed cache
245
+ this._processedRows = null;
246
+ this._processedIndexMap = null;
247
+
248
+ this.render();
249
+ }
250
+
251
+ /**
252
+ * Show / hide a translucent loading overlay over the table body.
253
+ * Used by server-side consumers between firing a fetch and
254
+ * receiving the new rows so the UI doesn't appear frozen.
255
+ * Callers either:
256
+ * - call `setLoading(true)` before their fetch, `setLoading(false)` after, OR
257
+ * - return a Promise from `onPageChange` / `onSort` callbacks
258
+ * — the overlay auto-shows during the await.
259
+ * @param {boolean} on
260
+ */
261
+ setLoading(on) {
262
+ if (!this._wrapperEl) return;
263
+ let overlay = this._wrapperEl.querySelector('.twm-data-table__loading');
264
+ if (on) {
265
+ if (!overlay) {
266
+ overlay = document.createElement('div');
267
+ overlay.className = 'twm-data-table__loading';
268
+ overlay.innerHTML =
269
+ '<div class="twm-data-table__spinner"></div>';
270
+ // Ensure positioning context.
271
+ if (!this._wrapperEl.style.position) {
272
+ this._wrapperEl.style.position = 'relative';
273
+ }
274
+ this._wrapperEl.appendChild(overlay);
275
+ }
276
+ } else if (overlay) {
277
+ overlay.remove();
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Get current selection indices
283
+ * @returns {number[]} Array of selected row indices
284
+ */
285
+ getSelection() {
286
+ return Array.from(this._state.selected).sort((a, b) => a - b);
287
+ }
288
+
289
+ /**
290
+ * Set selection programmatically
291
+ * @param {number[]} indices - Row indices to select
292
+ */
293
+ setSelection(indices) {
294
+ this._state.selected.clear();
295
+ for (const idx of indices) {
296
+ if (idx >= 0 && idx < this.config.rows.length) {
297
+ this._state.selected.add(idx);
298
+ }
299
+ }
300
+ this._updateRowSelection();
301
+ this._notifySelectionChange();
302
+ }
303
+
304
+ /**
305
+ * Clear selection
306
+ */
307
+ clearSelection() {
308
+ this._state.selected.clear();
309
+ this._state.anchorIndex = null;
310
+ this._updateRowSelection();
311
+ this._notifySelectionChange();
312
+ }
313
+
314
+ /**
315
+ * Go to specific page
316
+ * @param {number} page - Page number (0-indexed)
317
+ */
318
+ goToPage(page) {
319
+ const rowCount = this._getProcessedRows().length;
320
+ const maxPage = Math.ceil(rowCount / this.config.pageSize) - 1;
321
+ this._state.offset = Math.max(0, Math.min(page, maxPage)) * this.config.pageSize;
322
+ this.render();
323
+ }
324
+
325
+ /**
326
+ * Go to specific row
327
+ * @param {number} rowIndex - Row index
328
+ */
329
+ goToRow(rowIndex) {
330
+ const rowCount = this._getProcessedRows().length;
331
+ const idx = Math.max(0, Math.min(rowIndex, rowCount - 1));
332
+ this._state.offset = Math.floor(idx / this.config.pageSize) * this.config.pageSize;
333
+ this.render();
334
+ }
335
+
336
+ /**
337
+ * Sort by column
338
+ * @param {number} colIndex - Column index
339
+ * @param {boolean} [ascending] - Sort direction (toggles if same column)
340
+ */
341
+ sortBy(colIndex, ascending) {
342
+ if (this._state.sortColumn === colIndex && ascending === undefined) {
343
+ this._state.sortAscending = !this._state.sortAscending;
344
+ } else {
345
+ this._state.sortColumn = colIndex;
346
+ this._state.sortAscending = ascending ?? true;
347
+ }
348
+
349
+ // Invalidate processed cache
350
+ this._processedRows = null;
351
+ this._processedIndexMap = null;
352
+
353
+ // Auto-spinner: if the sort handler returns a Promise (i.e.
354
+ // it does a server-side refetch), show the loading overlay
355
+ // until it resolves so the user gets visible feedback.
356
+ const ret = this.config.onSort?.(colIndex, this._state.sortAscending);
357
+ this._awaitWithSpinner(ret);
358
+ this._savePersisted();
359
+ this.render();
360
+ }
361
+
362
+ /** Internal: if `maybePromise` is a Promise (or thenable),
363
+ * toggle the loading overlay around it. No-op otherwise. */
364
+ _awaitWithSpinner(maybePromise) {
365
+ if (!maybePromise || typeof maybePromise.then !== 'function') return;
366
+ this.setLoading(true);
367
+ Promise.resolve(maybePromise).finally(() => this.setLoading(false));
368
+ }
369
+
370
+ /**
371
+ * Clear all column filters
372
+ */
373
+ clearFilters() {
374
+ this._state.filters.clear();
375
+ this._processedRows = null;
376
+ this._processedIndexMap = null;
377
+ this._state.offset = 0;
378
+ this._savePersisted();
379
+ this.render();
380
+ }
381
+
382
+ /**
383
+ * Get the number of rows after filtering
384
+ * @returns {number}
385
+ */
386
+ getFilteredRowCount() {
387
+ return this._getProcessedRows().length;
388
+ }
389
+
390
+ /**
391
+ * Render the table
392
+ */
393
+ render() {
394
+ // Capture active filter input before re-render
395
+ const activeFilterColIdx = this._getActiveFilterColIdx();
396
+
397
+ this._cleanup();
398
+ this.container.innerHTML = '';
399
+
400
+ const { rows, pagination, emptyMessage } = this.config;
401
+
402
+ // Drop stale column-resize overrides when the columns change —
403
+ // widths are keyed by position, so a different schema must start
404
+ // from natural widths rather than inherit the old ones. (Restore
405
+ // stamps the matching signature so persisted widths survive the
406
+ // first render.)
407
+ const colSig = this._colSig();
408
+ if (this._colWidthsSig !== colSig) {
409
+ this._colWidths = {};
410
+ this._colWidthsSig = colSig;
411
+ }
412
+
413
+ // Detect column types if not cached
414
+ if (this._columnTypes.length === 0) {
415
+ this._columnTypes = this._detectColumnTypes();
416
+ }
417
+
418
+ // Create wrapper. Compact mode adds a modifier class that the
419
+ // CSS uses to tighten padding + drop font size — opt-in so
420
+ // existing landing/tab use sites stay identical.
421
+ this._wrapperEl = document.createElement('div');
422
+ this._wrapperEl.className = 'twm-data-table-component'
423
+ + (this.config.mode === 'compact' ? ' twm-data-table-component--compact' : '');
424
+ this._wrapperEl.style.cssText = 'display:flex; flex-direction:column; height:100%; min-height:0;';
425
+
426
+ // Pagination (top). `_createPagination` returns null when the
427
+ // strip would be uninformative (single page, no active filter).
428
+ if (pagination && rows.length > 0) {
429
+ this._paginationEl = this._createPagination();
430
+ if (this._paginationEl) this._wrapperEl.appendChild(this._paginationEl);
431
+ }
432
+
433
+ // ── Structural split: thead and tbody live in separate
434
+ // containers so the scrollbar appears only over the body,
435
+ // never over the header / filter row. Column widths are
436
+ // measured from the body after layout and applied to the
437
+ // header via `table-layout: fixed` + explicit cell widths.
438
+ const tableWrap = document.createElement('div');
439
+ tableWrap.className = this.config.readonly
440
+ ? 'twm-preview-table-wrap twm-preview-table-wrap--wizard'
441
+ : 'twm-preview-table-wrap';
442
+ tableWrap.style.cssText = 'flex:1 1 0; min-height:0; min-width:0; overflow:auto;';
443
+
444
+ if (rows.length === 0) {
445
+ // Empty state — render the same chrome as the data table
446
+ // (header row in its own non-scrolling wrap) followed by a
447
+ // single virtual row spanning every column with the empty
448
+ // message in italics. NO filter row: there's nothing to
449
+ // filter, so it'd only mislead the user.
450
+ const { headers, showRowNumbers } = this.config;
451
+ const headerWrap = document.createElement('div');
452
+ headerWrap.className = 'twm-preview-table-header-wrap';
453
+ headerWrap.style.cssText
454
+ = 'flex:0 0 auto; overflow:hidden; min-width:0;';
455
+ const headerTable = document.createElement('table');
456
+ headerTable.className = this.config.readonly
457
+ ? 'twm-preview-table twm-preview-table--readonly'
458
+ : 'twm-preview-table';
459
+ const thead = document.createElement('thead');
460
+ const tr = document.createElement('tr');
461
+ if (showRowNumbers) {
462
+ const th = document.createElement('th');
463
+ th.className = 'num';
464
+ th.textContent = '#';
465
+ tr.appendChild(th);
466
+ }
467
+ headers.forEach((h, colIdx) => {
468
+ const th = document.createElement('th');
469
+ th.className = this._columnTypes[colIdx] || 'text';
470
+ th.textContent = h;
471
+ tr.appendChild(th);
472
+ });
473
+ thead.appendChild(tr);
474
+ headerTable.appendChild(thead);
475
+ headerWrap.appendChild(headerTable);
476
+ this._wrapperEl.appendChild(headerWrap);
477
+ this._headerWrapEl = headerWrap;
478
+ this._headerTableEl = headerTable;
479
+
480
+ // Body: one virtual row across every column with the empty
481
+ // message in italics. Wraps in the standard scroll container
482
+ // so the placeholder sits where data rows would.
483
+ const bodyTable = document.createElement('table');
484
+ bodyTable.className = headerTable.className;
485
+ const tbody = document.createElement('tbody');
486
+ const emptyTr = document.createElement('tr');
487
+ emptyTr.className = 'data-preview-row data-table__empty-row';
488
+ const colCount = headers.length + (showRowNumbers ? 1 : 0);
489
+ const emptyTd = document.createElement('td');
490
+ emptyTd.colSpan = colCount;
491
+ emptyTd.style.cssText
492
+ = 'text-align:center; font-style:italic; color:#888; padding:16px;';
493
+ emptyTd.textContent = emptyMessage;
494
+ emptyTr.appendChild(emptyTd);
495
+ tbody.appendChild(emptyTr);
496
+ bodyTable.appendChild(tbody);
497
+ tableWrap.appendChild(bodyTable);
498
+ this._wrapperEl.appendChild(tableWrap);
499
+ this._tableEl = bodyTable;
500
+ this._tableWrapEl = tableWrap;
501
+ // No column-width sync needed — the body row has colspan=N
502
+ // so there's nothing per-column to align against.
503
+ } else {
504
+ const fullTable = this._createTable();
505
+ this._tableEl = fullTable;
506
+ // Extract thead → header table in its own non-scrolling
507
+ // container. The body table keeps tbody only.
508
+ const thead = fullTable.querySelector('thead');
509
+ if (thead) {
510
+ const headerWrap = document.createElement('div');
511
+ headerWrap.className = 'twm-preview-table-header-wrap';
512
+ headerWrap.style.cssText
513
+ = 'flex:0 0 auto; overflow:hidden; min-width:0;';
514
+ const headerTable = document.createElement('table');
515
+ headerTable.className = fullTable.className;
516
+ headerTable.appendChild(thead);
517
+ headerWrap.appendChild(headerTable);
518
+ this._wrapperEl.appendChild(headerWrap);
519
+ this._headerTableEl = headerTable;
520
+ this._headerWrapEl = headerWrap;
521
+ } else {
522
+ this._headerTableEl = null;
523
+ }
524
+ tableWrap.appendChild(fullTable);
525
+ this._wrapperEl.appendChild(tableWrap);
526
+ }
527
+
528
+ this._tableWrapEl = tableWrap;
529
+ this.container.appendChild(this._wrapperEl);
530
+
531
+ // Sync header column widths to body after layout. Two-pass: the
532
+ // first frame lets the browser settle natural widths from the
533
+ // body's auto layout, then we lock both tables to those widths
534
+ // via table-layout:fixed + explicit cell widths.
535
+ if (this._headerTableEl && this._tableEl) {
536
+ requestAnimationFrame(() => this._syncHeaderWidths());
537
+ // Re-sync on container resize so columns stay aligned when
538
+ // the parent flex / grid layout changes (window resize,
539
+ // splitter drag, etc.).
540
+ if (this._resizeObserver) {
541
+ try { this._resizeObserver.disconnect(); } catch (_) {}
542
+ }
543
+ if (typeof ResizeObserver !== 'undefined') {
544
+ this._resizeObserver = createRafResizeObserver(
545
+ () => this._syncHeaderWidths());
546
+ this._resizeObserver.observe(this._wrapperEl);
547
+ }
548
+ // The header lives in its own non-scrolling wrap (so it can't
549
+ // escape vertically), but that means it also won't follow the
550
+ // body's HORIZONTAL scroll on its own — translate it to match.
551
+ this._installHeaderScrollSync();
552
+ // Drag-to-resize handles on each header cell. Only meaningful
553
+ // when there are body rows to size against.
554
+ if (rows.length > 0) this._installColumnResizers();
555
+ }
556
+
557
+ // Install interactions (readonly only prevents editing, not selection/copy)
558
+ if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly)) {
559
+ this._installInteractions();
560
+ }
561
+
562
+ // Restore focus to filter input if it was active
563
+ if (activeFilterColIdx !== null) {
564
+ this._restoreFilterFocus(activeFilterColIdx);
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Copy selected rows to clipboard
570
+ * @param {'tsv'|'csv'} [format='tsv'] - Output format
571
+ */
572
+ async copyToClipboard(format = 'tsv') {
573
+ const indices = this.getSelection();
574
+ if (indices.length === 0) {
575
+ this._notify('Copy', 'No rows selected.', 'warn');
576
+ return;
577
+ }
578
+
579
+ const { headers, rows } = this.config;
580
+ const matrix = [headers];
581
+
582
+ for (const idx of indices) {
583
+ const row = rows[idx];
584
+ if (row) {
585
+ matrix.push(row.map((val, colIdx) => this._formatValue(val, colIdx)));
586
+ }
587
+ }
588
+
589
+ const separator = format === 'csv' ? ',' : '\t';
590
+ const text = format === 'csv'
591
+ ? matrix.map(row => row.map(cell => this._csvEscape(cell)).join(separator)).join('\n')
592
+ : matrix.map(row => row.join(separator)).join('\n');
593
+
594
+ let ok = false;
595
+ try {
596
+ await navigator.clipboard.writeText(text);
597
+ ok = true;
598
+ } catch (_) {
599
+ ok = this._fallbackCopy(text);
600
+ }
601
+
602
+ if (ok) {
603
+ const desc = indices.length === 1 ? 'row' : 'rows';
604
+ const suffix = format === 'csv' ? ' as CSV' : '';
605
+ this._notify('Copy', `Copied ${indices.length} ${desc}${suffix}.`, 'info');
606
+ } else {
607
+ this._notify('Copy', 'Clipboard unavailable.', 'warn');
608
+ }
609
+
610
+ this._hideContextMenu();
611
+ }
612
+
613
+ /**
614
+ * Download all data as CSV
615
+ * @param {string} [filename='data.csv'] - Filename for download
616
+ */
617
+ async downloadCSV(filename = 'data.csv') {
618
+ const { headers, rows } = this.config;
619
+ if (rows.length === 0) {
620
+ this._notify('Download', 'No data to download.', 'warn');
621
+ return;
622
+ }
623
+
624
+ const lines = [headers.join(',')];
625
+ for (const row of rows) {
626
+ lines.push(row.map((val, colIdx) =>
627
+ this._csvEscape(this._formatValue(val, colIdx))
628
+ ).join(','));
629
+ }
630
+
631
+ const csv = lines.join('\n');
632
+
633
+ // Native save dialog when the embedder supplies one.
634
+ //
635
+ // `saveFile` resolves to null when a host advertises `dialogs` but its
636
+ // transport has no save dialog behind it (host.js: null <=> unavailable).
637
+ // That is NOT a failed save — it means "I can't do this, use your own
638
+ // way", so it must reach the Blob fallback below, not the error toast.
639
+ const dialogs = this.config.host?.dialogs;
640
+ let result = null;
641
+ if (dialogs) {
642
+ try {
643
+ // Send plain text CSV - Python handles text files directly (no base64)
644
+ result = await dialogs.saveFile({ data: csv, filename, kind: 'csv' });
645
+ } catch (err) {
646
+ console.error('[DataTable] CSV download failed:', err);
647
+ this._notify('Download', 'Failed to save file', 'error');
648
+ return;
649
+ }
650
+ }
651
+ if (result) {
652
+ if (result.ok) {
653
+ this._notify('Download', `Saved ${rows.length} rows to ${result.path}`, 'success');
654
+ } else if (!result.cancelled) {
655
+ this._notify('Download', result.error || 'Failed to save file', 'error');
656
+ }
657
+ return;
658
+ }
659
+
660
+ // Fallback: no `dialogs` capability at all, or a host that has one but
661
+ // cannot actually save. Both mean "download it yourself".
662
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
663
+ const url = URL.createObjectURL(blob);
664
+ const link = document.createElement('a');
665
+ link.href = url;
666
+ link.download = filename;
667
+ link.click();
668
+ URL.revokeObjectURL(url);
669
+ this._notify('Download', 'Download started', 'info');
670
+ }
671
+
672
+ /**
673
+ * Dispose and cleanup
674
+ */
675
+ dispose() {
676
+ this._cleanup();
677
+ this._closeFilterDropdown();
678
+ this._teardownContextMenu();
679
+ if (this._resizeObserver) {
680
+ try { this._resizeObserver.disconnect(); } catch (_) {}
681
+ this._resizeObserver = null;
682
+ }
683
+ if (this._onBodyScroll && this._scrollSyncEl) {
684
+ try {
685
+ this._scrollSyncEl.removeEventListener('scroll', this._onBodyScroll);
686
+ } catch (_) {}
687
+ this._onBodyScroll = null;
688
+ this._scrollSyncEl = null;
689
+ }
690
+ this.container.innerHTML = '';
691
+ this._wrapperEl = null;
692
+ this._paginationEl = null;
693
+ this._tableEl = null;
694
+ this._tbodyEl = null;
695
+ this._headerTableEl = null;
696
+ this._headerWrapEl = null;
697
+ this._tableWrapEl = null;
698
+ this._processedRows = null;
699
+ this._processedIndexMap = null;
700
+ }
701
+
702
+ // ─────────────────────────────────────────────────────────────────
703
+ // Processed rows pipeline (filter → sort → cache)
704
+ // ─────────────────────────────────────────────────────────────────
705
+
706
+ _getProcessedRows() {
707
+ if (this._processedRows !== null) return this._processedRows;
708
+
709
+ const isServerSide = typeof this.config.onPageChange === 'function';
710
+ let rows = this.config.rows;
711
+ let indexMap = rows.map((_, i) => i);
712
+
713
+ // Apply filters (client-side only)
714
+ if (!isServerSide && this._state.filters.size > 0) {
715
+ const result = this._applyFilters(rows, indexMap);
716
+ rows = result.rows;
717
+ indexMap = result.indexMap;
718
+ }
719
+
720
+ // Apply sort (client-side, only when no external onSort handler)
721
+ if (!isServerSide && this.config.sortable && this._state.sortColumn !== null && !this.config.onSort) {
722
+ const result = this._applySorting(rows, indexMap);
723
+ rows = result.rows;
724
+ indexMap = result.indexMap;
725
+ }
726
+
727
+ this._processedRows = rows;
728
+ this._processedIndexMap = indexMap;
729
+ return rows;
730
+ }
731
+
732
+ _applyFilters(rows, indexMap) {
733
+ const filters = this._state.filters;
734
+ const filteredRows = [];
735
+ const filteredMap = [];
736
+
737
+ for (let i = 0; i < rows.length; i++) {
738
+ const row = rows[i];
739
+ let pass = true;
740
+
741
+ for (const [colIdx, filterText] of filters) {
742
+ if (!filterText) continue;
743
+ const value = row[colIdx];
744
+ const colType = this._columnTypes[colIdx];
745
+
746
+ if (colType === 'num') {
747
+ if (!this._matchNumericFilter(value, filterText)) { pass = false; break; }
748
+ } else {
749
+ if (!this._matchTextFilter(value, filterText)) { pass = false; break; }
750
+ }
751
+ }
752
+
753
+ if (pass) {
754
+ filteredRows.push(row);
755
+ filteredMap.push(indexMap[i]);
756
+ }
757
+ }
758
+
759
+ return { rows: filteredRows, indexMap: filteredMap };
760
+ }
761
+
762
+ _applySorting(rows, indexMap) {
763
+ const colIdx = this._state.sortColumn;
764
+ const asc = this._state.sortAscending;
765
+
766
+ // Build paired array for stable sort with index tracking
767
+ const paired = rows.map((row, i) => ({ row, origIdx: indexMap[i] }));
768
+ const isNumeric = this._columnTypes[colIdx] === 'num';
769
+
770
+ paired.sort((a, b) => {
771
+ let valA = a.row[colIdx];
772
+ let valB = b.row[colIdx];
773
+
774
+ if (valA == null && valB == null) return 0;
775
+ if (valA == null) return 1;
776
+ if (valB == null) return -1;
777
+
778
+ if (isNumeric) {
779
+ valA = typeof valA === 'number' ? valA : parseFloat(valA);
780
+ valB = typeof valB === 'number' ? valB : parseFloat(valB);
781
+ if (!Number.isFinite(valA)) return 1;
782
+ if (!Number.isFinite(valB)) return -1;
783
+ } else {
784
+ valA = String(valA).toLowerCase();
785
+ valB = String(valB).toLowerCase();
786
+ }
787
+
788
+ let cmp = 0;
789
+ if (valA < valB) cmp = -1;
790
+ else if (valA > valB) cmp = 1;
791
+
792
+ return asc ? cmp : -cmp;
793
+ });
794
+
795
+ return {
796
+ rows: paired.map(p => p.row),
797
+ indexMap: paired.map(p => p.origIdx),
798
+ };
799
+ }
800
+
801
+ _matchNumericFilter(value, filterText) {
802
+ const text = filterText.trim();
803
+ if (!text) return true;
804
+
805
+ const numVal = (typeof value === 'number') ? value : parseFloat(value);
806
+ if (!Number.isFinite(numVal)) return false;
807
+
808
+ // Range: "10..100"
809
+ const rangeMatch = text.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
810
+ if (rangeMatch) {
811
+ const lo = parseFloat(rangeMatch[1]);
812
+ const hi = parseFloat(rangeMatch[2]);
813
+ return numVal >= lo && numVal <= hi;
814
+ }
815
+
816
+ // Operator prefix: >=, <=, !=, >, <, =
817
+ const opMatch = text.match(/^(>=|<=|!=|>|<|=)\s*(-?[\d.]+)$/);
818
+ if (opMatch) {
819
+ const op = opMatch[1];
820
+ const target = parseFloat(opMatch[2]);
821
+ if (!Number.isFinite(target)) return true;
822
+ switch (op) {
823
+ case '>': return numVal > target;
824
+ case '<': return numVal < target;
825
+ case '>=': return numVal >= target;
826
+ case '<=': return numVal <= target;
827
+ case '!=': return Math.abs(numVal - target) > 1e-9;
828
+ case '=': return Math.abs(numVal - target) <= 1e-9;
829
+ }
830
+ }
831
+
832
+ // Plain number: equality
833
+ const plain = parseFloat(text);
834
+ if (Number.isFinite(plain)) {
835
+ return Math.abs(numVal - plain) <= 1e-9;
836
+ }
837
+
838
+ return true;
839
+ }
840
+
841
+ _matchTextFilter(value, filterText) {
842
+ if (!filterText) return true;
843
+ const haystack = (value == null ? '' : String(value)).toLowerCase();
844
+ const text = filterText.trim();
845
+
846
+ // Exact match: ="value"
847
+ if (text.startsWith('="') && text.endsWith('"')) {
848
+ return haystack === text.slice(2, -1).toLowerCase();
849
+ }
850
+ // Not contains: !value
851
+ if (text.startsWith('!')) {
852
+ return !haystack.includes(text.slice(1).toLowerCase());
853
+ }
854
+ // Starts with: ^value
855
+ if (text.startsWith('^')) {
856
+ return haystack.startsWith(text.slice(1).toLowerCase());
857
+ }
858
+ // Ends with: value$
859
+ if (text.endsWith('$')) {
860
+ return haystack.endsWith(text.slice(0, -1).toLowerCase());
861
+ }
862
+ // Contains (default)
863
+ return haystack.includes(text.toLowerCase());
864
+ }
865
+
866
+ _invalidateProcessedCache() {
867
+ this._processedRows = null;
868
+ this._processedIndexMap = null;
869
+ }
870
+
871
+ // ─────────────────────────────────────────────────────────────────
872
+ // Filter row
873
+ // ─────────────────────────────────────────────────────────────────
874
+
875
+ _createFilterRow(headers) {
876
+ const { showRowNumbers } = this.config;
877
+ const tr = document.createElement('tr');
878
+ tr.className = 'twm-data-table__filter-row';
879
+
880
+ if (showRowNumbers) {
881
+ const th = document.createElement('th');
882
+ th.className = 'data-table__filter-cell data-table__filter-cell--empty';
883
+ tr.appendChild(th);
884
+ }
885
+
886
+ headers.forEach((header, colIdx) => {
887
+ const th = document.createElement('th');
888
+ th.className = 'data-table__filter-cell';
889
+
890
+ const wrapper = document.createElement('div');
891
+ wrapper.className = 'twm-data-table__filter-wrapper';
892
+
893
+ const input = document.createElement('input');
894
+ input.type = 'text';
895
+ input.className = 'twm-data-table__filter-input';
896
+ const isNumeric = this._columnTypes[colIdx] === 'num';
897
+ input.placeholder = isNumeric ? 'e.g. >100' : 'Filter...';
898
+
899
+ // Restore existing filter value
900
+ const existingFilter = this._state.filters.get(colIdx);
901
+ if (existingFilter) {
902
+ input.value = existingFilter;
903
+ }
904
+
905
+ // Dropdown trigger button
906
+ const dropdownBtn = document.createElement('button');
907
+ dropdownBtn.type = 'button';
908
+ dropdownBtn.className = 'twm-data-table__filter-dropdown-btn';
909
+ dropdownBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:14px;">tune</span>';
910
+ dropdownBtn.addEventListener('click', (e) => {
911
+ e.stopPropagation();
912
+ this._openFilterDropdown(colIdx, th, input);
913
+ });
914
+
915
+ // Clear button
916
+ const clearBtn = document.createElement('button');
917
+ clearBtn.type = 'button';
918
+ clearBtn.className = 'twm-data-table__filter-clear';
919
+ clearBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:12px;">close</span>';
920
+ clearBtn.style.display = existingFilter ? '' : 'none';
921
+ clearBtn.addEventListener('click', (e) => {
922
+ e.stopPropagation();
923
+ input.value = '';
924
+ this._onFilterInput(colIdx, '');
925
+ clearBtn.style.display = 'none';
926
+ input.focus();
927
+ });
928
+
929
+ input.addEventListener('input', () => {
930
+ clearBtn.style.display = input.value ? '' : 'none';
931
+ this._onFilterInputDebounced(colIdx, input.value);
932
+ });
933
+
934
+ // Prevent sort from triggering when clicking in filter cell
935
+ th.addEventListener('click', (e) => e.stopPropagation());
936
+
937
+ wrapper.appendChild(input);
938
+ wrapper.appendChild(dropdownBtn);
939
+ wrapper.appendChild(clearBtn);
940
+ th.appendChild(wrapper);
941
+ tr.appendChild(th);
942
+ });
943
+
944
+ return tr;
945
+ }
946
+
947
+ // ─────────────────────────────────────────────────────────────────
948
+ // Filter dropdown
949
+ // ─────────────────────────────────────────────────────────────────
950
+
951
+ _openFilterDropdown(colIdx, anchorEl, filterInput) {
952
+ // Close any existing dropdown
953
+ this._closeFilterDropdown();
954
+
955
+ const isNumeric = this._columnTypes[colIdx] === 'num';
956
+ const currentFilter = this._state.filters.get(colIdx) || '';
957
+ const parsed = this._parseFilterForDropdown(currentFilter, isNumeric);
958
+
959
+ // Build dropdown panel
960
+ const panel = document.createElement('div');
961
+ panel.className = 'twm-data-table__filter-dropdown';
962
+
963
+ // Operator/mode select
964
+ const selectLabel = document.createElement('label');
965
+ selectLabel.className = 'twm-data-table__filter-dropdown-label';
966
+ selectLabel.textContent = isNumeric ? 'Operator' : 'Mode';
967
+
968
+ const select = document.createElement('select');
969
+ select.className = 'twm-data-table__filter-dropdown-select';
970
+
971
+ const options = isNumeric
972
+ ? [
973
+ { value: '=', label: 'Equals' },
974
+ { value: '!=', label: 'Not equals' },
975
+ { value: '>', label: 'Greater than' },
976
+ { value: '>=', label: 'Greater or equal' },
977
+ { value: '<', label: 'Less than' },
978
+ { value: '<=', label: 'Less or equal' },
979
+ { value: '..', label: 'Between' },
980
+ ]
981
+ : [
982
+ { value: 'contains', label: 'Contains' },
983
+ { value: 'equals', label: 'Equals' },
984
+ { value: 'starts', label: 'Starts with' },
985
+ { value: 'ends', label: 'Ends with' },
986
+ { value: 'not', label: 'Not contains' },
987
+ ];
988
+
989
+ for (const opt of options) {
990
+ const optEl = document.createElement('option');
991
+ optEl.value = opt.value;
992
+ optEl.textContent = opt.label;
993
+ if (opt.value === parsed.operator) optEl.selected = true;
994
+ select.appendChild(optEl);
995
+ }
996
+
997
+ // Value input
998
+ const valueLabel = document.createElement('label');
999
+ valueLabel.className = 'twm-data-table__filter-dropdown-label';
1000
+ valueLabel.textContent = 'Value';
1001
+
1002
+ const valueInput = document.createElement('input');
1003
+ valueInput.type = isNumeric ? 'number' : 'text';
1004
+ valueInput.className = 'twm-data-table__filter-dropdown-input';
1005
+ valueInput.placeholder = isNumeric ? 'Number...' : 'Text...';
1006
+ valueInput.value = parsed.value;
1007
+
1008
+ // Second value input (for "between")
1009
+ const value2Label = document.createElement('label');
1010
+ value2Label.className = 'twm-data-table__filter-dropdown-label';
1011
+ value2Label.textContent = 'And';
1012
+
1013
+ const value2Input = document.createElement('input');
1014
+ value2Input.type = 'number';
1015
+ value2Input.className = 'twm-data-table__filter-dropdown-input';
1016
+ value2Input.placeholder = 'Number...';
1017
+ value2Input.value = parsed.value2;
1018
+
1019
+ const value2Container = document.createElement('div');
1020
+ value2Container.className = 'twm-data-table__filter-dropdown-between';
1021
+ value2Container.style.display = (isNumeric && parsed.operator === '..') ? '' : 'none';
1022
+ value2Container.appendChild(value2Label);
1023
+ value2Container.appendChild(value2Input);
1024
+
1025
+ // Toggle between fields when operator changes
1026
+ select.addEventListener('change', () => {
1027
+ value2Container.style.display = (isNumeric && select.value === '..') ? '' : 'none';
1028
+ });
1029
+
1030
+ // Actions
1031
+ const actions = document.createElement('div');
1032
+ actions.className = 'twm-data-table__filter-dropdown-actions';
1033
+
1034
+ const clearBtn = document.createElement('button');
1035
+ clearBtn.type = 'button';
1036
+ clearBtn.className = 'twm-data-table__filter-dropdown-btn-action twm-data-table__filter-dropdown-btn-action--clear';
1037
+ clearBtn.textContent = 'Clear';
1038
+ clearBtn.addEventListener('click', (e) => {
1039
+ e.stopPropagation();
1040
+ filterInput.value = '';
1041
+ this._onFilterInput(colIdx, '');
1042
+ this._closeFilterDropdown();
1043
+ });
1044
+
1045
+ const applyBtn = document.createElement('button');
1046
+ applyBtn.type = 'button';
1047
+ applyBtn.className = 'twm-data-table__filter-dropdown-btn-action twm-data-table__filter-dropdown-btn-action--apply';
1048
+ applyBtn.textContent = 'Apply';
1049
+ applyBtn.addEventListener('click', (e) => {
1050
+ e.stopPropagation();
1051
+ const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, isNumeric);
1052
+ filterInput.value = composed;
1053
+ this._onFilterInput(colIdx, composed);
1054
+ this._closeFilterDropdown();
1055
+ });
1056
+
1057
+ actions.appendChild(clearBtn);
1058
+ actions.appendChild(applyBtn);
1059
+
1060
+ // Assemble panel
1061
+ panel.appendChild(selectLabel);
1062
+ panel.appendChild(select);
1063
+ panel.appendChild(valueLabel);
1064
+ panel.appendChild(valueInput);
1065
+ panel.appendChild(value2Container);
1066
+ panel.appendChild(actions);
1067
+
1068
+ // Add to document and position
1069
+ document.body.appendChild(panel);
1070
+
1071
+ const anchorRect = anchorEl.getBoundingClientRect();
1072
+ let left = anchorRect.left;
1073
+ let top = anchorRect.bottom + 4;
1074
+
1075
+ // Viewport boundary check
1076
+ const panelRect = panel.getBoundingClientRect();
1077
+ if (left + panelRect.width > window.innerWidth) {
1078
+ left = window.innerWidth - panelRect.width - 8;
1079
+ }
1080
+ if (top + panelRect.height > window.innerHeight) {
1081
+ top = anchorRect.top - panelRect.height - 4;
1082
+ }
1083
+
1084
+ panel.style.left = `${left}px`;
1085
+ panel.style.top = `${top}px`;
1086
+
1087
+ // Focus the value input
1088
+ requestAnimationFrame(() => valueInput.focus());
1089
+
1090
+ // Close on outside click (capture phase)
1091
+ const handleOutsideClick = (e) => {
1092
+ if (!panel.contains(e.target)) {
1093
+ this._closeFilterDropdown();
1094
+ }
1095
+ };
1096
+ const handleEscape = (e) => {
1097
+ if (e.key === 'Escape') {
1098
+ this._closeFilterDropdown();
1099
+ }
1100
+ };
1101
+ // Enter key applies the filter
1102
+ const handleEnter = (e) => {
1103
+ if (e.key === 'Enter') {
1104
+ e.preventDefault();
1105
+ const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, isNumeric);
1106
+ filterInput.value = composed;
1107
+ this._onFilterInput(colIdx, composed);
1108
+ this._closeFilterDropdown();
1109
+ }
1110
+ };
1111
+
1112
+ // Delay attaching outside-click to avoid catching the trigger click
1113
+ setTimeout(() => {
1114
+ document.addEventListener('click', handleOutsideClick, true);
1115
+ }, 0);
1116
+ document.addEventListener('keydown', handleEscape);
1117
+ panel.addEventListener('keydown', handleEnter);
1118
+
1119
+ this._filterDropdownEl = panel;
1120
+ this._filterDropdownCleanup = () => {
1121
+ document.removeEventListener('click', handleOutsideClick, true);
1122
+ document.removeEventListener('keydown', handleEscape);
1123
+ };
1124
+ }
1125
+
1126
+ _closeFilterDropdown() {
1127
+ if (this._filterDropdownEl?.parentNode) {
1128
+ this._filterDropdownEl.parentNode.removeChild(this._filterDropdownEl);
1129
+ }
1130
+ this._filterDropdownCleanup?.();
1131
+ this._filterDropdownEl = null;
1132
+ this._filterDropdownCleanup = null;
1133
+ }
1134
+
1135
+ _parseFilterForDropdown(filterText, isNumeric) {
1136
+ const text = (filterText || '').trim();
1137
+ if (!text) {
1138
+ return { operator: isNumeric ? '=' : 'contains', value: '', value2: '' };
1139
+ }
1140
+
1141
+ if (isNumeric) {
1142
+ // Range: "10..100"
1143
+ const rangeMatch = text.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
1144
+ if (rangeMatch) {
1145
+ return { operator: '..', value: rangeMatch[1], value2: rangeMatch[2] };
1146
+ }
1147
+ // Operator prefix: >=, <=, !=, >, <, =
1148
+ const opMatch = text.match(/^(>=|<=|!=|>|<|=)\s*(-?[\d.]+)$/);
1149
+ if (opMatch) {
1150
+ return { operator: opMatch[1], value: opMatch[2], value2: '' };
1151
+ }
1152
+ // Plain number
1153
+ return { operator: '=', value: text, value2: '' };
1154
+ }
1155
+
1156
+ // Text modes
1157
+ if (text.startsWith('="') && text.endsWith('"')) {
1158
+ return { operator: 'equals', value: text.slice(2, -1), value2: '' };
1159
+ }
1160
+ if (text.startsWith('!')) {
1161
+ return { operator: 'not', value: text.slice(1), value2: '' };
1162
+ }
1163
+ if (text.startsWith('^')) {
1164
+ return { operator: 'starts', value: text.slice(1), value2: '' };
1165
+ }
1166
+ if (text.endsWith('$')) {
1167
+ return { operator: 'ends', value: text.slice(0, -1), value2: '' };
1168
+ }
1169
+ return { operator: 'contains', value: text, value2: '' };
1170
+ }
1171
+
1172
+ _composeFilterString(operator, value, value2, isNumeric) {
1173
+ if (!value && operator !== '..') return '';
1174
+
1175
+ if (isNumeric) {
1176
+ if (operator === '..') {
1177
+ return (value && value2) ? `${value}..${value2}` : '';
1178
+ }
1179
+ if (operator === '=') return value;
1180
+ return `${operator}${value}`;
1181
+ }
1182
+
1183
+ // Text modes
1184
+ switch (operator) {
1185
+ case 'contains': return value;
1186
+ case 'equals': return value ? `="${value}"` : '';
1187
+ case 'starts': return value ? `^${value}` : '';
1188
+ case 'ends': return value ? `${value}$` : '';
1189
+ case 'not': return value ? `!${value}` : '';
1190
+ default: return value;
1191
+ }
1192
+ }
1193
+
1194
+ _onFilterInputDebounced(colIdx, value) {
1195
+ if (this._filterDebounceTimer) {
1196
+ clearTimeout(this._filterDebounceTimer);
1197
+ }
1198
+ this._filterDebounceTimer = setTimeout(() => {
1199
+ this._onFilterInput(colIdx, value);
1200
+ }, 200);
1201
+ }
1202
+
1203
+ _onFilterInput(colIdx, value) {
1204
+ if (value) {
1205
+ this._state.filters.set(colIdx, value);
1206
+ } else {
1207
+ this._state.filters.delete(colIdx);
1208
+ }
1209
+
1210
+ this._invalidateProcessedCache();
1211
+ this._state.offset = 0;
1212
+ this._state.selected.clear();
1213
+ this._state.anchorIndex = null;
1214
+
1215
+ this._savePersisted();
1216
+ this.render();
1217
+ }
1218
+
1219
+ _getActiveFilterColIdx() {
1220
+ const active = document.activeElement;
1221
+ if (!active || !active.classList.contains('twm-data-table__filter-input')) return null;
1222
+ const cell = active.closest('.data-table__filter-cell');
1223
+ if (!cell) return null;
1224
+ const row = cell.parentElement;
1225
+ if (!row) return null;
1226
+ const cells = Array.from(row.children);
1227
+ const idx = cells.indexOf(cell);
1228
+ return this.config.showRowNumbers ? idx - 1 : idx;
1229
+ }
1230
+
1231
+ _restoreFilterFocus(colIdx) {
1232
+ const filterRow = this._wrapperEl?.querySelector('.twm-data-table__filter-row');
1233
+ if (!filterRow) return;
1234
+ const cellIdx = this.config.showRowNumbers ? colIdx + 1 : colIdx;
1235
+ const cell = filterRow.children[cellIdx];
1236
+ const input = cell?.querySelector('.twm-data-table__filter-input');
1237
+ if (input) {
1238
+ input.focus();
1239
+ input.setSelectionRange(input.value.length, input.value.length);
1240
+ }
1241
+ }
1242
+
1243
+ // ─────────────────────────────────────────────────────────────────
1244
+ // Private methods
1245
+ // ─────────────────────────────────────────────────────────────────
1246
+
1247
+ _cleanup() {
1248
+ this._disposers.forEach(dispose => {
1249
+ try { dispose?.(); } catch (_) {}
1250
+ });
1251
+ this._disposers = [];
1252
+ this._closeFilterDropdown();
1253
+ if (this._filterDebounceTimer) {
1254
+ clearTimeout(this._filterDebounceTimer);
1255
+ this._filterDebounceTimer = null;
1256
+ }
1257
+ }
1258
+
1259
+ _detectColumnTypes() {
1260
+ const { headers, rows, getColumnType } = this.config;
1261
+ const types = [];
1262
+
1263
+ for (let colIdx = 0; colIdx < headers.length; colIdx++) {
1264
+ if (getColumnType) {
1265
+ types.push(getColumnType(colIdx, rows));
1266
+ continue;
1267
+ }
1268
+
1269
+ // Auto-detect by sampling first 10 rows
1270
+ let numericCount = 0;
1271
+ let sampleCount = 0;
1272
+ const maxSamples = Math.min(10, rows.length);
1273
+
1274
+ for (let i = 0; i < maxSamples; i++) {
1275
+ const value = rows[i]?.[colIdx];
1276
+ if (value != null && value !== '') {
1277
+ sampleCount++;
1278
+ if (typeof value === 'number' || /^-?[\d,.]+%?$/.test(String(value).trim())) {
1279
+ numericCount++;
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ types.push(sampleCount > 0 && numericCount / sampleCount > 0.5 ? 'num' : 'text');
1285
+ }
1286
+
1287
+ return types;
1288
+ }
1289
+
1290
+ _createPagination() {
1291
+ const { rows, pageSize, totalCount: configTotalCount, offset: configOffset, onPageChange, onJumpToRow } = this.config;
1292
+
1293
+ // Server-side pagination uses config offset, client-side uses state offset
1294
+ const isServerSide = typeof onPageChange === 'function';
1295
+ const offset = isServerSide ? (configOffset || 0) : this._state.offset;
1296
+
1297
+ // For client-side, use processed (filtered+sorted) row count
1298
+ const processedRows = isServerSide ? rows : this._getProcessedRows();
1299
+ const effectiveTotal = isServerSide ? (configTotalCount || rows.length) : processedRows.length;
1300
+ const unfilteredTotal = isServerSide ? (configTotalCount || rows.length) : rows.length;
1301
+ const isFiltered = !isServerSide && this._state.filters.size > 0;
1302
+
1303
+ const displayedRows = isServerSide ? rows.length : Math.min(pageSize, effectiveTotal - offset);
1304
+ const endRow = Math.min(offset + displayedRows, effectiveTotal);
1305
+ const totalPages = Math.max(1, Math.ceil(effectiveTotal / pageSize));
1306
+ // Don't render the strip when there's only one page — the row
1307
+ // count is uninformative (the table itself shows every row) and
1308
+ // the nav buttons would all be disabled.
1309
+ if (totalPages <= 1 && !isFiltered) return null;
1310
+ const currentPage = Math.floor(offset / pageSize) + 1;
1311
+ const hasPrev = offset > 0;
1312
+ const hasNext = offset + pageSize < effectiveTotal;
1313
+
1314
+ const el = document.createElement('div');
1315
+ el.className = 'twm-pagination-controls';
1316
+ el.style.cssText = 'display:flex; align-items:center; gap:6px; padding:2px 8px; border-bottom:1px solid #2a2a2a; font-size:11px;';
1317
+
1318
+ // Info
1319
+ const info = document.createElement('span');
1320
+ info.style.cssText = 'color:#aaa; white-space:nowrap;';
1321
+ if (effectiveTotal === 0) {
1322
+ info.textContent = isFiltered
1323
+ ? `0 of ${unfilteredTotal.toLocaleString()} rows match`
1324
+ : 'No rows';
1325
+ } else if (isFiltered) {
1326
+ info.textContent = `Rows ${offset + 1}\u2013${endRow} of ${effectiveTotal.toLocaleString()} (${unfilteredTotal.toLocaleString()} total)`;
1327
+ } else {
1328
+ info.textContent = `Rows ${offset + 1}\u2013${endRow} of ${effectiveTotal.toLocaleString()}`;
1329
+ }
1330
+
1331
+ // Spacer
1332
+ const spacer = document.createElement('div');
1333
+ spacer.style.flex = '1';
1334
+
1335
+ // Page change handler - supports both client-side and server-side pagination
1336
+ const handlePageChange = (newOffset) => {
1337
+ if (isServerSide) {
1338
+ // Auto-spinner if the consumer returns a Promise.
1339
+ this._awaitWithSpinner(onPageChange(newOffset, pageSize));
1340
+ } else {
1341
+ this._state.offset = newOffset;
1342
+ this.render();
1343
+ }
1344
+ };
1345
+
1346
+ // Navigation buttons
1347
+ const firstBtn = this._createPaginationBtn('first_page', 'First page', !hasPrev, () => {
1348
+ handlePageChange(0);
1349
+ });
1350
+
1351
+ const prevBtn = this._createPaginationBtn('chevron_left', 'Previous page', !hasPrev, () => {
1352
+ handlePageChange(Math.max(0, offset - pageSize));
1353
+ });
1354
+
1355
+ const pageInfo = document.createElement('span');
1356
+ pageInfo.style.cssText = 'color:#aaa; font-size:10px; min-width:72px; text-align:center;';
1357
+ pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
1358
+
1359
+ const nextBtn = this._createPaginationBtn('chevron_right', 'Next page', !hasNext, () => {
1360
+ handlePageChange(offset + pageSize);
1361
+ });
1362
+
1363
+ const lastBtn = this._createPaginationBtn('last_page', 'Last page', !hasNext, () => {
1364
+ handlePageChange(Math.floor((effectiveTotal - 1) / pageSize) * pageSize);
1365
+ });
1366
+
1367
+ // Jump to row
1368
+ const jumpInput = document.createElement('input');
1369
+ jumpInput.type = 'number';
1370
+ jumpInput.className = 'twm-pagination-input';
1371
+ jumpInput.style.cssText = 'width:54px; padding:1px 4px; border:1px solid #444; background:#2a2a2a; color:#ccc; font-size:10px;';
1372
+ jumpInput.min = '1';
1373
+ jumpInput.max = String(effectiveTotal);
1374
+ jumpInput.placeholder = 'Row #';
1375
+
1376
+ const jumpBtn = this._createPaginationBtn(null, 'Go to row', false, () => {
1377
+ const target = Number(jumpInput.value);
1378
+ if (Number.isFinite(target) && target >= 1) {
1379
+ if (isServerSide && onJumpToRow) {
1380
+ onJumpToRow(target - 1);
1381
+ } else if (isServerSide) {
1382
+ // Default: calculate page offset for the target row
1383
+ const newOffset = Math.floor((target - 1) / pageSize) * pageSize;
1384
+ handlePageChange(newOffset);
1385
+ } else {
1386
+ this.goToRow(target - 1);
1387
+ }
1388
+ }
1389
+ }, 'Go');
1390
+
1391
+ el.appendChild(info);
1392
+ el.appendChild(spacer);
1393
+ el.appendChild(firstBtn);
1394
+ el.appendChild(prevBtn);
1395
+ el.appendChild(pageInfo);
1396
+ el.appendChild(nextBtn);
1397
+ el.appendChild(lastBtn);
1398
+ el.appendChild(jumpInput);
1399
+ el.appendChild(jumpBtn);
1400
+
1401
+ // CSV export button (opt-in). Re-uses the existing downloadCSV
1402
+ // method which already handles the host save dialog + browser
1403
+ // fallback.
1404
+ if (this.config.showExportButton) {
1405
+ const exportBtn = this._createPaginationBtn(
1406
+ 'download', 'Download visible rows as CSV', false,
1407
+ () => this.downloadCSV('data.csv'));
1408
+ el.appendChild(exportBtn);
1409
+ }
1410
+
1411
+ return el;
1412
+ }
1413
+
1414
+ _createPaginationBtn(icon, tooltip, disabled, onClick, textLabel = null) {
1415
+ const btn = document.createElement('button');
1416
+ btn.type = 'button';
1417
+ btn.className = 'hoverbutton twm-pagination-btn twm-has-tooltip';
1418
+ btn.setAttribute('data-tooltip', tooltip);
1419
+ btn.disabled = disabled;
1420
+ 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;';
1421
+
1422
+ if (disabled) {
1423
+ btn.style.opacity = '0.4';
1424
+ btn.style.cursor = 'not-allowed';
1425
+ }
1426
+
1427
+ if (icon) {
1428
+ const iconEl = document.createElement('span');
1429
+ iconEl.className = 'material-symbols-outlined';
1430
+ iconEl.style.fontSize = '14px';
1431
+ iconEl.textContent = icon;
1432
+ btn.appendChild(iconEl);
1433
+ } else if (textLabel) {
1434
+ btn.textContent = textLabel;
1435
+ btn.style.fontSize = '10px';
1436
+ btn.style.padding = '1px 6px';
1437
+ }
1438
+
1439
+ btn.addEventListener('click', onClick);
1440
+ return btn;
1441
+ }
1442
+
1443
+ _createTable() {
1444
+ const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
1445
+ const { selected, sortColumn, sortAscending } = this._state;
1446
+
1447
+ // Server-side pagination: rows already represent current page, use config offset for row numbering
1448
+ // Client-side pagination: use processed (filtered+sorted) rows, then slice for current page
1449
+ const isServerSide = typeof onPageChange === 'function';
1450
+ const processedRows = isServerSide ? rows : this._getProcessedRows();
1451
+ const offset = isServerSide ? (configOffset || 0) : this._state.offset;
1452
+ const pageRows = isServerSide ? processedRows : processedRows.slice(offset, offset + pageSize);
1453
+
1454
+ const table = document.createElement('table');
1455
+ table.className = readonly ? 'twm-preview-table twm-preview-table--readonly' : 'twm-preview-table';
1456
+ table.tabIndex = 0;
1457
+
1458
+ // Header
1459
+ const thead = document.createElement('thead');
1460
+ const headerRow = document.createElement('tr');
1461
+
1462
+ if (showRowNumbers) {
1463
+ const th = document.createElement('th');
1464
+ th.className = 'num';
1465
+ th.textContent = '#';
1466
+ headerRow.appendChild(th);
1467
+ }
1468
+
1469
+ headers.forEach((header, colIdx) => {
1470
+ const th = document.createElement('th');
1471
+ th.className = this._columnTypes[colIdx] || 'text';
1472
+
1473
+ if (sortable) {
1474
+ th.classList.add('sortable');
1475
+ th.style.cursor = 'pointer';
1476
+ }
1477
+
1478
+ // Header icon
1479
+ if (getHeaderIcon) {
1480
+ const iconInfo = getHeaderIcon(header, colIdx);
1481
+ if (iconInfo) {
1482
+ const iconSpan = document.createElement('span');
1483
+ iconSpan.className = 'material-symbols-outlined twm-header-icon twm-has-tooltip';
1484
+ iconSpan.setAttribute('data-tooltip', iconInfo.title || '');
1485
+ iconSpan.style.cssText = 'font-size:16px; vertical-align:middle; margin-right:4px; opacity:0.7;';
1486
+ iconSpan.textContent = iconInfo.icon;
1487
+ th.appendChild(iconSpan);
1488
+ }
1489
+ }
1490
+
1491
+ th.appendChild(document.createTextNode(header));
1492
+
1493
+ // Sort indicator
1494
+ if (sortable) {
1495
+ const sortIcon = document.createElement('span');
1496
+ sortIcon.className = 'material-symbols-outlined twm-sort-icon';
1497
+ sortIcon.style.cssText = 'font-size:14px; margin-left:4px; opacity:0.5;';
1498
+ if (sortColumn === colIdx) {
1499
+ sortIcon.textContent = sortAscending ? 'arrow_upward' : 'arrow_downward';
1500
+ sortIcon.style.opacity = '1';
1501
+ } else {
1502
+ sortIcon.textContent = 'unfold_more';
1503
+ }
1504
+ th.appendChild(sortIcon);
1505
+
1506
+ th.addEventListener('click', () => this.sortBy(colIdx));
1507
+ }
1508
+
1509
+ th.title = header;
1510
+ headerRow.appendChild(th);
1511
+ });
1512
+
1513
+ thead.appendChild(headerRow);
1514
+
1515
+ // Filter row (below header)
1516
+ if (filterable) {
1517
+ const filterRow = this._createFilterRow(headers);
1518
+ thead.appendChild(filterRow);
1519
+ }
1520
+
1521
+ table.appendChild(thead);
1522
+
1523
+ // Body
1524
+ const tbody = document.createElement('tbody');
1525
+ pageRows.forEach((row, localIdx) => {
1526
+ const processedIdx = offset + localIdx;
1527
+ // Map back to original config.rows index for selection tracking
1528
+ const globalIdx = isServerSide
1529
+ ? (configOffset || 0) + localIdx
1530
+ : (this._processedIndexMap?.[processedIdx] ?? processedIdx);
1531
+ const tr = document.createElement('tr');
1532
+ tr.__rowIndex = globalIdx;
1533
+ tr.className = 'data-preview-row';
1534
+
1535
+ if (selected.has(globalIdx)) {
1536
+ tr.classList.add('selected');
1537
+ }
1538
+
1539
+ if (showRowNumbers) {
1540
+ const td = document.createElement('td');
1541
+ td.className = 'num';
1542
+ td.textContent = String(globalIdx + 1);
1543
+ tr.appendChild(td);
1544
+ }
1545
+
1546
+ for (let colIdx = 0; colIdx < row.length; colIdx++) {
1547
+ const td = document.createElement('td');
1548
+ td.className = this._columnTypes[colIdx] || 'text';
1549
+
1550
+ // Use custom cell renderer if provided
1551
+ const value = row[colIdx];
1552
+ if (this.config.renderCell) {
1553
+ const handled = this.config.renderCell(td, value, colIdx, globalIdx, row);
1554
+ if (!handled) {
1555
+ td.textContent = this._formatValue(value, colIdx);
1556
+ }
1557
+ } else {
1558
+ td.textContent = this._formatValue(value, colIdx);
1559
+ }
1560
+
1561
+ // Cell-level right-click hook (P2). Fires before the
1562
+ // row-level hook and the built-in context menu — the
1563
+ // caller can ev.preventDefault() to suppress the
1564
+ // default copy menu on this cell only.
1565
+ if (this.config.onCellContextMenu) {
1566
+ const cellColIdx = colIdx;
1567
+ td.addEventListener('contextmenu', (ev) => {
1568
+ this.config.onCellContextMenu(
1569
+ cellColIdx, globalIdx, value, td, ev);
1570
+ });
1571
+ }
1572
+
1573
+ tr.appendChild(td);
1574
+ }
1575
+
1576
+ // Row-level click + right-click hooks (P2). Bound after
1577
+ // cells so per-cell handlers run first.
1578
+ if (this.config.onRowClick) {
1579
+ tr.addEventListener('click', (ev) => {
1580
+ this.config.onRowClick(globalIdx, row, ev);
1581
+ });
1582
+ }
1583
+ if (this.config.onRowContextMenu) {
1584
+ tr.addEventListener('contextmenu', (ev) => {
1585
+ this.config.onRowContextMenu(globalIdx, row, ev);
1586
+ });
1587
+ }
1588
+
1589
+ tbody.appendChild(tr);
1590
+ });
1591
+
1592
+ table.appendChild(tbody);
1593
+ this._tbodyEl = tbody;
1594
+
1595
+ return table;
1596
+ }
1597
+
1598
+ /** Sync the (separate) header table's column widths to the body
1599
+ * table's measured widths. Without this, the two tables compute
1600
+ * widths independently and the columns drift apart. Locks both
1601
+ * tables to `table-layout: fixed` and writes explicit width onto
1602
+ * each header cell of every header row (header + filter row). */
1603
+ _syncHeaderWidths() {
1604
+ const headerTable = this._headerTableEl;
1605
+ const bodyTable = this._tableEl;
1606
+ if (!headerTable || !bodyTable) return;
1607
+ const firstRow = bodyTable.querySelector('tbody > tr');
1608
+ if (!firstRow) return;
1609
+ const bodyCells = firstRow.children;
1610
+ if (!bodyCells.length) return;
1611
+
1612
+ const headerRows = headerTable.querySelectorAll('thead > tr');
1613
+
1614
+ // ── Measure pass: let every column expand to its NATURAL content
1615
+ // width, ignoring the CSS caps (the 150px-pinned first column, the
1616
+ // 80px min on the rest) and the filter-row inputs. `dt-measuring`
1617
+ // flips both tables to `table-layout:auto; width:max-content` with
1618
+ // those caps off (via `!important`) for a single reflow; we read
1619
+ // the widths, then revert. Clearing inline widths first stops the
1620
+ // last sync's forced widths from constraining the measure.
1621
+ headerRows.forEach((tr) => {
1622
+ for (const th of tr.children) this._clearCellWidth(th);
1623
+ });
1624
+ for (const td of bodyCells) this._clearCellWidth(td);
1625
+ headerTable.style.width = '';
1626
+ bodyTable.style.width = '';
1627
+ headerTable.classList.add('twm-dt-measuring');
1628
+ bodyTable.classList.add('twm-dt-measuring');
1629
+ // Force layout flush so measurements are current.
1630
+ // eslint-disable-next-line no-unused-expressions
1631
+ bodyTable.offsetWidth;
1632
+
1633
+ // Natural width per column = the wider of its header label and its
1634
+ // body content. `max-content` already spans every rendered body
1635
+ // row, so the body cell of the first row reports the whole column.
1636
+ const labelRow = headerTable.querySelector('thead > tr');
1637
+ const cols = bodyCells.length;
1638
+ const natural = new Array(cols);
1639
+ for (let i = 0; i < cols; i++) {
1640
+ const body = bodyCells[i].getBoundingClientRect().width;
1641
+ const head = labelRow && labelRow.children[i]
1642
+ ? labelRow.children[i].getBoundingClientRect().width
1643
+ : 0;
1644
+ natural[i] = Math.max(body, head);
1645
+ }
1646
+
1647
+ headerTable.classList.remove('twm-dt-measuring');
1648
+ bodyTable.classList.remove('twm-dt-measuring');
1649
+
1650
+ // ── Fit pass: turn natural widths into final widths that respect
1651
+ // the available space, favour the first column, and cap runaways.
1652
+ // User-dragged columns (`_colWidths`) are honored as-is inside.
1653
+ const wrap = this._tableWrapEl;
1654
+ const headerWrap = this._headerWrapEl;
1655
+ const avail = wrap ? wrap.clientWidth : 0;
1656
+ const firstIdx = this.config.showRowNumbers && cols > 1 ? 1 : 0;
1657
+ const widths = this._fitColumnWidths(natural, avail, {
1658
+ firstIdx,
1659
+ overrides: this._colWidths,
1660
+ });
1661
+ const total = widths.reduce((a, b) => a + b, 0);
1662
+
1663
+ // Apply measured widths to every header row at the matching
1664
+ // column index AND the body's first row, so both tables compute
1665
+ // the SAME column track. `_setCellWidth` also clears any CSS
1666
+ // min/max-width (e.g. the 150px-pinned first "time" column) so
1667
+ // the explicit width is actually honored.
1668
+ headerRows.forEach((tr) => {
1669
+ for (let i = 0; i < tr.children.length && i < widths.length; i++) {
1670
+ this._setCellWidth(tr.children[i], widths[i]);
1671
+ }
1672
+ });
1673
+ for (let i = 0; i < bodyCells.length && i < widths.length; i++) {
1674
+ this._setCellWidth(bodyCells[i], widths[i]);
1675
+ }
1676
+
1677
+ // Lock both tables so the widths stick even when content changes.
1678
+ headerTable.style.tableLayout = 'fixed';
1679
+ bodyTable.style.tableLayout = 'fixed';
1680
+
1681
+ // Size both tables to the explicit column TOTAL (not the CSS
1682
+ // `width: 100%`) so a widened column GROWS the table and the
1683
+ // body wrap scrolls horizontally, instead of squeezing into a
1684
+ // fixed budget and stealing from its neighbours. When the
1685
+ // columns fit and the user hasn't dragged, stay at 100% to fill
1686
+ // the container exactly with no phantom scrollbar.
1687
+ const hasOverride = Object.keys(this._colWidths).length > 0;
1688
+ if (hasOverride || total > avail + 1) {
1689
+ headerTable.style.width = `${total}px`;
1690
+ bodyTable.style.width = `${total}px`;
1691
+ } else {
1692
+ headerTable.style.width = '';
1693
+ bodyTable.style.width = '';
1694
+ }
1695
+
1696
+ // Scrollbar gutter: when the body shows a vertical scrollbar, its
1697
+ // content is narrower than the wrap by `scrollbarW`. The header
1698
+ // wrap has no scrollbar, so pad it on the right to match.
1699
+ if (wrap && headerWrap) {
1700
+ const sbw = Math.max(0, wrap.offsetWidth - wrap.clientWidth);
1701
+ headerWrap.style.paddingRight = sbw ? `${sbw}px` : '';
1702
+ }
1703
+
1704
+ // Keep the header aligned with the body's current horizontal
1705
+ // scroll (a resize can clamp scrollLeft).
1706
+ this._syncHeaderScroll();
1707
+
1708
+ // Columns are now fixed-width: any cell whose text was clipped
1709
+ // gets a hover tooltip carrying the full value.
1710
+ this._updateCellTooltips();
1711
+ }
1712
+
1713
+ /**
1714
+ * Convert measured natural content widths into final column widths.
1715
+ *
1716
+ * Goals (the "intelligent" sizing):
1717
+ * - every column wants its content width (+a hair), clamped to a
1718
+ * sane [floor, cap]; the FIRST content column gets a more generous
1719
+ * cap so it shows its full value;
1720
+ * - user-dragged columns (`overrides`) are pinned, never grown/shrunk;
1721
+ * - if everything fits, grow the flexible columns evenly to fill the
1722
+ * width (no dead gap on the right);
1723
+ * - if it doesn't fit, protect the first column and water-fill-shrink
1724
+ * the rest (trim the widest first) until it fits; only if even the
1725
+ * floors overflow do we give up and let the body scroll sideways.
1726
+ *
1727
+ * @param {number[]} natural measured content width per column (px)
1728
+ * @param {number} avail usable width of the body wrap (px)
1729
+ * @param {{firstIdx?:number, overrides?:Object}} [opts]
1730
+ * @returns {number[]} final width per column (px)
1731
+ */
1732
+ _fitColumnWidths(natural, avail, opts = {}) {
1733
+ const FLOOR = 40; // matches the drag-resize minimum
1734
+ const CAP = 360; // general per-column ceiling
1735
+ const FIRST_CAP = 520; // the first column may run wider
1736
+ const PAD = 2; // sub-pixel safety against ellipsis
1737
+
1738
+ const n = natural.length;
1739
+ const firstIdx = opts.firstIdx ?? 0;
1740
+ const overrides = opts.overrides || {};
1741
+
1742
+ // Desired (pre-fit) width per column. Pinned columns take their
1743
+ // override verbatim and sit out the grow/shrink redistribution.
1744
+ const desired = new Array(n);
1745
+ const pinned = new Array(n).fill(false);
1746
+ for (let i = 0; i < n; i++) {
1747
+ if (overrides[i] != null) {
1748
+ desired[i] = Math.max(FLOOR, Math.round(overrides[i]));
1749
+ pinned[i] = true;
1750
+ continue;
1751
+ }
1752
+ const cap = i === firstIdx ? FIRST_CAP : CAP;
1753
+ desired[i] = Math.min(cap, Math.max(FLOOR, Math.ceil(natural[i] + PAD)));
1754
+ }
1755
+
1756
+ const widths = desired.slice();
1757
+ const sum = widths.reduce((a, b) => a + b, 0);
1758
+
1759
+ // Not laid out yet (or content already exactly fits): hand back the
1760
+ // desired widths; the caller decides fill vs horizontal scroll.
1761
+ if (avail <= 1) return widths;
1762
+
1763
+ if (sum <= avail) {
1764
+ // Grow flexible columns evenly to fill the remaining space so
1765
+ // the table doesn't leave a dead gap on the right.
1766
+ const flex = [];
1767
+ for (let i = 0; i < n; i++) if (!pinned[i]) flex.push(i);
1768
+ const slack = avail - sum;
1769
+ if (slack > 0 && flex.length) {
1770
+ const per = Math.floor(slack / flex.length);
1771
+ for (const i of flex) widths[i] += per;
1772
+ widths[flex[flex.length - 1]] += slack - per * flex.length;
1773
+ }
1774
+ return widths;
1775
+ }
1776
+
1777
+ // Overflow: protect the first column + pinned columns, water-fill
1778
+ // the rest down toward the floor.
1779
+ let protectedSum = 0;
1780
+ const shrinkable = [];
1781
+ for (let i = 0; i < n; i++) {
1782
+ if (pinned[i] || i === firstIdx) protectedSum += widths[i];
1783
+ else shrinkable.push(i);
1784
+ }
1785
+ const budget = avail - protectedSum;
1786
+ if (budget < shrinkable.length * FLOOR) {
1787
+ // Even at the floor we overflow → let the body scroll sideways.
1788
+ for (const i of shrinkable) widths[i] = FLOOR;
1789
+ return widths;
1790
+ }
1791
+ // Max-min fair allocation: the narrow columns keep their content,
1792
+ // the widest share the remaining budget equally.
1793
+ shrinkable.sort((a, b) => desired[a] - desired[b]);
1794
+ let remaining = budget;
1795
+ for (let k = 0; k < shrinkable.length; k++) {
1796
+ const colsLeft = shrinkable.length - k;
1797
+ const fair = Math.floor(remaining / colsLeft);
1798
+ const i = shrinkable[k];
1799
+ if (desired[i] <= fair) {
1800
+ widths[i] = desired[i];
1801
+ remaining -= desired[i];
1802
+ } else {
1803
+ const level = Math.max(FLOOR, fair);
1804
+ for (let j = k; j < shrinkable.length; j++) {
1805
+ widths[shrinkable[j]] = level;
1806
+ }
1807
+ // Dump any rounding remainder onto the widest column.
1808
+ const leftover = remaining - colsLeft * level;
1809
+ if (leftover > 0) widths[shrinkable[shrinkable.length - 1]] += leftover;
1810
+ break;
1811
+ }
1812
+ }
1813
+ return widths;
1814
+ }
1815
+
1816
+ /** Set an explicit width on a table cell, clearing any CSS min/max
1817
+ * width constraint so the width is honored exactly. The first
1818
+ * ("time") column is pinned to 150px by `min/max-width` in CSS;
1819
+ * without this, dragging it does nothing and the slack leaks to the
1820
+ * other columns. */
1821
+ _setCellWidth(cell, w) {
1822
+ cell.style.width = `${w}px`;
1823
+ cell.style.minWidth = '0';
1824
+ cell.style.maxWidth = 'none';
1825
+ }
1826
+
1827
+ /** Undo `_setCellWidth` so a re-measure sees the cell's natural,
1828
+ * CSS-constrained width again. */
1829
+ _clearCellWidth(cell) {
1830
+ cell.style.width = '';
1831
+ cell.style.minWidth = '';
1832
+ cell.style.maxWidth = '';
1833
+ }
1834
+
1835
+ /** Wire the body wrap's horizontal scroll to the header. The header
1836
+ * sits in an overflow:hidden wrap, so it can't scroll sideways on
1837
+ * its own — translate it by the body's scrollLeft. Re-installed each
1838
+ * render against the freshly-built wrap. */
1839
+ _installHeaderScrollSync() {
1840
+ const wrap = this._tableWrapEl;
1841
+ if (!wrap) return;
1842
+ if (this._onBodyScroll && this._scrollSyncEl) {
1843
+ this._scrollSyncEl.removeEventListener('scroll', this._onBodyScroll);
1844
+ }
1845
+ this._onBodyScroll = () => this._syncHeaderScroll();
1846
+ this._scrollSyncEl = wrap;
1847
+ wrap.addEventListener('scroll', this._onBodyScroll, { passive: true });
1848
+ this._syncHeaderScroll();
1849
+ }
1850
+
1851
+ /** Translate the header table to match the body's horizontal scroll
1852
+ * so the columns stay aligned when the table overflows sideways. */
1853
+ _syncHeaderScroll() {
1854
+ const wrap = this._tableWrapEl;
1855
+ const headerTable = this._headerTableEl;
1856
+ if (!wrap || !headerTable) return;
1857
+ const x = wrap.scrollLeft;
1858
+ headerTable.style.transform = x ? `translateX(${-x}px)` : '';
1859
+ }
1860
+
1861
+ /** Drag-to-resize: hang a thin grab handle off the right edge of
1862
+ * every header cell. Dragging it writes a per-column width override
1863
+ * (keyed by DOM index) that `_syncHeaderWidths` then honors. */
1864
+ _installColumnResizers() {
1865
+ const headerTable = this._headerTableEl;
1866
+ if (!headerTable) return;
1867
+ const headRow = headerTable.querySelector('thead > tr');
1868
+ if (!headRow) return;
1869
+ [...headRow.children].forEach((th, domIdx) => {
1870
+ if (th.querySelector('.twm-dt-col-resizer')) return;
1871
+ th.classList.add('twm-dt-col');
1872
+ const grip = document.createElement('div');
1873
+ grip.className = 'twm-dt-col-resizer';
1874
+ grip.addEventListener('mousedown',
1875
+ (ev) => this._beginColResize(ev, domIdx));
1876
+ // Keep a resize gesture from registering as a sort click.
1877
+ grip.addEventListener('click', (ev) => ev.stopPropagation());
1878
+ th.appendChild(grip);
1879
+ });
1880
+ }
1881
+
1882
+ _beginColResize(ev, domIdx) {
1883
+ ev.preventDefault();
1884
+ ev.stopPropagation();
1885
+ const headerTable = this._headerTableEl;
1886
+ const bodyTable = this._tableEl;
1887
+ const headRow = headerTable && headerTable.querySelector('thead > tr');
1888
+ const bodyRow = bodyTable && bodyTable.querySelector('tbody > tr');
1889
+ if (!headRow) return;
1890
+
1891
+ // Freeze EVERY column at its current width on BOTH tables and
1892
+ // lock fixed layout up front, so the drag moves only the grabbed
1893
+ // column and a neighbour can never absorb it. Widths come from
1894
+ // the header row (the column source of truth); the body's first
1895
+ // row is pinned to match so the two tables stay in lock-step.
1896
+ const startWidths = [...headRow.children].map(
1897
+ (c) => c.getBoundingClientRect().width);
1898
+ headerTable.style.tableLayout = 'fixed';
1899
+ if (bodyTable) bodyTable.style.tableLayout = 'fixed';
1900
+ const applyCol = (i, w) => {
1901
+ this._colWidths[i] = w;
1902
+ headerTable.querySelectorAll('thead > tr').forEach((tr) => {
1903
+ if (tr.children[i]) this._setCellWidth(tr.children[i], w);
1904
+ });
1905
+ if (bodyRow && bodyRow.children[i]) {
1906
+ this._setCellWidth(bodyRow.children[i], w);
1907
+ }
1908
+ };
1909
+ startWidths.forEach((w, i) => applyCol(i, w));
1910
+ this._applyTableWidth();
1911
+
1912
+ const startX = ev.clientX;
1913
+ const MIN = 40;
1914
+ document.body.classList.add('twm-dt-col-resizing');
1915
+ const onMove = (mv) => {
1916
+ const w = Math.max(
1917
+ MIN, Math.round(startWidths[domIdx] + (mv.clientX - startX)));
1918
+ applyCol(domIdx, w);
1919
+ this._applyTableWidth();
1920
+ this._syncHeaderScroll();
1921
+ };
1922
+ const onUp = () => {
1923
+ document.removeEventListener('mousemove', onMove);
1924
+ document.removeEventListener('mouseup', onUp);
1925
+ document.body.classList.remove('twm-dt-col-resizing');
1926
+ this._updateCellTooltips();
1927
+ this._savePersisted();
1928
+ };
1929
+ document.addEventListener('mousemove', onMove);
1930
+ document.addEventListener('mouseup', onUp);
1931
+ }
1932
+
1933
+ /** Size both tables to the sum of the header row's explicit column
1934
+ * widths so a widened column grows the table (→ horizontal scroll)
1935
+ * rather than stealing from its neighbours. */
1936
+ _applyTableWidth() {
1937
+ const headerTable = this._headerTableEl;
1938
+ const bodyTable = this._tableEl;
1939
+ const headRow = headerTable && headerTable.querySelector('thead > tr');
1940
+ if (!headRow) return;
1941
+ let total = 0;
1942
+ for (const th of headRow.children) {
1943
+ const px = parseFloat(th.style.width);
1944
+ total += Number.isFinite(px) ? px : th.getBoundingClientRect().width;
1945
+ }
1946
+ total = Math.ceil(total);
1947
+ headerTable.style.width = `${total}px`;
1948
+ if (bodyTable) bodyTable.style.width = `${total}px`;
1949
+ }
1950
+
1951
+ /** Add a native `title` tooltip to any body cell whose text is
1952
+ * clipped by its column width; remove ours once it fits again.
1953
+ * Leaves caller-supplied titles (e.g. from renderCell) untouched. */
1954
+ _updateCellTooltips() {
1955
+ const bodyTable = this._tableEl;
1956
+ if (!bodyTable) return;
1957
+ bodyTable.querySelectorAll('tbody td').forEach((td) => {
1958
+ const clipped = td.scrollWidth > td.clientWidth + 1;
1959
+ if (clipped) {
1960
+ if (!td.title) td.title = td.textContent;
1961
+ } else if (td.title && td.title === td.textContent) {
1962
+ td.removeAttribute('title');
1963
+ }
1964
+ });
1965
+ }
1966
+
1967
+ _installInteractions() {
1968
+ if (!this._tbodyEl || !this._tableEl) return;
1969
+
1970
+ const { selectable, copyable } = this.config;
1971
+ const tbody = this._tbodyEl;
1972
+ const table = this._tableEl;
1973
+
1974
+ if (selectable) {
1975
+ const handleRowClick = (event) => {
1976
+ const rowEl = event.target.closest('tr');
1977
+ if (!rowEl || rowEl.__rowIndex === undefined) return;
1978
+
1979
+ const idx = rowEl.__rowIndex;
1980
+ const selected = this._state.selected;
1981
+
1982
+ if (event.shiftKey && this._state.anchorIndex != null) {
1983
+ const start = Math.min(this._state.anchorIndex, idx);
1984
+ const end = Math.max(this._state.anchorIndex, idx);
1985
+ selected.clear();
1986
+ for (let i = start; i <= end; i++) selected.add(i);
1987
+ } else if (event.metaKey || event.ctrlKey) {
1988
+ if (selected.has(idx)) selected.delete(idx);
1989
+ else selected.add(idx);
1990
+ this._state.anchorIndex = idx;
1991
+ } else {
1992
+ selected.clear();
1993
+ selected.add(idx);
1994
+ this._state.anchorIndex = idx;
1995
+ }
1996
+
1997
+ this._updateRowSelection();
1998
+ this._notifySelectionChange();
1999
+ try { table.focus({ preventScroll: true }); } catch (_) {}
2000
+ };
2001
+
2002
+ tbody.addEventListener('click', handleRowClick);
2003
+ this._disposers.push(() => tbody.removeEventListener('click', handleRowClick));
2004
+ }
2005
+
2006
+ if (copyable) {
2007
+ const handleContextMenu = (event) => {
2008
+ const rowEl = event.target.closest('tr');
2009
+ if (!rowEl) {
2010
+ this._hideContextMenu();
2011
+ return;
2012
+ }
2013
+
2014
+ event.preventDefault();
2015
+ event.stopPropagation();
2016
+
2017
+ const idx = rowEl.__rowIndex;
2018
+ if (idx !== undefined && !this._state.selected.has(idx)) {
2019
+ this._state.selected.clear();
2020
+ this._state.selected.add(idx);
2021
+ this._state.anchorIndex = idx;
2022
+ this._updateRowSelection();
2023
+ this._notifySelectionChange();
2024
+ }
2025
+
2026
+ try { table.focus({ preventScroll: true }); } catch (_) {}
2027
+ setTimeout(() => this._showContextMenu(event.clientX, event.clientY), 0);
2028
+ };
2029
+
2030
+ tbody.addEventListener('contextmenu', handleContextMenu);
2031
+ this._disposers.push(() => tbody.removeEventListener('contextmenu', handleContextMenu));
2032
+
2033
+ const handleKeyDown = (event) => {
2034
+ const ctrlLike = event.ctrlKey || event.metaKey;
2035
+ if (ctrlLike && !event.altKey) {
2036
+ const key = String(event.key || '').toLowerCase();
2037
+ if (key === 'a' && selectable) {
2038
+ event.preventDefault();
2039
+ this._state.selected.clear();
2040
+ // Select only visible (filtered) rows via index map
2041
+ const indexMap = this._processedIndexMap;
2042
+ if (indexMap) {
2043
+ for (const originalIdx of indexMap) {
2044
+ this._state.selected.add(originalIdx);
2045
+ }
2046
+ } else {
2047
+ for (let i = 0; i < this.config.rows.length; i++) {
2048
+ this._state.selected.add(i);
2049
+ }
2050
+ }
2051
+ this._state.anchorIndex = this.config.rows.length - 1;
2052
+ this._updateRowSelection();
2053
+ this._notifySelectionChange();
2054
+ } else if (key === 'c') {
2055
+ event.preventDefault();
2056
+ this.copyToClipboard(event.shiftKey ? 'csv' : 'tsv');
2057
+ }
2058
+ } else if (event.key === 'Escape') {
2059
+ if (this._state.selected.size) {
2060
+ this.clearSelection();
2061
+ }
2062
+ this._hideContextMenu();
2063
+ }
2064
+ };
2065
+
2066
+ table.addEventListener('keydown', handleKeyDown);
2067
+ this._disposers.push(() => table.removeEventListener('keydown', handleKeyDown));
2068
+ }
2069
+ }
2070
+
2071
+ _updateRowSelection() {
2072
+ if (!this._tbodyEl) return;
2073
+ this._tbodyEl.querySelectorAll('tr').forEach(tr => {
2074
+ const idx = tr.__rowIndex;
2075
+ if (idx !== undefined) {
2076
+ tr.classList.toggle('selected', this._state.selected.has(idx));
2077
+ }
2078
+ });
2079
+ }
2080
+
2081
+ _notifySelectionChange() {
2082
+ this.config.onSelectionChange?.(this.getSelection());
2083
+ }
2084
+
2085
+ _ensureContextMenu() {
2086
+ if (this._contextMenuEl) return this._contextMenuEl;
2087
+
2088
+ const menu = document.createElement('div');
2089
+ menu.className = 'twm-context-menu data-context-menu';
2090
+ menu.style.display = 'none';
2091
+ menu.style.position = 'fixed';
2092
+ menu.style.zIndex = '10001';
2093
+ menu.innerHTML = `
2094
+ <div class="twm-context-menu-item" data-action="copy-tsv">
2095
+ <span class="material-symbols-outlined">content_copy</span>
2096
+ <span class="label">Copy Row(s)</span>
2097
+ </div>
2098
+ <div class="twm-context-menu-item" data-action="copy-csv">
2099
+ <span class="material-symbols-outlined">table</span>
2100
+ <span class="label">Copy as CSV</span>
2101
+ </div>
2102
+ `;
2103
+ document.body.appendChild(menu);
2104
+
2105
+ menu.addEventListener('click', (event) => {
2106
+ const item = event.target.closest('.twm-context-menu-item');
2107
+ if (!item || item.classList.contains('disabled')) return;
2108
+ const action = item.getAttribute('data-action');
2109
+ if (action === 'copy-tsv') this.copyToClipboard('tsv');
2110
+ else if (action === 'copy-csv') this.copyToClipboard('csv');
2111
+ event.stopPropagation();
2112
+ event.preventDefault();
2113
+ }, { capture: true });
2114
+
2115
+ menu.addEventListener('contextmenu', (e) => {
2116
+ e.preventDefault();
2117
+ e.stopPropagation();
2118
+ });
2119
+
2120
+ const hideOnGlobal = (event) => {
2121
+ if (event?.target && menu.contains(event.target)) return;
2122
+ this._hideContextMenu();
2123
+ };
2124
+ const hideOnEscape = (event) => {
2125
+ if (event.key === 'Escape') this._hideContextMenu();
2126
+ };
2127
+ const hideOnResize = () => this._hideContextMenu();
2128
+
2129
+ document.addEventListener('click', hideOnGlobal, true);
2130
+ document.addEventListener('scroll', hideOnGlobal, true);
2131
+ window.addEventListener('resize', hideOnResize);
2132
+ document.addEventListener('keydown', hideOnEscape);
2133
+
2134
+ this._contextMenuGlobals.push(() => document.removeEventListener('click', hideOnGlobal, true));
2135
+ this._contextMenuGlobals.push(() => document.removeEventListener('scroll', hideOnGlobal, true));
2136
+ this._contextMenuGlobals.push(() => document.removeEventListener('keydown', hideOnEscape));
2137
+ this._contextMenuGlobals.push(() => window.removeEventListener('resize', hideOnResize));
2138
+
2139
+ this._contextMenuEl = menu;
2140
+ return menu;
2141
+ }
2142
+
2143
+ _hideContextMenu() {
2144
+ if (this._contextMenuEl) {
2145
+ this._contextMenuEl.style.display = 'none';
2146
+ }
2147
+ }
2148
+
2149
+ _showContextMenu(clientX, clientY) {
2150
+ const menu = this._ensureContextMenu();
2151
+ const hasSelection = this._state.selected.size > 0;
2152
+
2153
+ menu.querySelectorAll('.twm-context-menu-item').forEach(item => {
2154
+ item.classList.toggle('disabled', !hasSelection);
2155
+ item.style.pointerEvents = hasSelection ? '' : 'none';
2156
+ });
2157
+
2158
+ menu.style.display = 'block';
2159
+ const rect = menu.getBoundingClientRect();
2160
+ let left = clientX;
2161
+ let top = clientY;
2162
+
2163
+ if (left + rect.width > window.innerWidth) {
2164
+ left = window.innerWidth - rect.width - 8;
2165
+ }
2166
+ if (top + rect.height > window.innerHeight) {
2167
+ top = window.innerHeight - rect.height - 8;
2168
+ }
2169
+
2170
+ menu.style.left = `${left}px`;
2171
+ menu.style.top = `${top}px`;
2172
+ }
2173
+
2174
+ _teardownContextMenu() {
2175
+ this._contextMenuGlobals.forEach(dispose => {
2176
+ try { dispose?.(); } catch (_) {}
2177
+ });
2178
+ this._contextMenuGlobals = [];
2179
+ if (this._contextMenuEl?.parentNode) {
2180
+ this._contextMenuEl.parentNode.removeChild(this._contextMenuEl);
2181
+ }
2182
+ this._contextMenuEl = null;
2183
+ }
2184
+
2185
+ _formatValue(value, colIndex) {
2186
+ if (this.config.formatValue) {
2187
+ return this.config.formatValue(value, colIndex);
2188
+ }
2189
+ if (value === undefined || value === null) return '-';
2190
+ if (typeof value === 'number') {
2191
+ if (!Number.isFinite(value)) return '-';
2192
+ return Number(value.toFixed(4)).toString();
2193
+ }
2194
+ return String(value);
2195
+ }
2196
+
2197
+ _csvEscape(value) {
2198
+ if (value == null) return '';
2199
+ const str = String(value);
2200
+ if (/[",\n]/.test(str)) {
2201
+ return `"${str.replace(/"/g, '""')}"`;
2202
+ }
2203
+ return str;
2204
+ }
2205
+
2206
+ _fallbackCopy(text) {
2207
+ try {
2208
+ const textarea = document.createElement('textarea');
2209
+ textarea.value = text;
2210
+ textarea.setAttribute('readonly', '');
2211
+ textarea.style.position = 'absolute';
2212
+ textarea.style.left = '-9999px';
2213
+ document.body.appendChild(textarea);
2214
+ textarea.select();
2215
+ const ok = document.execCommand('copy');
2216
+ document.body.removeChild(textarea);
2217
+ return ok;
2218
+ } catch (_) {
2219
+ return false;
2220
+ }
2221
+ }
2222
+
2223
+ _notify(title, message, type = 'info') {
2224
+ this.config.services?.eventBus?.emit?.('toast:show', { title, message, type });
2225
+ }
2226
+ }
2227
+
2228
+ export default DataTable;