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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- package/src/ui/utils/tooltip_service.js +406 -0
|
@@ -0,0 +1,1770 @@
|
|
|
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 DataTable = class {
|
|
19
|
+
/**
|
|
20
|
+
* Create a DataTable instance
|
|
21
|
+
* @param {HTMLElement} container - Container element to render into
|
|
22
|
+
* @param {DataTableConfig} config - Configuration options
|
|
23
|
+
*/
|
|
24
|
+
constructor(container, config = {}) {
|
|
25
|
+
this.container = container;
|
|
26
|
+
this.config = {
|
|
27
|
+
headers: [],
|
|
28
|
+
rows: [],
|
|
29
|
+
pageSize: DEFAULT_PAGE_SIZE,
|
|
30
|
+
pagination: true,
|
|
31
|
+
selectable: true,
|
|
32
|
+
copyable: true,
|
|
33
|
+
sortable: false,
|
|
34
|
+
filterable: false,
|
|
35
|
+
readonly: false,
|
|
36
|
+
showRowNumbers: false,
|
|
37
|
+
emptyMessage: "No data",
|
|
38
|
+
onSort: null,
|
|
39
|
+
onSelectionChange: null,
|
|
40
|
+
formatValue: null,
|
|
41
|
+
getHeaderIcon: null,
|
|
42
|
+
getColumnType: null,
|
|
43
|
+
services: null,
|
|
44
|
+
// Server-side pagination
|
|
45
|
+
totalCount: null,
|
|
46
|
+
offset: 0,
|
|
47
|
+
onPageChange: null,
|
|
48
|
+
onJumpToRow: null,
|
|
49
|
+
// Custom cell rendering
|
|
50
|
+
renderCell: null,
|
|
51
|
+
// Rendering density + bottom-panel hooks (P2)
|
|
52
|
+
mode: "normal",
|
|
53
|
+
onRowClick: null,
|
|
54
|
+
onRowContextMenu: null,
|
|
55
|
+
onCellContextMenu: null,
|
|
56
|
+
showExportButton: false,
|
|
57
|
+
// The host port. TOP LEVEL, deliberately — NOT inside `services`,
|
|
58
|
+
// which most call sites never pass. Supplies the native save
|
|
59
|
+
// dialog for downloadCSV(); absent => Blob fallback.
|
|
60
|
+
host: null,
|
|
61
|
+
// Opt-in persistence: when set, this table's sort, filters, and
|
|
62
|
+
// column widths survive teardown/reload, keyed by this string
|
|
63
|
+
// inside the injected `stateStore`. Omit it and the table stays
|
|
64
|
+
// ephemeral. WHERE the store persists is the embedder's business.
|
|
65
|
+
persistKey: null,
|
|
66
|
+
stateStore: null,
|
|
67
|
+
...config
|
|
68
|
+
};
|
|
69
|
+
if (this.config.persistKey && !this.config.stateStore) {
|
|
70
|
+
throw new Error(`DataTable: persistKey "${this.config.persistKey}" requires { stateStore }`);
|
|
71
|
+
}
|
|
72
|
+
this._state = {
|
|
73
|
+
offset: 0,
|
|
74
|
+
selected: /* @__PURE__ */ new Set(),
|
|
75
|
+
anchorIndex: null,
|
|
76
|
+
sortColumn: null,
|
|
77
|
+
sortAscending: true,
|
|
78
|
+
filters: /* @__PURE__ */ new Map()
|
|
79
|
+
};
|
|
80
|
+
this._columnTypes = [];
|
|
81
|
+
this._processedRows = null;
|
|
82
|
+
this._processedIndexMap = null;
|
|
83
|
+
this._filterDebounceTimer = null;
|
|
84
|
+
this._wrapperEl = null;
|
|
85
|
+
this._paginationEl = null;
|
|
86
|
+
this._tableEl = null;
|
|
87
|
+
this._tbodyEl = null;
|
|
88
|
+
this._contextMenuEl = null;
|
|
89
|
+
this._filterDropdownEl = null;
|
|
90
|
+
this._filterDropdownCleanup = null;
|
|
91
|
+
this._colWidths = {};
|
|
92
|
+
this._colWidthsSig = null;
|
|
93
|
+
this._disposers = [];
|
|
94
|
+
this._contextMenuGlobals = [];
|
|
95
|
+
if (this.config.persistKey) {
|
|
96
|
+
const store = this.config.stateStore;
|
|
97
|
+
this._restorePersisted(store.get(this.config.persistKey));
|
|
98
|
+
store.ready().then(() => {
|
|
99
|
+
const late = store.get(this.config.persistKey);
|
|
100
|
+
if (late && this._restorePersisted(late) && this._wrapperEl) {
|
|
101
|
+
this._invalidateProcessedCache?.();
|
|
102
|
+
this.render();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Apply a persisted blob ({sort, asc, filters, widths}) onto this
|
|
108
|
+
* table's live state. Returns true if anything was applied. */
|
|
109
|
+
_restorePersisted(blob) {
|
|
110
|
+
if (!blob) return false;
|
|
111
|
+
let applied = false;
|
|
112
|
+
if (typeof blob.sortColumn === "number" || blob.sortColumn === null) {
|
|
113
|
+
this._state.sortColumn = blob.sortColumn;
|
|
114
|
+
this._state.sortAscending = blob.sortAscending !== false;
|
|
115
|
+
applied = true;
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(blob.filters)) {
|
|
118
|
+
this._state.filters = new Map(blob.filters);
|
|
119
|
+
applied = true;
|
|
120
|
+
}
|
|
121
|
+
if (blob.colWidths && typeof blob.colWidths === "object") {
|
|
122
|
+
this._colWidths = {};
|
|
123
|
+
for (const [k, v] of Object.entries(blob.colWidths)) {
|
|
124
|
+
this._colWidths[Number(k)] = v;
|
|
125
|
+
}
|
|
126
|
+
this._colWidthsSig = this._colSig();
|
|
127
|
+
applied = true;
|
|
128
|
+
}
|
|
129
|
+
return applied;
|
|
130
|
+
}
|
|
131
|
+
/** Identity of the current column set — restored widths/sort are
|
|
132
|
+
* keyed by position, so a schema change invalidates them. */
|
|
133
|
+
_colSig() {
|
|
134
|
+
return (this.config.headers || []).join("") + (this.config.showRowNumbers ? "|#" : "");
|
|
135
|
+
}
|
|
136
|
+
/** Snapshot the persistable slice of state to the project store.
|
|
137
|
+
* No-op unless `persistKey` is set. Debounced inside the store. */
|
|
138
|
+
_savePersisted() {
|
|
139
|
+
if (!this.config.persistKey) return;
|
|
140
|
+
this.config.stateStore.set(this.config.persistKey, {
|
|
141
|
+
sortColumn: this._state.sortColumn,
|
|
142
|
+
sortAscending: this._state.sortAscending,
|
|
143
|
+
filters: [...this._state.filters.entries()],
|
|
144
|
+
colWidths: { ...this._colWidths }
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Update data and re-render
|
|
149
|
+
* @param {Object} updates - Partial config updates (headers, rows, etc.)
|
|
150
|
+
*/
|
|
151
|
+
setData(updates) {
|
|
152
|
+
if (updates.headers && this._state.filters.size > 0) {
|
|
153
|
+
const oldHeaders = this.config.headers;
|
|
154
|
+
const newHeaders = updates.headers;
|
|
155
|
+
if (oldHeaders.length !== newHeaders.length || oldHeaders.some((h, i) => h !== newHeaders[i])) {
|
|
156
|
+
this._state.filters.clear();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
Object.assign(this.config, updates);
|
|
160
|
+
if (updates.rows) {
|
|
161
|
+
this._state.selected.clear();
|
|
162
|
+
this._state.anchorIndex = null;
|
|
163
|
+
if (this._state.offset >= updates.rows.length) {
|
|
164
|
+
this._state.offset = 0;
|
|
165
|
+
}
|
|
166
|
+
this._columnTypes = this._detectColumnTypes();
|
|
167
|
+
}
|
|
168
|
+
this._processedRows = null;
|
|
169
|
+
this._processedIndexMap = null;
|
|
170
|
+
this.render();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Show / hide a translucent loading overlay over the table body.
|
|
174
|
+
* Used by server-side consumers between firing a fetch and
|
|
175
|
+
* receiving the new rows so the UI doesn't appear frozen.
|
|
176
|
+
* Callers either:
|
|
177
|
+
* - call `setLoading(true)` before their fetch, `setLoading(false)` after, OR
|
|
178
|
+
* - return a Promise from `onPageChange` / `onSort` callbacks
|
|
179
|
+
* — the overlay auto-shows during the await.
|
|
180
|
+
* @param {boolean} on
|
|
181
|
+
*/
|
|
182
|
+
setLoading(on) {
|
|
183
|
+
if (!this._wrapperEl) return;
|
|
184
|
+
let overlay = this._wrapperEl.querySelector(".twm-data-table__loading");
|
|
185
|
+
if (on) {
|
|
186
|
+
if (!overlay) {
|
|
187
|
+
overlay = document.createElement("div");
|
|
188
|
+
overlay.className = "twm-data-table__loading";
|
|
189
|
+
overlay.innerHTML = '<div class="twm-data-table__spinner"></div>';
|
|
190
|
+
if (!this._wrapperEl.style.position) {
|
|
191
|
+
this._wrapperEl.style.position = "relative";
|
|
192
|
+
}
|
|
193
|
+
this._wrapperEl.appendChild(overlay);
|
|
194
|
+
}
|
|
195
|
+
} else if (overlay) {
|
|
196
|
+
overlay.remove();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Get current selection indices
|
|
201
|
+
* @returns {number[]} Array of selected row indices
|
|
202
|
+
*/
|
|
203
|
+
getSelection() {
|
|
204
|
+
return Array.from(this._state.selected).sort((a, b) => a - b);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Set selection programmatically
|
|
208
|
+
* @param {number[]} indices - Row indices to select
|
|
209
|
+
*/
|
|
210
|
+
setSelection(indices) {
|
|
211
|
+
this._state.selected.clear();
|
|
212
|
+
for (const idx of indices) {
|
|
213
|
+
if (idx >= 0 && idx < this.config.rows.length) {
|
|
214
|
+
this._state.selected.add(idx);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
this._updateRowSelection();
|
|
218
|
+
this._notifySelectionChange();
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Clear selection
|
|
222
|
+
*/
|
|
223
|
+
clearSelection() {
|
|
224
|
+
this._state.selected.clear();
|
|
225
|
+
this._state.anchorIndex = null;
|
|
226
|
+
this._updateRowSelection();
|
|
227
|
+
this._notifySelectionChange();
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Go to specific page
|
|
231
|
+
* @param {number} page - Page number (0-indexed)
|
|
232
|
+
*/
|
|
233
|
+
goToPage(page) {
|
|
234
|
+
const rowCount = this._getProcessedRows().length;
|
|
235
|
+
const maxPage = Math.ceil(rowCount / this.config.pageSize) - 1;
|
|
236
|
+
this._state.offset = Math.max(0, Math.min(page, maxPage)) * this.config.pageSize;
|
|
237
|
+
this.render();
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Go to specific row
|
|
241
|
+
* @param {number} rowIndex - Row index
|
|
242
|
+
*/
|
|
243
|
+
goToRow(rowIndex) {
|
|
244
|
+
const rowCount = this._getProcessedRows().length;
|
|
245
|
+
const idx = Math.max(0, Math.min(rowIndex, rowCount - 1));
|
|
246
|
+
this._state.offset = Math.floor(idx / this.config.pageSize) * this.config.pageSize;
|
|
247
|
+
this.render();
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Sort by column
|
|
251
|
+
* @param {number} colIndex - Column index
|
|
252
|
+
* @param {boolean} [ascending] - Sort direction (toggles if same column)
|
|
253
|
+
*/
|
|
254
|
+
sortBy(colIndex, ascending) {
|
|
255
|
+
if (this._state.sortColumn === colIndex && ascending === void 0) {
|
|
256
|
+
this._state.sortAscending = !this._state.sortAscending;
|
|
257
|
+
} else {
|
|
258
|
+
this._state.sortColumn = colIndex;
|
|
259
|
+
this._state.sortAscending = ascending ?? true;
|
|
260
|
+
}
|
|
261
|
+
this._processedRows = null;
|
|
262
|
+
this._processedIndexMap = null;
|
|
263
|
+
const ret = this.config.onSort?.(colIndex, this._state.sortAscending);
|
|
264
|
+
this._awaitWithSpinner(ret);
|
|
265
|
+
this._savePersisted();
|
|
266
|
+
this.render();
|
|
267
|
+
}
|
|
268
|
+
/** Internal: if `maybePromise` is a Promise (or thenable),
|
|
269
|
+
* toggle the loading overlay around it. No-op otherwise. */
|
|
270
|
+
_awaitWithSpinner(maybePromise) {
|
|
271
|
+
if (!maybePromise || typeof maybePromise.then !== "function") return;
|
|
272
|
+
this.setLoading(true);
|
|
273
|
+
Promise.resolve(maybePromise).finally(() => this.setLoading(false));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Clear all column filters
|
|
277
|
+
*/
|
|
278
|
+
clearFilters() {
|
|
279
|
+
this._state.filters.clear();
|
|
280
|
+
this._processedRows = null;
|
|
281
|
+
this._processedIndexMap = null;
|
|
282
|
+
this._state.offset = 0;
|
|
283
|
+
this._savePersisted();
|
|
284
|
+
this.render();
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Get the number of rows after filtering
|
|
288
|
+
* @returns {number}
|
|
289
|
+
*/
|
|
290
|
+
getFilteredRowCount() {
|
|
291
|
+
return this._getProcessedRows().length;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Render the table
|
|
295
|
+
*/
|
|
296
|
+
render() {
|
|
297
|
+
const activeFilterColIdx = this._getActiveFilterColIdx();
|
|
298
|
+
this._cleanup();
|
|
299
|
+
this.container.innerHTML = "";
|
|
300
|
+
const { rows, pagination, emptyMessage } = this.config;
|
|
301
|
+
const colSig = this._colSig();
|
|
302
|
+
if (this._colWidthsSig !== colSig) {
|
|
303
|
+
this._colWidths = {};
|
|
304
|
+
this._colWidthsSig = colSig;
|
|
305
|
+
}
|
|
306
|
+
if (this._columnTypes.length === 0) {
|
|
307
|
+
this._columnTypes = this._detectColumnTypes();
|
|
308
|
+
}
|
|
309
|
+
this._wrapperEl = document.createElement("div");
|
|
310
|
+
this._wrapperEl.className = "twm-data-table-component" + (this.config.mode === "compact" ? " twm-data-table-component--compact" : "");
|
|
311
|
+
this._wrapperEl.style.cssText = "display:flex; flex-direction:column; height:100%; min-height:0;";
|
|
312
|
+
if (pagination && rows.length > 0) {
|
|
313
|
+
this._paginationEl = this._createPagination();
|
|
314
|
+
if (this._paginationEl) this._wrapperEl.appendChild(this._paginationEl);
|
|
315
|
+
}
|
|
316
|
+
const tableWrap = document.createElement("div");
|
|
317
|
+
tableWrap.className = this.config.readonly ? "twm-preview-table-wrap twm-preview-table-wrap--wizard" : "twm-preview-table-wrap";
|
|
318
|
+
tableWrap.style.cssText = "flex:1 1 0; min-height:0; min-width:0; overflow:auto;";
|
|
319
|
+
if (rows.length === 0) {
|
|
320
|
+
const { headers, showRowNumbers } = this.config;
|
|
321
|
+
const headerWrap = document.createElement("div");
|
|
322
|
+
headerWrap.className = "twm-preview-table-header-wrap";
|
|
323
|
+
headerWrap.style.cssText = "flex:0 0 auto; overflow:hidden; min-width:0;";
|
|
324
|
+
const headerTable = document.createElement("table");
|
|
325
|
+
headerTable.className = this.config.readonly ? "twm-preview-table twm-preview-table--readonly" : "twm-preview-table";
|
|
326
|
+
const thead = document.createElement("thead");
|
|
327
|
+
const tr = document.createElement("tr");
|
|
328
|
+
if (showRowNumbers) {
|
|
329
|
+
const th = document.createElement("th");
|
|
330
|
+
th.className = "num";
|
|
331
|
+
th.textContent = "#";
|
|
332
|
+
tr.appendChild(th);
|
|
333
|
+
}
|
|
334
|
+
headers.forEach((h, colIdx) => {
|
|
335
|
+
const th = document.createElement("th");
|
|
336
|
+
th.className = this._columnTypes[colIdx] || "text";
|
|
337
|
+
th.textContent = h;
|
|
338
|
+
tr.appendChild(th);
|
|
339
|
+
});
|
|
340
|
+
thead.appendChild(tr);
|
|
341
|
+
headerTable.appendChild(thead);
|
|
342
|
+
headerWrap.appendChild(headerTable);
|
|
343
|
+
this._wrapperEl.appendChild(headerWrap);
|
|
344
|
+
this._headerWrapEl = headerWrap;
|
|
345
|
+
this._headerTableEl = headerTable;
|
|
346
|
+
const bodyTable = document.createElement("table");
|
|
347
|
+
bodyTable.className = headerTable.className;
|
|
348
|
+
const tbody = document.createElement("tbody");
|
|
349
|
+
const emptyTr = document.createElement("tr");
|
|
350
|
+
emptyTr.className = "data-preview-row data-table__empty-row";
|
|
351
|
+
const colCount = headers.length + (showRowNumbers ? 1 : 0);
|
|
352
|
+
const emptyTd = document.createElement("td");
|
|
353
|
+
emptyTd.colSpan = colCount;
|
|
354
|
+
emptyTd.style.cssText = "text-align:center; font-style:italic; color:#888; padding:16px;";
|
|
355
|
+
emptyTd.textContent = emptyMessage;
|
|
356
|
+
emptyTr.appendChild(emptyTd);
|
|
357
|
+
tbody.appendChild(emptyTr);
|
|
358
|
+
bodyTable.appendChild(tbody);
|
|
359
|
+
tableWrap.appendChild(bodyTable);
|
|
360
|
+
this._wrapperEl.appendChild(tableWrap);
|
|
361
|
+
this._tableEl = bodyTable;
|
|
362
|
+
this._tableWrapEl = tableWrap;
|
|
363
|
+
} else {
|
|
364
|
+
const fullTable = this._createTable();
|
|
365
|
+
this._tableEl = fullTable;
|
|
366
|
+
const thead = fullTable.querySelector("thead");
|
|
367
|
+
if (thead) {
|
|
368
|
+
const headerWrap = document.createElement("div");
|
|
369
|
+
headerWrap.className = "twm-preview-table-header-wrap";
|
|
370
|
+
headerWrap.style.cssText = "flex:0 0 auto; overflow:hidden; min-width:0;";
|
|
371
|
+
const headerTable = document.createElement("table");
|
|
372
|
+
headerTable.className = fullTable.className;
|
|
373
|
+
headerTable.appendChild(thead);
|
|
374
|
+
headerWrap.appendChild(headerTable);
|
|
375
|
+
this._wrapperEl.appendChild(headerWrap);
|
|
376
|
+
this._headerTableEl = headerTable;
|
|
377
|
+
this._headerWrapEl = headerWrap;
|
|
378
|
+
} else {
|
|
379
|
+
this._headerTableEl = null;
|
|
380
|
+
}
|
|
381
|
+
tableWrap.appendChild(fullTable);
|
|
382
|
+
this._wrapperEl.appendChild(tableWrap);
|
|
383
|
+
}
|
|
384
|
+
this._tableWrapEl = tableWrap;
|
|
385
|
+
this.container.appendChild(this._wrapperEl);
|
|
386
|
+
if (this._headerTableEl && this._tableEl) {
|
|
387
|
+
requestAnimationFrame(() => this._syncHeaderWidths());
|
|
388
|
+
if (this._resizeObserver) {
|
|
389
|
+
try {
|
|
390
|
+
this._resizeObserver.disconnect();
|
|
391
|
+
} catch (_) {
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
395
|
+
this._resizeObserver = createRafResizeObserver(
|
|
396
|
+
() => this._syncHeaderWidths()
|
|
397
|
+
);
|
|
398
|
+
this._resizeObserver.observe(this._wrapperEl);
|
|
399
|
+
}
|
|
400
|
+
this._installHeaderScrollSync();
|
|
401
|
+
if (rows.length > 0) this._installColumnResizers();
|
|
402
|
+
}
|
|
403
|
+
if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly)) {
|
|
404
|
+
this._installInteractions();
|
|
405
|
+
}
|
|
406
|
+
if (activeFilterColIdx !== null) {
|
|
407
|
+
this._restoreFilterFocus(activeFilterColIdx);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Copy selected rows to clipboard
|
|
412
|
+
* @param {'tsv'|'csv'} [format='tsv'] - Output format
|
|
413
|
+
*/
|
|
414
|
+
async copyToClipboard(format = "tsv") {
|
|
415
|
+
const indices = this.getSelection();
|
|
416
|
+
if (indices.length === 0) {
|
|
417
|
+
this._notify("Copy", "No rows selected.", "warn");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const { headers, rows } = this.config;
|
|
421
|
+
const matrix = [headers];
|
|
422
|
+
for (const idx of indices) {
|
|
423
|
+
const row = rows[idx];
|
|
424
|
+
if (row) {
|
|
425
|
+
matrix.push(row.map((val, colIdx) => this._formatValue(val, colIdx)));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const separator = format === "csv" ? "," : " ";
|
|
429
|
+
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");
|
|
430
|
+
let ok = false;
|
|
431
|
+
try {
|
|
432
|
+
await navigator.clipboard.writeText(text);
|
|
433
|
+
ok = true;
|
|
434
|
+
} catch (_) {
|
|
435
|
+
ok = this._fallbackCopy(text);
|
|
436
|
+
}
|
|
437
|
+
if (ok) {
|
|
438
|
+
const desc = indices.length === 1 ? "row" : "rows";
|
|
439
|
+
const suffix = format === "csv" ? " as CSV" : "";
|
|
440
|
+
this._notify("Copy", `Copied ${indices.length} ${desc}${suffix}.`, "info");
|
|
441
|
+
} else {
|
|
442
|
+
this._notify("Copy", "Clipboard unavailable.", "warn");
|
|
443
|
+
}
|
|
444
|
+
this._hideContextMenu();
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Download all data as CSV
|
|
448
|
+
* @param {string} [filename='data.csv'] - Filename for download
|
|
449
|
+
*/
|
|
450
|
+
async downloadCSV(filename = "data.csv") {
|
|
451
|
+
const { headers, rows } = this.config;
|
|
452
|
+
if (rows.length === 0) {
|
|
453
|
+
this._notify("Download", "No data to download.", "warn");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const lines = [headers.join(",")];
|
|
457
|
+
for (const row of rows) {
|
|
458
|
+
lines.push(row.map(
|
|
459
|
+
(val, colIdx) => this._csvEscape(this._formatValue(val, colIdx))
|
|
460
|
+
).join(","));
|
|
461
|
+
}
|
|
462
|
+
const csv = lines.join("\n");
|
|
463
|
+
const dialogs = this.config.host?.dialogs;
|
|
464
|
+
let result = null;
|
|
465
|
+
if (dialogs) {
|
|
466
|
+
try {
|
|
467
|
+
result = await dialogs.saveFile({ data: csv, filename, kind: "csv" });
|
|
468
|
+
} catch (err) {
|
|
469
|
+
console.error("[DataTable] CSV download failed:", err);
|
|
470
|
+
this._notify("Download", "Failed to save file", "error");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (result) {
|
|
475
|
+
if (result.ok) {
|
|
476
|
+
this._notify("Download", `Saved ${rows.length} rows to ${result.path}`, "success");
|
|
477
|
+
} else if (!result.cancelled) {
|
|
478
|
+
this._notify("Download", result.error || "Failed to save file", "error");
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
483
|
+
const url = URL.createObjectURL(blob);
|
|
484
|
+
const link = document.createElement("a");
|
|
485
|
+
link.href = url;
|
|
486
|
+
link.download = filename;
|
|
487
|
+
link.click();
|
|
488
|
+
URL.revokeObjectURL(url);
|
|
489
|
+
this._notify("Download", "Download started", "info");
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Dispose and cleanup
|
|
493
|
+
*/
|
|
494
|
+
dispose() {
|
|
495
|
+
this._cleanup();
|
|
496
|
+
this._closeFilterDropdown();
|
|
497
|
+
this._teardownContextMenu();
|
|
498
|
+
if (this._resizeObserver) {
|
|
499
|
+
try {
|
|
500
|
+
this._resizeObserver.disconnect();
|
|
501
|
+
} catch (_) {
|
|
502
|
+
}
|
|
503
|
+
this._resizeObserver = null;
|
|
504
|
+
}
|
|
505
|
+
if (this._onBodyScroll && this._scrollSyncEl) {
|
|
506
|
+
try {
|
|
507
|
+
this._scrollSyncEl.removeEventListener("scroll", this._onBodyScroll);
|
|
508
|
+
} catch (_) {
|
|
509
|
+
}
|
|
510
|
+
this._onBodyScroll = null;
|
|
511
|
+
this._scrollSyncEl = null;
|
|
512
|
+
}
|
|
513
|
+
this.container.innerHTML = "";
|
|
514
|
+
this._wrapperEl = null;
|
|
515
|
+
this._paginationEl = null;
|
|
516
|
+
this._tableEl = null;
|
|
517
|
+
this._tbodyEl = null;
|
|
518
|
+
this._headerTableEl = null;
|
|
519
|
+
this._headerWrapEl = null;
|
|
520
|
+
this._tableWrapEl = null;
|
|
521
|
+
this._processedRows = null;
|
|
522
|
+
this._processedIndexMap = null;
|
|
523
|
+
}
|
|
524
|
+
// ─────────────────────────────────────────────────────────────────
|
|
525
|
+
// Processed rows pipeline (filter → sort → cache)
|
|
526
|
+
// ─────────────────────────────────────────────────────────────────
|
|
527
|
+
_getProcessedRows() {
|
|
528
|
+
if (this._processedRows !== null) return this._processedRows;
|
|
529
|
+
const isServerSide = typeof this.config.onPageChange === "function";
|
|
530
|
+
let rows = this.config.rows;
|
|
531
|
+
let indexMap = rows.map((_, i) => i);
|
|
532
|
+
if (!isServerSide && this._state.filters.size > 0) {
|
|
533
|
+
const result = this._applyFilters(rows, indexMap);
|
|
534
|
+
rows = result.rows;
|
|
535
|
+
indexMap = result.indexMap;
|
|
536
|
+
}
|
|
537
|
+
if (!isServerSide && this.config.sortable && this._state.sortColumn !== null && !this.config.onSort) {
|
|
538
|
+
const result = this._applySorting(rows, indexMap);
|
|
539
|
+
rows = result.rows;
|
|
540
|
+
indexMap = result.indexMap;
|
|
541
|
+
}
|
|
542
|
+
this._processedRows = rows;
|
|
543
|
+
this._processedIndexMap = indexMap;
|
|
544
|
+
return rows;
|
|
545
|
+
}
|
|
546
|
+
_applyFilters(rows, indexMap) {
|
|
547
|
+
const filters = this._state.filters;
|
|
548
|
+
const filteredRows = [];
|
|
549
|
+
const filteredMap = [];
|
|
550
|
+
for (let i = 0; i < rows.length; i++) {
|
|
551
|
+
const row = rows[i];
|
|
552
|
+
let pass = true;
|
|
553
|
+
for (const [colIdx, filterText] of filters) {
|
|
554
|
+
if (!filterText) continue;
|
|
555
|
+
const value = row[colIdx];
|
|
556
|
+
const colType = this._columnTypes[colIdx];
|
|
557
|
+
if (colType === "num") {
|
|
558
|
+
if (!this._matchNumericFilter(value, filterText)) {
|
|
559
|
+
pass = false;
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
if (!this._matchTextFilter(value, filterText)) {
|
|
564
|
+
pass = false;
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (pass) {
|
|
570
|
+
filteredRows.push(row);
|
|
571
|
+
filteredMap.push(indexMap[i]);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return { rows: filteredRows, indexMap: filteredMap };
|
|
575
|
+
}
|
|
576
|
+
_applySorting(rows, indexMap) {
|
|
577
|
+
const colIdx = this._state.sortColumn;
|
|
578
|
+
const asc = this._state.sortAscending;
|
|
579
|
+
const paired = rows.map((row, i) => ({ row, origIdx: indexMap[i] }));
|
|
580
|
+
const isNumeric = this._columnTypes[colIdx] === "num";
|
|
581
|
+
paired.sort((a, b) => {
|
|
582
|
+
let valA = a.row[colIdx];
|
|
583
|
+
let valB = b.row[colIdx];
|
|
584
|
+
if (valA == null && valB == null) return 0;
|
|
585
|
+
if (valA == null) return 1;
|
|
586
|
+
if (valB == null) return -1;
|
|
587
|
+
if (isNumeric) {
|
|
588
|
+
valA = typeof valA === "number" ? valA : parseFloat(valA);
|
|
589
|
+
valB = typeof valB === "number" ? valB : parseFloat(valB);
|
|
590
|
+
if (!Number.isFinite(valA)) return 1;
|
|
591
|
+
if (!Number.isFinite(valB)) return -1;
|
|
592
|
+
} else {
|
|
593
|
+
valA = String(valA).toLowerCase();
|
|
594
|
+
valB = String(valB).toLowerCase();
|
|
595
|
+
}
|
|
596
|
+
let cmp = 0;
|
|
597
|
+
if (valA < valB) cmp = -1;
|
|
598
|
+
else if (valA > valB) cmp = 1;
|
|
599
|
+
return asc ? cmp : -cmp;
|
|
600
|
+
});
|
|
601
|
+
return {
|
|
602
|
+
rows: paired.map((p) => p.row),
|
|
603
|
+
indexMap: paired.map((p) => p.origIdx)
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
_matchNumericFilter(value, filterText) {
|
|
607
|
+
const text = filterText.trim();
|
|
608
|
+
if (!text) return true;
|
|
609
|
+
const numVal = typeof value === "number" ? value : parseFloat(value);
|
|
610
|
+
if (!Number.isFinite(numVal)) return false;
|
|
611
|
+
const rangeMatch = text.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
|
|
612
|
+
if (rangeMatch) {
|
|
613
|
+
const lo = parseFloat(rangeMatch[1]);
|
|
614
|
+
const hi = parseFloat(rangeMatch[2]);
|
|
615
|
+
return numVal >= lo && numVal <= hi;
|
|
616
|
+
}
|
|
617
|
+
const opMatch = text.match(/^(>=|<=|!=|>|<|=)\s*(-?[\d.]+)$/);
|
|
618
|
+
if (opMatch) {
|
|
619
|
+
const op = opMatch[1];
|
|
620
|
+
const target = parseFloat(opMatch[2]);
|
|
621
|
+
if (!Number.isFinite(target)) return true;
|
|
622
|
+
switch (op) {
|
|
623
|
+
case ">":
|
|
624
|
+
return numVal > target;
|
|
625
|
+
case "<":
|
|
626
|
+
return numVal < target;
|
|
627
|
+
case ">=":
|
|
628
|
+
return numVal >= target;
|
|
629
|
+
case "<=":
|
|
630
|
+
return numVal <= target;
|
|
631
|
+
case "!=":
|
|
632
|
+
return Math.abs(numVal - target) > 1e-9;
|
|
633
|
+
case "=":
|
|
634
|
+
return Math.abs(numVal - target) <= 1e-9;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const plain = parseFloat(text);
|
|
638
|
+
if (Number.isFinite(plain)) {
|
|
639
|
+
return Math.abs(numVal - plain) <= 1e-9;
|
|
640
|
+
}
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
_matchTextFilter(value, filterText) {
|
|
644
|
+
if (!filterText) return true;
|
|
645
|
+
const haystack = (value == null ? "" : String(value)).toLowerCase();
|
|
646
|
+
const text = filterText.trim();
|
|
647
|
+
if (text.startsWith('="') && text.endsWith('"')) {
|
|
648
|
+
return haystack === text.slice(2, -1).toLowerCase();
|
|
649
|
+
}
|
|
650
|
+
if (text.startsWith("!")) {
|
|
651
|
+
return !haystack.includes(text.slice(1).toLowerCase());
|
|
652
|
+
}
|
|
653
|
+
if (text.startsWith("^")) {
|
|
654
|
+
return haystack.startsWith(text.slice(1).toLowerCase());
|
|
655
|
+
}
|
|
656
|
+
if (text.endsWith("$")) {
|
|
657
|
+
return haystack.endsWith(text.slice(0, -1).toLowerCase());
|
|
658
|
+
}
|
|
659
|
+
return haystack.includes(text.toLowerCase());
|
|
660
|
+
}
|
|
661
|
+
_invalidateProcessedCache() {
|
|
662
|
+
this._processedRows = null;
|
|
663
|
+
this._processedIndexMap = null;
|
|
664
|
+
}
|
|
665
|
+
// ─────────────────────────────────────────────────────────────────
|
|
666
|
+
// Filter row
|
|
667
|
+
// ─────────────────────────────────────────────────────────────────
|
|
668
|
+
_createFilterRow(headers) {
|
|
669
|
+
const { showRowNumbers } = this.config;
|
|
670
|
+
const tr = document.createElement("tr");
|
|
671
|
+
tr.className = "twm-data-table__filter-row";
|
|
672
|
+
if (showRowNumbers) {
|
|
673
|
+
const th = document.createElement("th");
|
|
674
|
+
th.className = "data-table__filter-cell data-table__filter-cell--empty";
|
|
675
|
+
tr.appendChild(th);
|
|
676
|
+
}
|
|
677
|
+
headers.forEach((header, colIdx) => {
|
|
678
|
+
const th = document.createElement("th");
|
|
679
|
+
th.className = "data-table__filter-cell";
|
|
680
|
+
const wrapper = document.createElement("div");
|
|
681
|
+
wrapper.className = "twm-data-table__filter-wrapper";
|
|
682
|
+
const input = document.createElement("input");
|
|
683
|
+
input.type = "text";
|
|
684
|
+
input.className = "twm-data-table__filter-input";
|
|
685
|
+
const isNumeric = this._columnTypes[colIdx] === "num";
|
|
686
|
+
input.placeholder = isNumeric ? "e.g. >100" : "Filter...";
|
|
687
|
+
const existingFilter = this._state.filters.get(colIdx);
|
|
688
|
+
if (existingFilter) {
|
|
689
|
+
input.value = existingFilter;
|
|
690
|
+
}
|
|
691
|
+
const dropdownBtn = document.createElement("button");
|
|
692
|
+
dropdownBtn.type = "button";
|
|
693
|
+
dropdownBtn.className = "twm-data-table__filter-dropdown-btn";
|
|
694
|
+
dropdownBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:14px;">tune</span>';
|
|
695
|
+
dropdownBtn.addEventListener("click", (e) => {
|
|
696
|
+
e.stopPropagation();
|
|
697
|
+
this._openFilterDropdown(colIdx, th, input);
|
|
698
|
+
});
|
|
699
|
+
const clearBtn = document.createElement("button");
|
|
700
|
+
clearBtn.type = "button";
|
|
701
|
+
clearBtn.className = "twm-data-table__filter-clear";
|
|
702
|
+
clearBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:12px;">close</span>';
|
|
703
|
+
clearBtn.style.display = existingFilter ? "" : "none";
|
|
704
|
+
clearBtn.addEventListener("click", (e) => {
|
|
705
|
+
e.stopPropagation();
|
|
706
|
+
input.value = "";
|
|
707
|
+
this._onFilterInput(colIdx, "");
|
|
708
|
+
clearBtn.style.display = "none";
|
|
709
|
+
input.focus();
|
|
710
|
+
});
|
|
711
|
+
input.addEventListener("input", () => {
|
|
712
|
+
clearBtn.style.display = input.value ? "" : "none";
|
|
713
|
+
this._onFilterInputDebounced(colIdx, input.value);
|
|
714
|
+
});
|
|
715
|
+
th.addEventListener("click", (e) => e.stopPropagation());
|
|
716
|
+
wrapper.appendChild(input);
|
|
717
|
+
wrapper.appendChild(dropdownBtn);
|
|
718
|
+
wrapper.appendChild(clearBtn);
|
|
719
|
+
th.appendChild(wrapper);
|
|
720
|
+
tr.appendChild(th);
|
|
721
|
+
});
|
|
722
|
+
return tr;
|
|
723
|
+
}
|
|
724
|
+
// ─────────────────────────────────────────────────────────────────
|
|
725
|
+
// Filter dropdown
|
|
726
|
+
// ─────────────────────────────────────────────────────────────────
|
|
727
|
+
_openFilterDropdown(colIdx, anchorEl, filterInput) {
|
|
728
|
+
this._closeFilterDropdown();
|
|
729
|
+
const isNumeric = this._columnTypes[colIdx] === "num";
|
|
730
|
+
const currentFilter = this._state.filters.get(colIdx) || "";
|
|
731
|
+
const parsed = this._parseFilterForDropdown(currentFilter, isNumeric);
|
|
732
|
+
const panel = document.createElement("div");
|
|
733
|
+
panel.className = "twm-data-table__filter-dropdown";
|
|
734
|
+
const selectLabel = document.createElement("label");
|
|
735
|
+
selectLabel.className = "twm-data-table__filter-dropdown-label";
|
|
736
|
+
selectLabel.textContent = isNumeric ? "Operator" : "Mode";
|
|
737
|
+
const select = document.createElement("select");
|
|
738
|
+
select.className = "twm-data-table__filter-dropdown-select";
|
|
739
|
+
const options = isNumeric ? [
|
|
740
|
+
{ value: "=", label: "Equals" },
|
|
741
|
+
{ value: "!=", label: "Not equals" },
|
|
742
|
+
{ value: ">", label: "Greater than" },
|
|
743
|
+
{ value: ">=", label: "Greater or equal" },
|
|
744
|
+
{ value: "<", label: "Less than" },
|
|
745
|
+
{ value: "<=", label: "Less or equal" },
|
|
746
|
+
{ value: "..", label: "Between" }
|
|
747
|
+
] : [
|
|
748
|
+
{ value: "contains", label: "Contains" },
|
|
749
|
+
{ value: "equals", label: "Equals" },
|
|
750
|
+
{ value: "starts", label: "Starts with" },
|
|
751
|
+
{ value: "ends", label: "Ends with" },
|
|
752
|
+
{ value: "not", label: "Not contains" }
|
|
753
|
+
];
|
|
754
|
+
for (const opt of options) {
|
|
755
|
+
const optEl = document.createElement("option");
|
|
756
|
+
optEl.value = opt.value;
|
|
757
|
+
optEl.textContent = opt.label;
|
|
758
|
+
if (opt.value === parsed.operator) optEl.selected = true;
|
|
759
|
+
select.appendChild(optEl);
|
|
760
|
+
}
|
|
761
|
+
const valueLabel = document.createElement("label");
|
|
762
|
+
valueLabel.className = "twm-data-table__filter-dropdown-label";
|
|
763
|
+
valueLabel.textContent = "Value";
|
|
764
|
+
const valueInput = document.createElement("input");
|
|
765
|
+
valueInput.type = isNumeric ? "number" : "text";
|
|
766
|
+
valueInput.className = "twm-data-table__filter-dropdown-input";
|
|
767
|
+
valueInput.placeholder = isNumeric ? "Number..." : "Text...";
|
|
768
|
+
valueInput.value = parsed.value;
|
|
769
|
+
const value2Label = document.createElement("label");
|
|
770
|
+
value2Label.className = "twm-data-table__filter-dropdown-label";
|
|
771
|
+
value2Label.textContent = "And";
|
|
772
|
+
const value2Input = document.createElement("input");
|
|
773
|
+
value2Input.type = "number";
|
|
774
|
+
value2Input.className = "twm-data-table__filter-dropdown-input";
|
|
775
|
+
value2Input.placeholder = "Number...";
|
|
776
|
+
value2Input.value = parsed.value2;
|
|
777
|
+
const value2Container = document.createElement("div");
|
|
778
|
+
value2Container.className = "twm-data-table__filter-dropdown-between";
|
|
779
|
+
value2Container.style.display = isNumeric && parsed.operator === ".." ? "" : "none";
|
|
780
|
+
value2Container.appendChild(value2Label);
|
|
781
|
+
value2Container.appendChild(value2Input);
|
|
782
|
+
select.addEventListener("change", () => {
|
|
783
|
+
value2Container.style.display = isNumeric && select.value === ".." ? "" : "none";
|
|
784
|
+
});
|
|
785
|
+
const actions = document.createElement("div");
|
|
786
|
+
actions.className = "twm-data-table__filter-dropdown-actions";
|
|
787
|
+
const clearBtn = document.createElement("button");
|
|
788
|
+
clearBtn.type = "button";
|
|
789
|
+
clearBtn.className = "twm-data-table__filter-dropdown-btn-action twm-data-table__filter-dropdown-btn-action--clear";
|
|
790
|
+
clearBtn.textContent = "Clear";
|
|
791
|
+
clearBtn.addEventListener("click", (e) => {
|
|
792
|
+
e.stopPropagation();
|
|
793
|
+
filterInput.value = "";
|
|
794
|
+
this._onFilterInput(colIdx, "");
|
|
795
|
+
this._closeFilterDropdown();
|
|
796
|
+
});
|
|
797
|
+
const applyBtn = document.createElement("button");
|
|
798
|
+
applyBtn.type = "button";
|
|
799
|
+
applyBtn.className = "twm-data-table__filter-dropdown-btn-action twm-data-table__filter-dropdown-btn-action--apply";
|
|
800
|
+
applyBtn.textContent = "Apply";
|
|
801
|
+
applyBtn.addEventListener("click", (e) => {
|
|
802
|
+
e.stopPropagation();
|
|
803
|
+
const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, isNumeric);
|
|
804
|
+
filterInput.value = composed;
|
|
805
|
+
this._onFilterInput(colIdx, composed);
|
|
806
|
+
this._closeFilterDropdown();
|
|
807
|
+
});
|
|
808
|
+
actions.appendChild(clearBtn);
|
|
809
|
+
actions.appendChild(applyBtn);
|
|
810
|
+
panel.appendChild(selectLabel);
|
|
811
|
+
panel.appendChild(select);
|
|
812
|
+
panel.appendChild(valueLabel);
|
|
813
|
+
panel.appendChild(valueInput);
|
|
814
|
+
panel.appendChild(value2Container);
|
|
815
|
+
panel.appendChild(actions);
|
|
816
|
+
document.body.appendChild(panel);
|
|
817
|
+
const anchorRect = anchorEl.getBoundingClientRect();
|
|
818
|
+
let left = anchorRect.left;
|
|
819
|
+
let top = anchorRect.bottom + 4;
|
|
820
|
+
const panelRect = panel.getBoundingClientRect();
|
|
821
|
+
if (left + panelRect.width > window.innerWidth) {
|
|
822
|
+
left = window.innerWidth - panelRect.width - 8;
|
|
823
|
+
}
|
|
824
|
+
if (top + panelRect.height > window.innerHeight) {
|
|
825
|
+
top = anchorRect.top - panelRect.height - 4;
|
|
826
|
+
}
|
|
827
|
+
panel.style.left = `${left}px`;
|
|
828
|
+
panel.style.top = `${top}px`;
|
|
829
|
+
requestAnimationFrame(() => valueInput.focus());
|
|
830
|
+
const handleOutsideClick = (e) => {
|
|
831
|
+
if (!panel.contains(e.target)) {
|
|
832
|
+
this._closeFilterDropdown();
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
const handleEscape = (e) => {
|
|
836
|
+
if (e.key === "Escape") {
|
|
837
|
+
this._closeFilterDropdown();
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
const handleEnter = (e) => {
|
|
841
|
+
if (e.key === "Enter") {
|
|
842
|
+
e.preventDefault();
|
|
843
|
+
const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, isNumeric);
|
|
844
|
+
filterInput.value = composed;
|
|
845
|
+
this._onFilterInput(colIdx, composed);
|
|
846
|
+
this._closeFilterDropdown();
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
setTimeout(() => {
|
|
850
|
+
document.addEventListener("click", handleOutsideClick, true);
|
|
851
|
+
}, 0);
|
|
852
|
+
document.addEventListener("keydown", handleEscape);
|
|
853
|
+
panel.addEventListener("keydown", handleEnter);
|
|
854
|
+
this._filterDropdownEl = panel;
|
|
855
|
+
this._filterDropdownCleanup = () => {
|
|
856
|
+
document.removeEventListener("click", handleOutsideClick, true);
|
|
857
|
+
document.removeEventListener("keydown", handleEscape);
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
_closeFilterDropdown() {
|
|
861
|
+
if (this._filterDropdownEl?.parentNode) {
|
|
862
|
+
this._filterDropdownEl.parentNode.removeChild(this._filterDropdownEl);
|
|
863
|
+
}
|
|
864
|
+
this._filterDropdownCleanup?.();
|
|
865
|
+
this._filterDropdownEl = null;
|
|
866
|
+
this._filterDropdownCleanup = null;
|
|
867
|
+
}
|
|
868
|
+
_parseFilterForDropdown(filterText, isNumeric) {
|
|
869
|
+
const text = (filterText || "").trim();
|
|
870
|
+
if (!text) {
|
|
871
|
+
return { operator: isNumeric ? "=" : "contains", value: "", value2: "" };
|
|
872
|
+
}
|
|
873
|
+
if (isNumeric) {
|
|
874
|
+
const rangeMatch = text.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
|
|
875
|
+
if (rangeMatch) {
|
|
876
|
+
return { operator: "..", value: rangeMatch[1], value2: rangeMatch[2] };
|
|
877
|
+
}
|
|
878
|
+
const opMatch = text.match(/^(>=|<=|!=|>|<|=)\s*(-?[\d.]+)$/);
|
|
879
|
+
if (opMatch) {
|
|
880
|
+
return { operator: opMatch[1], value: opMatch[2], value2: "" };
|
|
881
|
+
}
|
|
882
|
+
return { operator: "=", value: text, value2: "" };
|
|
883
|
+
}
|
|
884
|
+
if (text.startsWith('="') && text.endsWith('"')) {
|
|
885
|
+
return { operator: "equals", value: text.slice(2, -1), value2: "" };
|
|
886
|
+
}
|
|
887
|
+
if (text.startsWith("!")) {
|
|
888
|
+
return { operator: "not", value: text.slice(1), value2: "" };
|
|
889
|
+
}
|
|
890
|
+
if (text.startsWith("^")) {
|
|
891
|
+
return { operator: "starts", value: text.slice(1), value2: "" };
|
|
892
|
+
}
|
|
893
|
+
if (text.endsWith("$")) {
|
|
894
|
+
return { operator: "ends", value: text.slice(0, -1), value2: "" };
|
|
895
|
+
}
|
|
896
|
+
return { operator: "contains", value: text, value2: "" };
|
|
897
|
+
}
|
|
898
|
+
_composeFilterString(operator, value, value2, isNumeric) {
|
|
899
|
+
if (!value && operator !== "..") return "";
|
|
900
|
+
if (isNumeric) {
|
|
901
|
+
if (operator === "..") {
|
|
902
|
+
return value && value2 ? `${value}..${value2}` : "";
|
|
903
|
+
}
|
|
904
|
+
if (operator === "=") return value;
|
|
905
|
+
return `${operator}${value}`;
|
|
906
|
+
}
|
|
907
|
+
switch (operator) {
|
|
908
|
+
case "contains":
|
|
909
|
+
return value;
|
|
910
|
+
case "equals":
|
|
911
|
+
return value ? `="${value}"` : "";
|
|
912
|
+
case "starts":
|
|
913
|
+
return value ? `^${value}` : "";
|
|
914
|
+
case "ends":
|
|
915
|
+
return value ? `${value}$` : "";
|
|
916
|
+
case "not":
|
|
917
|
+
return value ? `!${value}` : "";
|
|
918
|
+
default:
|
|
919
|
+
return value;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
_onFilterInputDebounced(colIdx, value) {
|
|
923
|
+
if (this._filterDebounceTimer) {
|
|
924
|
+
clearTimeout(this._filterDebounceTimer);
|
|
925
|
+
}
|
|
926
|
+
this._filterDebounceTimer = setTimeout(() => {
|
|
927
|
+
this._onFilterInput(colIdx, value);
|
|
928
|
+
}, 200);
|
|
929
|
+
}
|
|
930
|
+
_onFilterInput(colIdx, value) {
|
|
931
|
+
if (value) {
|
|
932
|
+
this._state.filters.set(colIdx, value);
|
|
933
|
+
} else {
|
|
934
|
+
this._state.filters.delete(colIdx);
|
|
935
|
+
}
|
|
936
|
+
this._invalidateProcessedCache();
|
|
937
|
+
this._state.offset = 0;
|
|
938
|
+
this._state.selected.clear();
|
|
939
|
+
this._state.anchorIndex = null;
|
|
940
|
+
this._savePersisted();
|
|
941
|
+
this.render();
|
|
942
|
+
}
|
|
943
|
+
_getActiveFilterColIdx() {
|
|
944
|
+
const active = document.activeElement;
|
|
945
|
+
if (!active || !active.classList.contains("twm-data-table__filter-input")) return null;
|
|
946
|
+
const cell = active.closest(".data-table__filter-cell");
|
|
947
|
+
if (!cell) return null;
|
|
948
|
+
const row = cell.parentElement;
|
|
949
|
+
if (!row) return null;
|
|
950
|
+
const cells = Array.from(row.children);
|
|
951
|
+
const idx = cells.indexOf(cell);
|
|
952
|
+
return this.config.showRowNumbers ? idx - 1 : idx;
|
|
953
|
+
}
|
|
954
|
+
_restoreFilterFocus(colIdx) {
|
|
955
|
+
const filterRow = this._wrapperEl?.querySelector(".twm-data-table__filter-row");
|
|
956
|
+
if (!filterRow) return;
|
|
957
|
+
const cellIdx = this.config.showRowNumbers ? colIdx + 1 : colIdx;
|
|
958
|
+
const cell = filterRow.children[cellIdx];
|
|
959
|
+
const input = cell?.querySelector(".twm-data-table__filter-input");
|
|
960
|
+
if (input) {
|
|
961
|
+
input.focus();
|
|
962
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// ─────────────────────────────────────────────────────────────────
|
|
966
|
+
// Private methods
|
|
967
|
+
// ─────────────────────────────────────────────────────────────────
|
|
968
|
+
_cleanup() {
|
|
969
|
+
this._disposers.forEach((dispose) => {
|
|
970
|
+
try {
|
|
971
|
+
dispose?.();
|
|
972
|
+
} catch (_) {
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
this._disposers = [];
|
|
976
|
+
this._closeFilterDropdown();
|
|
977
|
+
if (this._filterDebounceTimer) {
|
|
978
|
+
clearTimeout(this._filterDebounceTimer);
|
|
979
|
+
this._filterDebounceTimer = null;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
_detectColumnTypes() {
|
|
983
|
+
const { headers, rows, getColumnType } = this.config;
|
|
984
|
+
const types = [];
|
|
985
|
+
for (let colIdx = 0; colIdx < headers.length; colIdx++) {
|
|
986
|
+
if (getColumnType) {
|
|
987
|
+
types.push(getColumnType(colIdx, rows));
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
let numericCount = 0;
|
|
991
|
+
let sampleCount = 0;
|
|
992
|
+
const maxSamples = Math.min(10, rows.length);
|
|
993
|
+
for (let i = 0; i < maxSamples; i++) {
|
|
994
|
+
const value = rows[i]?.[colIdx];
|
|
995
|
+
if (value != null && value !== "") {
|
|
996
|
+
sampleCount++;
|
|
997
|
+
if (typeof value === "number" || /^-?[\d,.]+%?$/.test(String(value).trim())) {
|
|
998
|
+
numericCount++;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
types.push(sampleCount > 0 && numericCount / sampleCount > 0.5 ? "num" : "text");
|
|
1003
|
+
}
|
|
1004
|
+
return types;
|
|
1005
|
+
}
|
|
1006
|
+
_createPagination() {
|
|
1007
|
+
const { rows, pageSize, totalCount: configTotalCount, offset: configOffset, onPageChange, onJumpToRow } = this.config;
|
|
1008
|
+
const isServerSide = typeof onPageChange === "function";
|
|
1009
|
+
const offset = isServerSide ? configOffset || 0 : this._state.offset;
|
|
1010
|
+
const processedRows = isServerSide ? rows : this._getProcessedRows();
|
|
1011
|
+
const effectiveTotal = isServerSide ? configTotalCount || rows.length : processedRows.length;
|
|
1012
|
+
const unfilteredTotal = isServerSide ? configTotalCount || rows.length : rows.length;
|
|
1013
|
+
const isFiltered = !isServerSide && this._state.filters.size > 0;
|
|
1014
|
+
const displayedRows = isServerSide ? rows.length : Math.min(pageSize, effectiveTotal - offset);
|
|
1015
|
+
const endRow = Math.min(offset + displayedRows, effectiveTotal);
|
|
1016
|
+
const totalPages = Math.max(1, Math.ceil(effectiveTotal / pageSize));
|
|
1017
|
+
if (totalPages <= 1 && !isFiltered) return null;
|
|
1018
|
+
const currentPage = Math.floor(offset / pageSize) + 1;
|
|
1019
|
+
const hasPrev = offset > 0;
|
|
1020
|
+
const hasNext = offset + pageSize < effectiveTotal;
|
|
1021
|
+
const el = document.createElement("div");
|
|
1022
|
+
el.className = "twm-pagination-controls";
|
|
1023
|
+
el.style.cssText = "display:flex; align-items:center; gap:6px; padding:2px 8px; border-bottom:1px solid #2a2a2a; font-size:11px;";
|
|
1024
|
+
const info = document.createElement("span");
|
|
1025
|
+
info.style.cssText = "color:#aaa; white-space:nowrap;";
|
|
1026
|
+
if (effectiveTotal === 0) {
|
|
1027
|
+
info.textContent = isFiltered ? `0 of ${unfilteredTotal.toLocaleString()} rows match` : "No rows";
|
|
1028
|
+
} else if (isFiltered) {
|
|
1029
|
+
info.textContent = `Rows ${offset + 1}\u2013${endRow} of ${effectiveTotal.toLocaleString()} (${unfilteredTotal.toLocaleString()} total)`;
|
|
1030
|
+
} else {
|
|
1031
|
+
info.textContent = `Rows ${offset + 1}\u2013${endRow} of ${effectiveTotal.toLocaleString()}`;
|
|
1032
|
+
}
|
|
1033
|
+
const spacer = document.createElement("div");
|
|
1034
|
+
spacer.style.flex = "1";
|
|
1035
|
+
const handlePageChange = (newOffset) => {
|
|
1036
|
+
if (isServerSide) {
|
|
1037
|
+
this._awaitWithSpinner(onPageChange(newOffset, pageSize));
|
|
1038
|
+
} else {
|
|
1039
|
+
this._state.offset = newOffset;
|
|
1040
|
+
this.render();
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
const firstBtn = this._createPaginationBtn("first_page", "First page", !hasPrev, () => {
|
|
1044
|
+
handlePageChange(0);
|
|
1045
|
+
});
|
|
1046
|
+
const prevBtn = this._createPaginationBtn("chevron_left", "Previous page", !hasPrev, () => {
|
|
1047
|
+
handlePageChange(Math.max(0, offset - pageSize));
|
|
1048
|
+
});
|
|
1049
|
+
const pageInfo = document.createElement("span");
|
|
1050
|
+
pageInfo.style.cssText = "color:#aaa; font-size:10px; min-width:72px; text-align:center;";
|
|
1051
|
+
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
|
|
1052
|
+
const nextBtn = this._createPaginationBtn("chevron_right", "Next page", !hasNext, () => {
|
|
1053
|
+
handlePageChange(offset + pageSize);
|
|
1054
|
+
});
|
|
1055
|
+
const lastBtn = this._createPaginationBtn("last_page", "Last page", !hasNext, () => {
|
|
1056
|
+
handlePageChange(Math.floor((effectiveTotal - 1) / pageSize) * pageSize);
|
|
1057
|
+
});
|
|
1058
|
+
const jumpInput = document.createElement("input");
|
|
1059
|
+
jumpInput.type = "number";
|
|
1060
|
+
jumpInput.className = "twm-pagination-input";
|
|
1061
|
+
jumpInput.style.cssText = "width:54px; padding:1px 4px; border:1px solid #444; background:#2a2a2a; color:#ccc; font-size:10px;";
|
|
1062
|
+
jumpInput.min = "1";
|
|
1063
|
+
jumpInput.max = String(effectiveTotal);
|
|
1064
|
+
jumpInput.placeholder = "Row #";
|
|
1065
|
+
const jumpBtn = this._createPaginationBtn(null, "Go to row", false, () => {
|
|
1066
|
+
const target = Number(jumpInput.value);
|
|
1067
|
+
if (Number.isFinite(target) && target >= 1) {
|
|
1068
|
+
if (isServerSide && onJumpToRow) {
|
|
1069
|
+
onJumpToRow(target - 1);
|
|
1070
|
+
} else if (isServerSide) {
|
|
1071
|
+
const newOffset = Math.floor((target - 1) / pageSize) * pageSize;
|
|
1072
|
+
handlePageChange(newOffset);
|
|
1073
|
+
} else {
|
|
1074
|
+
this.goToRow(target - 1);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}, "Go");
|
|
1078
|
+
el.appendChild(info);
|
|
1079
|
+
el.appendChild(spacer);
|
|
1080
|
+
el.appendChild(firstBtn);
|
|
1081
|
+
el.appendChild(prevBtn);
|
|
1082
|
+
el.appendChild(pageInfo);
|
|
1083
|
+
el.appendChild(nextBtn);
|
|
1084
|
+
el.appendChild(lastBtn);
|
|
1085
|
+
el.appendChild(jumpInput);
|
|
1086
|
+
el.appendChild(jumpBtn);
|
|
1087
|
+
if (this.config.showExportButton) {
|
|
1088
|
+
const exportBtn = this._createPaginationBtn(
|
|
1089
|
+
"download",
|
|
1090
|
+
"Download visible rows as CSV",
|
|
1091
|
+
false,
|
|
1092
|
+
() => this.downloadCSV("data.csv")
|
|
1093
|
+
);
|
|
1094
|
+
el.appendChild(exportBtn);
|
|
1095
|
+
}
|
|
1096
|
+
return el;
|
|
1097
|
+
}
|
|
1098
|
+
_createPaginationBtn(icon, tooltip, disabled, onClick, textLabel = null) {
|
|
1099
|
+
const btn = document.createElement("button");
|
|
1100
|
+
btn.type = "button";
|
|
1101
|
+
btn.className = "hoverbutton twm-pagination-btn twm-has-tooltip";
|
|
1102
|
+
btn.setAttribute("data-tooltip", tooltip);
|
|
1103
|
+
btn.disabled = disabled;
|
|
1104
|
+
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;";
|
|
1105
|
+
if (disabled) {
|
|
1106
|
+
btn.style.opacity = "0.4";
|
|
1107
|
+
btn.style.cursor = "not-allowed";
|
|
1108
|
+
}
|
|
1109
|
+
if (icon) {
|
|
1110
|
+
const iconEl = document.createElement("span");
|
|
1111
|
+
iconEl.className = "material-symbols-outlined";
|
|
1112
|
+
iconEl.style.fontSize = "14px";
|
|
1113
|
+
iconEl.textContent = icon;
|
|
1114
|
+
btn.appendChild(iconEl);
|
|
1115
|
+
} else if (textLabel) {
|
|
1116
|
+
btn.textContent = textLabel;
|
|
1117
|
+
btn.style.fontSize = "10px";
|
|
1118
|
+
btn.style.padding = "1px 6px";
|
|
1119
|
+
}
|
|
1120
|
+
btn.addEventListener("click", onClick);
|
|
1121
|
+
return btn;
|
|
1122
|
+
}
|
|
1123
|
+
_createTable() {
|
|
1124
|
+
const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
|
|
1125
|
+
const { selected, sortColumn, sortAscending } = this._state;
|
|
1126
|
+
const isServerSide = typeof onPageChange === "function";
|
|
1127
|
+
const processedRows = isServerSide ? rows : this._getProcessedRows();
|
|
1128
|
+
const offset = isServerSide ? configOffset || 0 : this._state.offset;
|
|
1129
|
+
const pageRows = isServerSide ? processedRows : processedRows.slice(offset, offset + pageSize);
|
|
1130
|
+
const table = document.createElement("table");
|
|
1131
|
+
table.className = readonly ? "twm-preview-table twm-preview-table--readonly" : "twm-preview-table";
|
|
1132
|
+
table.tabIndex = 0;
|
|
1133
|
+
const thead = document.createElement("thead");
|
|
1134
|
+
const headerRow = document.createElement("tr");
|
|
1135
|
+
if (showRowNumbers) {
|
|
1136
|
+
const th = document.createElement("th");
|
|
1137
|
+
th.className = "num";
|
|
1138
|
+
th.textContent = "#";
|
|
1139
|
+
headerRow.appendChild(th);
|
|
1140
|
+
}
|
|
1141
|
+
headers.forEach((header, colIdx) => {
|
|
1142
|
+
const th = document.createElement("th");
|
|
1143
|
+
th.className = this._columnTypes[colIdx] || "text";
|
|
1144
|
+
if (sortable) {
|
|
1145
|
+
th.classList.add("sortable");
|
|
1146
|
+
th.style.cursor = "pointer";
|
|
1147
|
+
}
|
|
1148
|
+
if (getHeaderIcon) {
|
|
1149
|
+
const iconInfo = getHeaderIcon(header, colIdx);
|
|
1150
|
+
if (iconInfo) {
|
|
1151
|
+
const iconSpan = document.createElement("span");
|
|
1152
|
+
iconSpan.className = "material-symbols-outlined twm-header-icon twm-has-tooltip";
|
|
1153
|
+
iconSpan.setAttribute("data-tooltip", iconInfo.title || "");
|
|
1154
|
+
iconSpan.style.cssText = "font-size:16px; vertical-align:middle; margin-right:4px; opacity:0.7;";
|
|
1155
|
+
iconSpan.textContent = iconInfo.icon;
|
|
1156
|
+
th.appendChild(iconSpan);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
th.appendChild(document.createTextNode(header));
|
|
1160
|
+
if (sortable) {
|
|
1161
|
+
const sortIcon = document.createElement("span");
|
|
1162
|
+
sortIcon.className = "material-symbols-outlined twm-sort-icon";
|
|
1163
|
+
sortIcon.style.cssText = "font-size:14px; margin-left:4px; opacity:0.5;";
|
|
1164
|
+
if (sortColumn === colIdx) {
|
|
1165
|
+
sortIcon.textContent = sortAscending ? "arrow_upward" : "arrow_downward";
|
|
1166
|
+
sortIcon.style.opacity = "1";
|
|
1167
|
+
} else {
|
|
1168
|
+
sortIcon.textContent = "unfold_more";
|
|
1169
|
+
}
|
|
1170
|
+
th.appendChild(sortIcon);
|
|
1171
|
+
th.addEventListener("click", () => this.sortBy(colIdx));
|
|
1172
|
+
}
|
|
1173
|
+
th.title = header;
|
|
1174
|
+
headerRow.appendChild(th);
|
|
1175
|
+
});
|
|
1176
|
+
thead.appendChild(headerRow);
|
|
1177
|
+
if (filterable) {
|
|
1178
|
+
const filterRow = this._createFilterRow(headers);
|
|
1179
|
+
thead.appendChild(filterRow);
|
|
1180
|
+
}
|
|
1181
|
+
table.appendChild(thead);
|
|
1182
|
+
const tbody = document.createElement("tbody");
|
|
1183
|
+
pageRows.forEach((row, localIdx) => {
|
|
1184
|
+
const processedIdx = offset + localIdx;
|
|
1185
|
+
const globalIdx = isServerSide ? (configOffset || 0) + localIdx : this._processedIndexMap?.[processedIdx] ?? processedIdx;
|
|
1186
|
+
const tr = document.createElement("tr");
|
|
1187
|
+
tr.__rowIndex = globalIdx;
|
|
1188
|
+
tr.className = "data-preview-row";
|
|
1189
|
+
if (selected.has(globalIdx)) {
|
|
1190
|
+
tr.classList.add("selected");
|
|
1191
|
+
}
|
|
1192
|
+
if (showRowNumbers) {
|
|
1193
|
+
const td = document.createElement("td");
|
|
1194
|
+
td.className = "num";
|
|
1195
|
+
td.textContent = String(globalIdx + 1);
|
|
1196
|
+
tr.appendChild(td);
|
|
1197
|
+
}
|
|
1198
|
+
for (let colIdx = 0; colIdx < row.length; colIdx++) {
|
|
1199
|
+
const td = document.createElement("td");
|
|
1200
|
+
td.className = this._columnTypes[colIdx] || "text";
|
|
1201
|
+
const value = row[colIdx];
|
|
1202
|
+
if (this.config.renderCell) {
|
|
1203
|
+
const handled = this.config.renderCell(td, value, colIdx, globalIdx, row);
|
|
1204
|
+
if (!handled) {
|
|
1205
|
+
td.textContent = this._formatValue(value, colIdx);
|
|
1206
|
+
}
|
|
1207
|
+
} else {
|
|
1208
|
+
td.textContent = this._formatValue(value, colIdx);
|
|
1209
|
+
}
|
|
1210
|
+
if (this.config.onCellContextMenu) {
|
|
1211
|
+
const cellColIdx = colIdx;
|
|
1212
|
+
td.addEventListener("contextmenu", (ev) => {
|
|
1213
|
+
this.config.onCellContextMenu(
|
|
1214
|
+
cellColIdx,
|
|
1215
|
+
globalIdx,
|
|
1216
|
+
value,
|
|
1217
|
+
td,
|
|
1218
|
+
ev
|
|
1219
|
+
);
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
tr.appendChild(td);
|
|
1223
|
+
}
|
|
1224
|
+
if (this.config.onRowClick) {
|
|
1225
|
+
tr.addEventListener("click", (ev) => {
|
|
1226
|
+
this.config.onRowClick(globalIdx, row, ev);
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
if (this.config.onRowContextMenu) {
|
|
1230
|
+
tr.addEventListener("contextmenu", (ev) => {
|
|
1231
|
+
this.config.onRowContextMenu(globalIdx, row, ev);
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
tbody.appendChild(tr);
|
|
1235
|
+
});
|
|
1236
|
+
table.appendChild(tbody);
|
|
1237
|
+
this._tbodyEl = tbody;
|
|
1238
|
+
return table;
|
|
1239
|
+
}
|
|
1240
|
+
/** Sync the (separate) header table's column widths to the body
|
|
1241
|
+
* table's measured widths. Without this, the two tables compute
|
|
1242
|
+
* widths independently and the columns drift apart. Locks both
|
|
1243
|
+
* tables to `table-layout: fixed` and writes explicit width onto
|
|
1244
|
+
* each header cell of every header row (header + filter row). */
|
|
1245
|
+
_syncHeaderWidths() {
|
|
1246
|
+
const headerTable = this._headerTableEl;
|
|
1247
|
+
const bodyTable = this._tableEl;
|
|
1248
|
+
if (!headerTable || !bodyTable) return;
|
|
1249
|
+
const firstRow = bodyTable.querySelector("tbody > tr");
|
|
1250
|
+
if (!firstRow) return;
|
|
1251
|
+
const bodyCells = firstRow.children;
|
|
1252
|
+
if (!bodyCells.length) return;
|
|
1253
|
+
const headerRows = headerTable.querySelectorAll("thead > tr");
|
|
1254
|
+
headerRows.forEach((tr) => {
|
|
1255
|
+
for (const th of tr.children) this._clearCellWidth(th);
|
|
1256
|
+
});
|
|
1257
|
+
for (const td of bodyCells) this._clearCellWidth(td);
|
|
1258
|
+
headerTable.style.width = "";
|
|
1259
|
+
bodyTable.style.width = "";
|
|
1260
|
+
headerTable.classList.add("twm-dt-measuring");
|
|
1261
|
+
bodyTable.classList.add("twm-dt-measuring");
|
|
1262
|
+
bodyTable.offsetWidth;
|
|
1263
|
+
const labelRow = headerTable.querySelector("thead > tr");
|
|
1264
|
+
const cols = bodyCells.length;
|
|
1265
|
+
const natural = new Array(cols);
|
|
1266
|
+
for (let i = 0; i < cols; i++) {
|
|
1267
|
+
const body = bodyCells[i].getBoundingClientRect().width;
|
|
1268
|
+
const head = labelRow && labelRow.children[i] ? labelRow.children[i].getBoundingClientRect().width : 0;
|
|
1269
|
+
natural[i] = Math.max(body, head);
|
|
1270
|
+
}
|
|
1271
|
+
headerTable.classList.remove("twm-dt-measuring");
|
|
1272
|
+
bodyTable.classList.remove("twm-dt-measuring");
|
|
1273
|
+
const wrap = this._tableWrapEl;
|
|
1274
|
+
const headerWrap = this._headerWrapEl;
|
|
1275
|
+
const avail = wrap ? wrap.clientWidth : 0;
|
|
1276
|
+
const firstIdx = this.config.showRowNumbers && cols > 1 ? 1 : 0;
|
|
1277
|
+
const widths = this._fitColumnWidths(natural, avail, {
|
|
1278
|
+
firstIdx,
|
|
1279
|
+
overrides: this._colWidths
|
|
1280
|
+
});
|
|
1281
|
+
const total = widths.reduce((a, b) => a + b, 0);
|
|
1282
|
+
headerRows.forEach((tr) => {
|
|
1283
|
+
for (let i = 0; i < tr.children.length && i < widths.length; i++) {
|
|
1284
|
+
this._setCellWidth(tr.children[i], widths[i]);
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
for (let i = 0; i < bodyCells.length && i < widths.length; i++) {
|
|
1288
|
+
this._setCellWidth(bodyCells[i], widths[i]);
|
|
1289
|
+
}
|
|
1290
|
+
headerTable.style.tableLayout = "fixed";
|
|
1291
|
+
bodyTable.style.tableLayout = "fixed";
|
|
1292
|
+
const hasOverride = Object.keys(this._colWidths).length > 0;
|
|
1293
|
+
if (hasOverride || total > avail + 1) {
|
|
1294
|
+
headerTable.style.width = `${total}px`;
|
|
1295
|
+
bodyTable.style.width = `${total}px`;
|
|
1296
|
+
} else {
|
|
1297
|
+
headerTable.style.width = "";
|
|
1298
|
+
bodyTable.style.width = "";
|
|
1299
|
+
}
|
|
1300
|
+
if (wrap && headerWrap) {
|
|
1301
|
+
const sbw = Math.max(0, wrap.offsetWidth - wrap.clientWidth);
|
|
1302
|
+
headerWrap.style.paddingRight = sbw ? `${sbw}px` : "";
|
|
1303
|
+
}
|
|
1304
|
+
this._syncHeaderScroll();
|
|
1305
|
+
this._updateCellTooltips();
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Convert measured natural content widths into final column widths.
|
|
1309
|
+
*
|
|
1310
|
+
* Goals (the "intelligent" sizing):
|
|
1311
|
+
* - every column wants its content width (+a hair), clamped to a
|
|
1312
|
+
* sane [floor, cap]; the FIRST content column gets a more generous
|
|
1313
|
+
* cap so it shows its full value;
|
|
1314
|
+
* - user-dragged columns (`overrides`) are pinned, never grown/shrunk;
|
|
1315
|
+
* - if everything fits, grow the flexible columns evenly to fill the
|
|
1316
|
+
* width (no dead gap on the right);
|
|
1317
|
+
* - if it doesn't fit, protect the first column and water-fill-shrink
|
|
1318
|
+
* the rest (trim the widest first) until it fits; only if even the
|
|
1319
|
+
* floors overflow do we give up and let the body scroll sideways.
|
|
1320
|
+
*
|
|
1321
|
+
* @param {number[]} natural measured content width per column (px)
|
|
1322
|
+
* @param {number} avail usable width of the body wrap (px)
|
|
1323
|
+
* @param {{firstIdx?:number, overrides?:Object}} [opts]
|
|
1324
|
+
* @returns {number[]} final width per column (px)
|
|
1325
|
+
*/
|
|
1326
|
+
_fitColumnWidths(natural, avail, opts = {}) {
|
|
1327
|
+
const FLOOR = 40;
|
|
1328
|
+
const CAP = 360;
|
|
1329
|
+
const FIRST_CAP = 520;
|
|
1330
|
+
const PAD = 2;
|
|
1331
|
+
const n = natural.length;
|
|
1332
|
+
const firstIdx = opts.firstIdx ?? 0;
|
|
1333
|
+
const overrides = opts.overrides || {};
|
|
1334
|
+
const desired = new Array(n);
|
|
1335
|
+
const pinned = new Array(n).fill(false);
|
|
1336
|
+
for (let i = 0; i < n; i++) {
|
|
1337
|
+
if (overrides[i] != null) {
|
|
1338
|
+
desired[i] = Math.max(FLOOR, Math.round(overrides[i]));
|
|
1339
|
+
pinned[i] = true;
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
const cap = i === firstIdx ? FIRST_CAP : CAP;
|
|
1343
|
+
desired[i] = Math.min(cap, Math.max(FLOOR, Math.ceil(natural[i] + PAD)));
|
|
1344
|
+
}
|
|
1345
|
+
const widths = desired.slice();
|
|
1346
|
+
const sum = widths.reduce((a, b) => a + b, 0);
|
|
1347
|
+
if (avail <= 1) return widths;
|
|
1348
|
+
if (sum <= avail) {
|
|
1349
|
+
const flex = [];
|
|
1350
|
+
for (let i = 0; i < n; i++) if (!pinned[i]) flex.push(i);
|
|
1351
|
+
const slack = avail - sum;
|
|
1352
|
+
if (slack > 0 && flex.length) {
|
|
1353
|
+
const per = Math.floor(slack / flex.length);
|
|
1354
|
+
for (const i of flex) widths[i] += per;
|
|
1355
|
+
widths[flex[flex.length - 1]] += slack - per * flex.length;
|
|
1356
|
+
}
|
|
1357
|
+
return widths;
|
|
1358
|
+
}
|
|
1359
|
+
let protectedSum = 0;
|
|
1360
|
+
const shrinkable = [];
|
|
1361
|
+
for (let i = 0; i < n; i++) {
|
|
1362
|
+
if (pinned[i] || i === firstIdx) protectedSum += widths[i];
|
|
1363
|
+
else shrinkable.push(i);
|
|
1364
|
+
}
|
|
1365
|
+
const budget = avail - protectedSum;
|
|
1366
|
+
if (budget < shrinkable.length * FLOOR) {
|
|
1367
|
+
for (const i of shrinkable) widths[i] = FLOOR;
|
|
1368
|
+
return widths;
|
|
1369
|
+
}
|
|
1370
|
+
shrinkable.sort((a, b) => desired[a] - desired[b]);
|
|
1371
|
+
let remaining = budget;
|
|
1372
|
+
for (let k = 0; k < shrinkable.length; k++) {
|
|
1373
|
+
const colsLeft = shrinkable.length - k;
|
|
1374
|
+
const fair = Math.floor(remaining / colsLeft);
|
|
1375
|
+
const i = shrinkable[k];
|
|
1376
|
+
if (desired[i] <= fair) {
|
|
1377
|
+
widths[i] = desired[i];
|
|
1378
|
+
remaining -= desired[i];
|
|
1379
|
+
} else {
|
|
1380
|
+
const level = Math.max(FLOOR, fair);
|
|
1381
|
+
for (let j = k; j < shrinkable.length; j++) {
|
|
1382
|
+
widths[shrinkable[j]] = level;
|
|
1383
|
+
}
|
|
1384
|
+
const leftover = remaining - colsLeft * level;
|
|
1385
|
+
if (leftover > 0) widths[shrinkable[shrinkable.length - 1]] += leftover;
|
|
1386
|
+
break;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return widths;
|
|
1390
|
+
}
|
|
1391
|
+
/** Set an explicit width on a table cell, clearing any CSS min/max
|
|
1392
|
+
* width constraint so the width is honored exactly. The first
|
|
1393
|
+
* ("time") column is pinned to 150px by `min/max-width` in CSS;
|
|
1394
|
+
* without this, dragging it does nothing and the slack leaks to the
|
|
1395
|
+
* other columns. */
|
|
1396
|
+
_setCellWidth(cell, w) {
|
|
1397
|
+
cell.style.width = `${w}px`;
|
|
1398
|
+
cell.style.minWidth = "0";
|
|
1399
|
+
cell.style.maxWidth = "none";
|
|
1400
|
+
}
|
|
1401
|
+
/** Undo `_setCellWidth` so a re-measure sees the cell's natural,
|
|
1402
|
+
* CSS-constrained width again. */
|
|
1403
|
+
_clearCellWidth(cell) {
|
|
1404
|
+
cell.style.width = "";
|
|
1405
|
+
cell.style.minWidth = "";
|
|
1406
|
+
cell.style.maxWidth = "";
|
|
1407
|
+
}
|
|
1408
|
+
/** Wire the body wrap's horizontal scroll to the header. The header
|
|
1409
|
+
* sits in an overflow:hidden wrap, so it can't scroll sideways on
|
|
1410
|
+
* its own — translate it by the body's scrollLeft. Re-installed each
|
|
1411
|
+
* render against the freshly-built wrap. */
|
|
1412
|
+
_installHeaderScrollSync() {
|
|
1413
|
+
const wrap = this._tableWrapEl;
|
|
1414
|
+
if (!wrap) return;
|
|
1415
|
+
if (this._onBodyScroll && this._scrollSyncEl) {
|
|
1416
|
+
this._scrollSyncEl.removeEventListener("scroll", this._onBodyScroll);
|
|
1417
|
+
}
|
|
1418
|
+
this._onBodyScroll = () => this._syncHeaderScroll();
|
|
1419
|
+
this._scrollSyncEl = wrap;
|
|
1420
|
+
wrap.addEventListener("scroll", this._onBodyScroll, { passive: true });
|
|
1421
|
+
this._syncHeaderScroll();
|
|
1422
|
+
}
|
|
1423
|
+
/** Translate the header table to match the body's horizontal scroll
|
|
1424
|
+
* so the columns stay aligned when the table overflows sideways. */
|
|
1425
|
+
_syncHeaderScroll() {
|
|
1426
|
+
const wrap = this._tableWrapEl;
|
|
1427
|
+
const headerTable = this._headerTableEl;
|
|
1428
|
+
if (!wrap || !headerTable) return;
|
|
1429
|
+
const x = wrap.scrollLeft;
|
|
1430
|
+
headerTable.style.transform = x ? `translateX(${-x}px)` : "";
|
|
1431
|
+
}
|
|
1432
|
+
/** Drag-to-resize: hang a thin grab handle off the right edge of
|
|
1433
|
+
* every header cell. Dragging it writes a per-column width override
|
|
1434
|
+
* (keyed by DOM index) that `_syncHeaderWidths` then honors. */
|
|
1435
|
+
_installColumnResizers() {
|
|
1436
|
+
const headerTable = this._headerTableEl;
|
|
1437
|
+
if (!headerTable) return;
|
|
1438
|
+
const headRow = headerTable.querySelector("thead > tr");
|
|
1439
|
+
if (!headRow) return;
|
|
1440
|
+
[...headRow.children].forEach((th, domIdx) => {
|
|
1441
|
+
if (th.querySelector(".twm-dt-col-resizer")) return;
|
|
1442
|
+
th.classList.add("twm-dt-col");
|
|
1443
|
+
const grip = document.createElement("div");
|
|
1444
|
+
grip.className = "twm-dt-col-resizer";
|
|
1445
|
+
grip.addEventListener(
|
|
1446
|
+
"mousedown",
|
|
1447
|
+
(ev) => this._beginColResize(ev, domIdx)
|
|
1448
|
+
);
|
|
1449
|
+
grip.addEventListener("click", (ev) => ev.stopPropagation());
|
|
1450
|
+
th.appendChild(grip);
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
_beginColResize(ev, domIdx) {
|
|
1454
|
+
ev.preventDefault();
|
|
1455
|
+
ev.stopPropagation();
|
|
1456
|
+
const headerTable = this._headerTableEl;
|
|
1457
|
+
const bodyTable = this._tableEl;
|
|
1458
|
+
const headRow = headerTable && headerTable.querySelector("thead > tr");
|
|
1459
|
+
const bodyRow = bodyTable && bodyTable.querySelector("tbody > tr");
|
|
1460
|
+
if (!headRow) return;
|
|
1461
|
+
const startWidths = [...headRow.children].map(
|
|
1462
|
+
(c) => c.getBoundingClientRect().width
|
|
1463
|
+
);
|
|
1464
|
+
headerTable.style.tableLayout = "fixed";
|
|
1465
|
+
if (bodyTable) bodyTable.style.tableLayout = "fixed";
|
|
1466
|
+
const applyCol = (i, w) => {
|
|
1467
|
+
this._colWidths[i] = w;
|
|
1468
|
+
headerTable.querySelectorAll("thead > tr").forEach((tr) => {
|
|
1469
|
+
if (tr.children[i]) this._setCellWidth(tr.children[i], w);
|
|
1470
|
+
});
|
|
1471
|
+
if (bodyRow && bodyRow.children[i]) {
|
|
1472
|
+
this._setCellWidth(bodyRow.children[i], w);
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
startWidths.forEach((w, i) => applyCol(i, w));
|
|
1476
|
+
this._applyTableWidth();
|
|
1477
|
+
const startX = ev.clientX;
|
|
1478
|
+
const MIN = 40;
|
|
1479
|
+
document.body.classList.add("twm-dt-col-resizing");
|
|
1480
|
+
const onMove = (mv) => {
|
|
1481
|
+
const w = Math.max(
|
|
1482
|
+
MIN,
|
|
1483
|
+
Math.round(startWidths[domIdx] + (mv.clientX - startX))
|
|
1484
|
+
);
|
|
1485
|
+
applyCol(domIdx, w);
|
|
1486
|
+
this._applyTableWidth();
|
|
1487
|
+
this._syncHeaderScroll();
|
|
1488
|
+
};
|
|
1489
|
+
const onUp = () => {
|
|
1490
|
+
document.removeEventListener("mousemove", onMove);
|
|
1491
|
+
document.removeEventListener("mouseup", onUp);
|
|
1492
|
+
document.body.classList.remove("twm-dt-col-resizing");
|
|
1493
|
+
this._updateCellTooltips();
|
|
1494
|
+
this._savePersisted();
|
|
1495
|
+
};
|
|
1496
|
+
document.addEventListener("mousemove", onMove);
|
|
1497
|
+
document.addEventListener("mouseup", onUp);
|
|
1498
|
+
}
|
|
1499
|
+
/** Size both tables to the sum of the header row's explicit column
|
|
1500
|
+
* widths so a widened column grows the table (→ horizontal scroll)
|
|
1501
|
+
* rather than stealing from its neighbours. */
|
|
1502
|
+
_applyTableWidth() {
|
|
1503
|
+
const headerTable = this._headerTableEl;
|
|
1504
|
+
const bodyTable = this._tableEl;
|
|
1505
|
+
const headRow = headerTable && headerTable.querySelector("thead > tr");
|
|
1506
|
+
if (!headRow) return;
|
|
1507
|
+
let total = 0;
|
|
1508
|
+
for (const th of headRow.children) {
|
|
1509
|
+
const px = parseFloat(th.style.width);
|
|
1510
|
+
total += Number.isFinite(px) ? px : th.getBoundingClientRect().width;
|
|
1511
|
+
}
|
|
1512
|
+
total = Math.ceil(total);
|
|
1513
|
+
headerTable.style.width = `${total}px`;
|
|
1514
|
+
if (bodyTable) bodyTable.style.width = `${total}px`;
|
|
1515
|
+
}
|
|
1516
|
+
/** Add a native `title` tooltip to any body cell whose text is
|
|
1517
|
+
* clipped by its column width; remove ours once it fits again.
|
|
1518
|
+
* Leaves caller-supplied titles (e.g. from renderCell) untouched. */
|
|
1519
|
+
_updateCellTooltips() {
|
|
1520
|
+
const bodyTable = this._tableEl;
|
|
1521
|
+
if (!bodyTable) return;
|
|
1522
|
+
bodyTable.querySelectorAll("tbody td").forEach((td) => {
|
|
1523
|
+
const clipped = td.scrollWidth > td.clientWidth + 1;
|
|
1524
|
+
if (clipped) {
|
|
1525
|
+
if (!td.title) td.title = td.textContent;
|
|
1526
|
+
} else if (td.title && td.title === td.textContent) {
|
|
1527
|
+
td.removeAttribute("title");
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
_installInteractions() {
|
|
1532
|
+
if (!this._tbodyEl || !this._tableEl) return;
|
|
1533
|
+
const { selectable, copyable } = this.config;
|
|
1534
|
+
const tbody = this._tbodyEl;
|
|
1535
|
+
const table = this._tableEl;
|
|
1536
|
+
if (selectable) {
|
|
1537
|
+
const handleRowClick = (event) => {
|
|
1538
|
+
const rowEl = event.target.closest("tr");
|
|
1539
|
+
if (!rowEl || rowEl.__rowIndex === void 0) return;
|
|
1540
|
+
const idx = rowEl.__rowIndex;
|
|
1541
|
+
const selected = this._state.selected;
|
|
1542
|
+
if (event.shiftKey && this._state.anchorIndex != null) {
|
|
1543
|
+
const start = Math.min(this._state.anchorIndex, idx);
|
|
1544
|
+
const end = Math.max(this._state.anchorIndex, idx);
|
|
1545
|
+
selected.clear();
|
|
1546
|
+
for (let i = start; i <= end; i++) selected.add(i);
|
|
1547
|
+
} else if (event.metaKey || event.ctrlKey) {
|
|
1548
|
+
if (selected.has(idx)) selected.delete(idx);
|
|
1549
|
+
else selected.add(idx);
|
|
1550
|
+
this._state.anchorIndex = idx;
|
|
1551
|
+
} else {
|
|
1552
|
+
selected.clear();
|
|
1553
|
+
selected.add(idx);
|
|
1554
|
+
this._state.anchorIndex = idx;
|
|
1555
|
+
}
|
|
1556
|
+
this._updateRowSelection();
|
|
1557
|
+
this._notifySelectionChange();
|
|
1558
|
+
try {
|
|
1559
|
+
table.focus({ preventScroll: true });
|
|
1560
|
+
} catch (_) {
|
|
1561
|
+
}
|
|
1562
|
+
};
|
|
1563
|
+
tbody.addEventListener("click", handleRowClick);
|
|
1564
|
+
this._disposers.push(() => tbody.removeEventListener("click", handleRowClick));
|
|
1565
|
+
}
|
|
1566
|
+
if (copyable) {
|
|
1567
|
+
const handleContextMenu = (event) => {
|
|
1568
|
+
const rowEl = event.target.closest("tr");
|
|
1569
|
+
if (!rowEl) {
|
|
1570
|
+
this._hideContextMenu();
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
event.preventDefault();
|
|
1574
|
+
event.stopPropagation();
|
|
1575
|
+
const idx = rowEl.__rowIndex;
|
|
1576
|
+
if (idx !== void 0 && !this._state.selected.has(idx)) {
|
|
1577
|
+
this._state.selected.clear();
|
|
1578
|
+
this._state.selected.add(idx);
|
|
1579
|
+
this._state.anchorIndex = idx;
|
|
1580
|
+
this._updateRowSelection();
|
|
1581
|
+
this._notifySelectionChange();
|
|
1582
|
+
}
|
|
1583
|
+
try {
|
|
1584
|
+
table.focus({ preventScroll: true });
|
|
1585
|
+
} catch (_) {
|
|
1586
|
+
}
|
|
1587
|
+
setTimeout(() => this._showContextMenu(event.clientX, event.clientY), 0);
|
|
1588
|
+
};
|
|
1589
|
+
tbody.addEventListener("contextmenu", handleContextMenu);
|
|
1590
|
+
this._disposers.push(() => tbody.removeEventListener("contextmenu", handleContextMenu));
|
|
1591
|
+
const handleKeyDown = (event) => {
|
|
1592
|
+
const ctrlLike = event.ctrlKey || event.metaKey;
|
|
1593
|
+
if (ctrlLike && !event.altKey) {
|
|
1594
|
+
const key = String(event.key || "").toLowerCase();
|
|
1595
|
+
if (key === "a" && selectable) {
|
|
1596
|
+
event.preventDefault();
|
|
1597
|
+
this._state.selected.clear();
|
|
1598
|
+
const indexMap = this._processedIndexMap;
|
|
1599
|
+
if (indexMap) {
|
|
1600
|
+
for (const originalIdx of indexMap) {
|
|
1601
|
+
this._state.selected.add(originalIdx);
|
|
1602
|
+
}
|
|
1603
|
+
} else {
|
|
1604
|
+
for (let i = 0; i < this.config.rows.length; i++) {
|
|
1605
|
+
this._state.selected.add(i);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
this._state.anchorIndex = this.config.rows.length - 1;
|
|
1609
|
+
this._updateRowSelection();
|
|
1610
|
+
this._notifySelectionChange();
|
|
1611
|
+
} else if (key === "c") {
|
|
1612
|
+
event.preventDefault();
|
|
1613
|
+
this.copyToClipboard(event.shiftKey ? "csv" : "tsv");
|
|
1614
|
+
}
|
|
1615
|
+
} else if (event.key === "Escape") {
|
|
1616
|
+
if (this._state.selected.size) {
|
|
1617
|
+
this.clearSelection();
|
|
1618
|
+
}
|
|
1619
|
+
this._hideContextMenu();
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
table.addEventListener("keydown", handleKeyDown);
|
|
1623
|
+
this._disposers.push(() => table.removeEventListener("keydown", handleKeyDown));
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
_updateRowSelection() {
|
|
1627
|
+
if (!this._tbodyEl) return;
|
|
1628
|
+
this._tbodyEl.querySelectorAll("tr").forEach((tr) => {
|
|
1629
|
+
const idx = tr.__rowIndex;
|
|
1630
|
+
if (idx !== void 0) {
|
|
1631
|
+
tr.classList.toggle("selected", this._state.selected.has(idx));
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
_notifySelectionChange() {
|
|
1636
|
+
this.config.onSelectionChange?.(this.getSelection());
|
|
1637
|
+
}
|
|
1638
|
+
_ensureContextMenu() {
|
|
1639
|
+
if (this._contextMenuEl) return this._contextMenuEl;
|
|
1640
|
+
const menu = document.createElement("div");
|
|
1641
|
+
menu.className = "twm-context-menu data-context-menu";
|
|
1642
|
+
menu.style.display = "none";
|
|
1643
|
+
menu.style.position = "fixed";
|
|
1644
|
+
menu.style.zIndex = "10001";
|
|
1645
|
+
menu.innerHTML = `
|
|
1646
|
+
<div class="twm-context-menu-item" data-action="copy-tsv">
|
|
1647
|
+
<span class="material-symbols-outlined">content_copy</span>
|
|
1648
|
+
<span class="label">Copy Row(s)</span>
|
|
1649
|
+
</div>
|
|
1650
|
+
<div class="twm-context-menu-item" data-action="copy-csv">
|
|
1651
|
+
<span class="material-symbols-outlined">table</span>
|
|
1652
|
+
<span class="label">Copy as CSV</span>
|
|
1653
|
+
</div>
|
|
1654
|
+
`;
|
|
1655
|
+
document.body.appendChild(menu);
|
|
1656
|
+
menu.addEventListener("click", (event) => {
|
|
1657
|
+
const item = event.target.closest(".twm-context-menu-item");
|
|
1658
|
+
if (!item || item.classList.contains("disabled")) return;
|
|
1659
|
+
const action = item.getAttribute("data-action");
|
|
1660
|
+
if (action === "copy-tsv") this.copyToClipboard("tsv");
|
|
1661
|
+
else if (action === "copy-csv") this.copyToClipboard("csv");
|
|
1662
|
+
event.stopPropagation();
|
|
1663
|
+
event.preventDefault();
|
|
1664
|
+
}, { capture: true });
|
|
1665
|
+
menu.addEventListener("contextmenu", (e) => {
|
|
1666
|
+
e.preventDefault();
|
|
1667
|
+
e.stopPropagation();
|
|
1668
|
+
});
|
|
1669
|
+
const hideOnGlobal = (event) => {
|
|
1670
|
+
if (event?.target && menu.contains(event.target)) return;
|
|
1671
|
+
this._hideContextMenu();
|
|
1672
|
+
};
|
|
1673
|
+
const hideOnEscape = (event) => {
|
|
1674
|
+
if (event.key === "Escape") this._hideContextMenu();
|
|
1675
|
+
};
|
|
1676
|
+
const hideOnResize = () => this._hideContextMenu();
|
|
1677
|
+
document.addEventListener("click", hideOnGlobal, true);
|
|
1678
|
+
document.addEventListener("scroll", hideOnGlobal, true);
|
|
1679
|
+
window.addEventListener("resize", hideOnResize);
|
|
1680
|
+
document.addEventListener("keydown", hideOnEscape);
|
|
1681
|
+
this._contextMenuGlobals.push(() => document.removeEventListener("click", hideOnGlobal, true));
|
|
1682
|
+
this._contextMenuGlobals.push(() => document.removeEventListener("scroll", hideOnGlobal, true));
|
|
1683
|
+
this._contextMenuGlobals.push(() => document.removeEventListener("keydown", hideOnEscape));
|
|
1684
|
+
this._contextMenuGlobals.push(() => window.removeEventListener("resize", hideOnResize));
|
|
1685
|
+
this._contextMenuEl = menu;
|
|
1686
|
+
return menu;
|
|
1687
|
+
}
|
|
1688
|
+
_hideContextMenu() {
|
|
1689
|
+
if (this._contextMenuEl) {
|
|
1690
|
+
this._contextMenuEl.style.display = "none";
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
_showContextMenu(clientX, clientY) {
|
|
1694
|
+
const menu = this._ensureContextMenu();
|
|
1695
|
+
const hasSelection = this._state.selected.size > 0;
|
|
1696
|
+
menu.querySelectorAll(".twm-context-menu-item").forEach((item) => {
|
|
1697
|
+
item.classList.toggle("disabled", !hasSelection);
|
|
1698
|
+
item.style.pointerEvents = hasSelection ? "" : "none";
|
|
1699
|
+
});
|
|
1700
|
+
menu.style.display = "block";
|
|
1701
|
+
const rect = menu.getBoundingClientRect();
|
|
1702
|
+
let left = clientX;
|
|
1703
|
+
let top = clientY;
|
|
1704
|
+
if (left + rect.width > window.innerWidth) {
|
|
1705
|
+
left = window.innerWidth - rect.width - 8;
|
|
1706
|
+
}
|
|
1707
|
+
if (top + rect.height > window.innerHeight) {
|
|
1708
|
+
top = window.innerHeight - rect.height - 8;
|
|
1709
|
+
}
|
|
1710
|
+
menu.style.left = `${left}px`;
|
|
1711
|
+
menu.style.top = `${top}px`;
|
|
1712
|
+
}
|
|
1713
|
+
_teardownContextMenu() {
|
|
1714
|
+
this._contextMenuGlobals.forEach((dispose) => {
|
|
1715
|
+
try {
|
|
1716
|
+
dispose?.();
|
|
1717
|
+
} catch (_) {
|
|
1718
|
+
}
|
|
1719
|
+
});
|
|
1720
|
+
this._contextMenuGlobals = [];
|
|
1721
|
+
if (this._contextMenuEl?.parentNode) {
|
|
1722
|
+
this._contextMenuEl.parentNode.removeChild(this._contextMenuEl);
|
|
1723
|
+
}
|
|
1724
|
+
this._contextMenuEl = null;
|
|
1725
|
+
}
|
|
1726
|
+
_formatValue(value, colIndex) {
|
|
1727
|
+
if (this.config.formatValue) {
|
|
1728
|
+
return this.config.formatValue(value, colIndex);
|
|
1729
|
+
}
|
|
1730
|
+
if (value === void 0 || value === null) return "-";
|
|
1731
|
+
if (typeof value === "number") {
|
|
1732
|
+
if (!Number.isFinite(value)) return "-";
|
|
1733
|
+
return Number(value.toFixed(4)).toString();
|
|
1734
|
+
}
|
|
1735
|
+
return String(value);
|
|
1736
|
+
}
|
|
1737
|
+
_csvEscape(value) {
|
|
1738
|
+
if (value == null) return "";
|
|
1739
|
+
const str = String(value);
|
|
1740
|
+
if (/[",\n]/.test(str)) {
|
|
1741
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
1742
|
+
}
|
|
1743
|
+
return str;
|
|
1744
|
+
}
|
|
1745
|
+
_fallbackCopy(text) {
|
|
1746
|
+
try {
|
|
1747
|
+
const textarea = document.createElement("textarea");
|
|
1748
|
+
textarea.value = text;
|
|
1749
|
+
textarea.setAttribute("readonly", "");
|
|
1750
|
+
textarea.style.position = "absolute";
|
|
1751
|
+
textarea.style.left = "-9999px";
|
|
1752
|
+
document.body.appendChild(textarea);
|
|
1753
|
+
textarea.select();
|
|
1754
|
+
const ok = document.execCommand("copy");
|
|
1755
|
+
document.body.removeChild(textarea);
|
|
1756
|
+
return ok;
|
|
1757
|
+
} catch (_) {
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
_notify(title, message, type = "info") {
|
|
1762
|
+
this.config.services?.eventBus?.emit?.("toast:show", { title, message, type });
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1765
|
+
|
|
1766
|
+
export {
|
|
1767
|
+
createRafResizeObserver,
|
|
1768
|
+
DataTable
|
|
1769
|
+
};
|
|
1770
|
+
//# sourceMappingURL=chunk-CT4YXXLP.js.map
|