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,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* landing_table.js — shared helpers for the "navigation table"
|
|
3
|
+
* pattern used by SFC / Markets / Scenarios / KPIs landings (and the
|
|
4
|
+
* market-archetype instances table). Three responsibilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. Single-click row → open the entity (handled by callers).
|
|
7
|
+
* 2. Right-click row → custom context menu with Open / Edit / Delete
|
|
8
|
+
* (replaces DataTable's default copy-as-TSV menu for these tables;
|
|
9
|
+
* the default still applies to data tables outside this helper).
|
|
10
|
+
* 3. Renders an "actions" column with inline edit + delete icons so
|
|
11
|
+
* the user can act on a row without opening it first.
|
|
12
|
+
*
|
|
13
|
+
* Callers configure the per-kind behaviour via the `entityActions`
|
|
14
|
+
* object: `{ open(row), edit(row), delete(row) }` — any of which may be
|
|
15
|
+
* omitted to hide the corresponding action.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { showContextMenu } from '../ui/components/context_menu.js';
|
|
19
|
+
import { registerPanelKeys } from './panel_keys.js';
|
|
20
|
+
|
|
21
|
+
/** Attach the navigation-table interactions to a host element that
|
|
22
|
+
* contains a `<table>` already rendered by DataTable. Returns a
|
|
23
|
+
* teardown function.
|
|
24
|
+
*
|
|
25
|
+
* `entityActions` maps action names to handlers. The canonical names
|
|
26
|
+
* are `open` / `edit` / `delete` (plus `openInTab` / `openInWindow`),
|
|
27
|
+
* but ANY `data-twm-action="<name>"` button or context-menu item
|
|
28
|
+
* dispatches to `entityActions[<name>]` — so tree landings can wire
|
|
29
|
+
* `toggle`, `contract`, etc. through the same robust path instead of
|
|
30
|
+
* hand-rolling their own tbody listeners (which is how the Agents /
|
|
31
|
+
* Markets pages used to drift out of sync with this helper).
|
|
32
|
+
*
|
|
33
|
+
* `options.buildMenu(row) => items[]` overrides the default
|
|
34
|
+
* Open / Edit / Delete right-click menu for tree-shaped rows that need
|
|
35
|
+
* conditional entries (expand/collapse, contract detail). Each item's
|
|
36
|
+
* `action` is dispatched the same way as a button. */
|
|
37
|
+
export function attachLandingTableBehavior(host, getRow, entityActions, options = {}) {
|
|
38
|
+
if (!host) return () => {};
|
|
39
|
+
|
|
40
|
+
// Delegate from the stable `host` container, NOT the <tbody>. DataTable
|
|
41
|
+
// rebuilds its <tbody> on every internal re-render — pagination, filter,
|
|
42
|
+
// sort, and the post-layout column-width sync — and each re-render
|
|
43
|
+
// re-installs ITS OWN row listeners (data_table.js `_installInteractions`)
|
|
44
|
+
// but knows nothing about ours. A listener bound directly to the tbody
|
|
45
|
+
// therefore goes silently dead after the first re-render: events still
|
|
46
|
+
// reach the live tbody (DataTable's copy menu still pops) while our
|
|
47
|
+
// handlers sit orphaned on a detached node. Binding on `host` and
|
|
48
|
+
// resolving the row at event time survives any number of tbody rebuilds.
|
|
49
|
+
|
|
50
|
+
// Dispatch an action name to its handler. The `open-tab` /
|
|
51
|
+
// `open-window` menu aliases map onto the `openInTab` /
|
|
52
|
+
// `openInWindow` handlers; every other name (including `edit`,
|
|
53
|
+
// `delete`, and tree-only verbs like `toggle` / `contract`) routes
|
|
54
|
+
// straight to `entityActions[name]`.
|
|
55
|
+
const dispatch = (action, row) => {
|
|
56
|
+
if (!row) return;
|
|
57
|
+
const fn = action === 'open-tab' ? entityActions.openInTab
|
|
58
|
+
: action === 'open-window' ? entityActions.openInWindow
|
|
59
|
+
: entityActions[action];
|
|
60
|
+
try { fn?.(row); }
|
|
61
|
+
catch (e) { console.warn(`[landing] action "${action}" failed`, e); }
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// The BODY <tr> for an event, re-found live each time. Excludes the
|
|
65
|
+
// header / filter row (it lives in the separate non-scrolling header
|
|
66
|
+
// wrap, not inside `.preview-table-wrap`'s tbody).
|
|
67
|
+
const bodyRowFor = (ev) => {
|
|
68
|
+
const tr = ev.target.closest('tr');
|
|
69
|
+
if (!tr || !tr.closest('tbody')) return null;
|
|
70
|
+
const wrap = tr.closest('.twm-preview-table-wrap');
|
|
71
|
+
if (!wrap || !host.contains(wrap)) return null;
|
|
72
|
+
return tr;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const onClick = (ev) => {
|
|
76
|
+
// Inline action buttons short-circuit the row-open default and
|
|
77
|
+
// suppress every other listener (DataTable's row-selection
|
|
78
|
+
// listener, etc.). `stopImmediatePropagation` is load-bearing.
|
|
79
|
+
const actionBtn = ev.target.closest('[data-twm-action]');
|
|
80
|
+
if (actionBtn && host.contains(actionBtn)) {
|
|
81
|
+
ev.preventDefault();
|
|
82
|
+
ev.stopPropagation();
|
|
83
|
+
ev.stopImmediatePropagation();
|
|
84
|
+
const tr = actionBtn.closest('tr');
|
|
85
|
+
dispatch(actionBtn.dataset.twmAction, tr ? getRow(tr.__rowIndex) : null);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Plain row click → open. Ignore clicks on interactive controls
|
|
89
|
+
// (buttons, filter inputs, links) and anything outside a body row
|
|
90
|
+
// (pagination strip, header, empty space).
|
|
91
|
+
if (ev.target.closest('button, a, input, select, textarea')) return;
|
|
92
|
+
const tr = bodyRowFor(ev);
|
|
93
|
+
if (tr) dispatch('open', getRow(tr.__rowIndex));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const defaultMenu = (row) => {
|
|
97
|
+
const items = [];
|
|
98
|
+
if (entityActions.open) {
|
|
99
|
+
items.push({ label: 'Open', icon: 'open_in_new', action: 'open' });
|
|
100
|
+
if (entityActions.openInTab) {
|
|
101
|
+
items.push({ label: 'Open in new tab', icon: 'tab', action: 'open-tab' });
|
|
102
|
+
}
|
|
103
|
+
if (entityActions.openInWindow) {
|
|
104
|
+
items.push({ label: 'Open in new window', icon: 'open_in_full',
|
|
105
|
+
action: 'open-window' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (entityActions.edit) items.push({ label: 'Rename / edit', icon: 'edit', action: 'edit' });
|
|
109
|
+
if (entityActions.delete) {
|
|
110
|
+
if (items.length) items.push({ separator: true });
|
|
111
|
+
items.push({ label: 'Delete', icon: 'delete', action: 'delete', danger: true });
|
|
112
|
+
}
|
|
113
|
+
return items;
|
|
114
|
+
};
|
|
115
|
+
const buildMenu = typeof options.buildMenu === 'function'
|
|
116
|
+
? options.buildMenu
|
|
117
|
+
: defaultMenu;
|
|
118
|
+
|
|
119
|
+
const onContextMenu = (ev) => {
|
|
120
|
+
const tr = bodyRowFor(ev);
|
|
121
|
+
if (!tr) return;
|
|
122
|
+
ev.preventDefault();
|
|
123
|
+
// Bypass DataTable's own copy-as-TSV/CSV menu by stopping
|
|
124
|
+
// propagation — the right-click for a navigation row is about the
|
|
125
|
+
// *entity*, not the cell text. Capture phase on `host` runs before
|
|
126
|
+
// DataTable's bubble-phase contextmenu on the tbody.
|
|
127
|
+
ev.stopPropagation();
|
|
128
|
+
const row = getRow(tr.__rowIndex);
|
|
129
|
+
if (!row) return;
|
|
130
|
+
const items = buildMenu(row) || [];
|
|
131
|
+
if (items.length === 0) return;
|
|
132
|
+
showContextMenu(ev.clientX, ev.clientY, items, (action) => dispatch(action, row));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Click in bubble phase (after DataTable's row-selection); contextmenu
|
|
136
|
+
// in CAPTURE phase so ours runs before DataTable's bubble copy menu.
|
|
137
|
+
host.addEventListener('click', onClick);
|
|
138
|
+
host.addEventListener('contextmenu', onContextMenu, true);
|
|
139
|
+
return () => {
|
|
140
|
+
host.removeEventListener('click', onClick);
|
|
141
|
+
host.removeEventListener('contextmenu', onContextMenu, true);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Wire pane-focus highlighting. SfcLandingTab gives each
|
|
146
|
+
* `.ea-sfc-landing__pane` an `--focused` modifier when it's the
|
|
147
|
+
* active pane — that's what paints the blue head + accent title.
|
|
148
|
+
* Other landings need the same treatment to read as consistent.
|
|
149
|
+
*
|
|
150
|
+
* Single-pane landings can call this once: the only pane stays
|
|
151
|
+
* focused at all times. Multi-pane landings get mousedown wiring
|
|
152
|
+
* per pane and a reflectFocus() helper they can call from keyboard
|
|
153
|
+
* navigation if they implement it. */
|
|
154
|
+
export function wireLandingPaneFocus(hostEl) {
|
|
155
|
+
const panes = Array.from(hostEl.querySelectorAll('.twm-landing__pane'));
|
|
156
|
+
if (panes.length === 0) return { focus: () => {}, panes };
|
|
157
|
+
let focused = panes[0];
|
|
158
|
+
const reflect = () => {
|
|
159
|
+
for (const p of panes) {
|
|
160
|
+
p.classList.toggle('twm-landing__pane--focused', p === focused);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
reflect();
|
|
164
|
+
for (const p of panes) {
|
|
165
|
+
p.addEventListener('mousedown', () => {
|
|
166
|
+
if (focused === p) return;
|
|
167
|
+
focused = p;
|
|
168
|
+
reflect();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
focus: (pane) => { if (pane && panes.includes(pane)) { focused = pane; reflect(); } },
|
|
173
|
+
panes,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Arrow-key navigation for a landing page's DataTable.
|
|
178
|
+
*
|
|
179
|
+
* - ArrowDown / ArrowUp moves a row cursor within `host`.
|
|
180
|
+
* - Enter (and the optional `activateKey`, e.g. `o`) opens the row.
|
|
181
|
+
* - Home / End jump to the first / last row.
|
|
182
|
+
* - Captures the keys at the document level so the user doesn't have
|
|
183
|
+
* to click inside the table first — same UX as sfc_landing_tab.
|
|
184
|
+
*
|
|
185
|
+
* The visible cursor is the `ea-row-cursor` CSS class on the active
|
|
186
|
+
* `<tr>`; the row also `scrollIntoView({block:'nearest'})` so the
|
|
187
|
+
* selection stays visible during keyboard scroll-through.
|
|
188
|
+
*
|
|
189
|
+
* Returns `{ teardown, refresh, focusFirst }`. Call `refresh()` after
|
|
190
|
+
* the table re-renders so the cursor lands on the right row again.
|
|
191
|
+
*/
|
|
192
|
+
export function attachLandingKeyboardNav(host, getRows, onActivate, options = {}) {
|
|
193
|
+
if (!host) return { teardown: () => {}, refresh: () => {}, focusFirst: () => {} };
|
|
194
|
+
|
|
195
|
+
let cursor = 0;
|
|
196
|
+
const tbody = () => host.querySelector('tbody');
|
|
197
|
+
|
|
198
|
+
const repaintCursor = () => {
|
|
199
|
+
const tb = tbody();
|
|
200
|
+
if (!tb) return;
|
|
201
|
+
const rows = getRows() || [];
|
|
202
|
+
if (rows.length === 0) { cursor = 0; return; }
|
|
203
|
+
if (cursor >= rows.length) cursor = rows.length - 1;
|
|
204
|
+
if (cursor < 0) cursor = 0;
|
|
205
|
+
let active = null;
|
|
206
|
+
tb.querySelectorAll('tr').forEach((tr) => {
|
|
207
|
+
const on = tr.__rowIndex === cursor;
|
|
208
|
+
tr.classList.toggle('twm-row-cursor', on);
|
|
209
|
+
if (on) active = tr;
|
|
210
|
+
});
|
|
211
|
+
if (active) active.scrollIntoView({ block: 'nearest' });
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const move = (delta) => {
|
|
215
|
+
const rows = getRows() || [];
|
|
216
|
+
if (rows.length === 0) return;
|
|
217
|
+
cursor = Math.max(0, Math.min(rows.length - 1, cursor + delta));
|
|
218
|
+
repaintCursor();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const open = () => {
|
|
222
|
+
const rows = getRows() || [];
|
|
223
|
+
const row = rows[cursor];
|
|
224
|
+
if (row) onActivate(row);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const onKey = (ev) => {
|
|
228
|
+
if (!host.isConnected) return;
|
|
229
|
+
// Stay out of the way of inputs and unrelated focus targets.
|
|
230
|
+
const t = ev.target;
|
|
231
|
+
const editable = t && (
|
|
232
|
+
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
|
233
|
+
t.tagName === 'SELECT' || t.isContentEditable);
|
|
234
|
+
if (editable) return;
|
|
235
|
+
// Scope (which tile is selected) is owned by the panel-key router.
|
|
236
|
+
|
|
237
|
+
if (ev.key === 'ArrowDown') { ev.preventDefault(); move(+1); }
|
|
238
|
+
else if (ev.key === 'ArrowUp') { ev.preventDefault(); move(-1); }
|
|
239
|
+
else if (ev.key === 'Home') { ev.preventDefault(); cursor = 0; repaintCursor(); }
|
|
240
|
+
else if (ev.key === 'End') {
|
|
241
|
+
ev.preventDefault();
|
|
242
|
+
const rows = getRows() || [];
|
|
243
|
+
cursor = Math.max(0, rows.length - 1);
|
|
244
|
+
repaintCursor();
|
|
245
|
+
}
|
|
246
|
+
else if (ev.key === 'Enter') { ev.preventDefault(); open(); }
|
|
247
|
+
else if (options.activateKey && ev.key === options.activateKey
|
|
248
|
+
&& !ev.metaKey && !ev.ctrlKey && !ev.altKey) {
|
|
249
|
+
ev.preventDefault(); open();
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const offKey = registerPanelKeys(host, onKey);
|
|
254
|
+
// Re-paint immediately so the cursor is visible from first render.
|
|
255
|
+
setTimeout(repaintCursor, 0);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
teardown: () => offKey(),
|
|
259
|
+
refresh: () => repaintCursor(),
|
|
260
|
+
focusFirst: () => { cursor = 0; repaintCursor(); },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
/** Unified landing-page keyboard shell.
|
|
266
|
+
*
|
|
267
|
+
* Wires the four behaviors every landing should share, in one call:
|
|
268
|
+
*
|
|
269
|
+
* - Pane focus highlight (`.ea-sfc-landing__pane--focused`) — click
|
|
270
|
+
* on a pane or Tab to it, the header band lights up.
|
|
271
|
+
* - ArrowUp / ArrowDown moves the row cursor within the focused
|
|
272
|
+
* pane's table; Home / End jump to first / last. The visible cursor
|
|
273
|
+
* is `.ea-row-cursor` on the active `<tr>`.
|
|
274
|
+
* - Tab / Shift+Tab cycles between panes. Single-pane landings just
|
|
275
|
+
* no-op the Tab — the behaviour is a property of the shell, not
|
|
276
|
+
* the page.
|
|
277
|
+
* - Enter activates the cursor row via `onActivate(row)`.
|
|
278
|
+
* - ArrowRight / ArrowLeft expand / collapse the cursor row when
|
|
279
|
+
* the pane provides `onExpandToggle` + `isExpanded` (tree-shaped
|
|
280
|
+
* tables; harmless on flat tables that don't define them).
|
|
281
|
+
*
|
|
282
|
+
* Each pane spec:
|
|
283
|
+
* {
|
|
284
|
+
* paneEl, // .ea-sfc-landing__pane element
|
|
285
|
+
* getRows, // () => Array<row>
|
|
286
|
+
* onActivate, // (row) => void — Enter
|
|
287
|
+
* onExpandToggle?, // (row, expand:bool) => void
|
|
288
|
+
* isExpanded?, // (row) => bool
|
|
289
|
+
* isExpandable?, // (row) => bool — gates Left/Right
|
|
290
|
+
* tableHost?, // override pane.querySelector('.twm-landing__table')
|
|
291
|
+
* }
|
|
292
|
+
*
|
|
293
|
+
* Returns `{ teardown, refresh, focusPane(idx) }`. Call `refresh()` after
|
|
294
|
+
* the pane tables re-render so the cursor lands on the right row again.
|
|
295
|
+
*
|
|
296
|
+
* Backspace is intentionally **not** handled here — `keymap.js` owns
|
|
297
|
+
* it globally and walks `wm.navigateBack()` regardless of which
|
|
298
|
+
* landing is showing. */
|
|
299
|
+
export function attachLandingShell(hostEl, { panes, initialPane = 0 } = {}) {
|
|
300
|
+
if (!hostEl || !Array.isArray(panes) || panes.length === 0) {
|
|
301
|
+
return { teardown: () => {}, refresh: () => {}, focusPane: () => {} };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const state = panes.map((spec) => ({
|
|
305
|
+
spec,
|
|
306
|
+
paneEl: spec.paneEl,
|
|
307
|
+
tableHost: spec.tableHost
|
|
308
|
+
|| spec.paneEl?.querySelector('.twm-landing__table')
|
|
309
|
+
|| spec.paneEl,
|
|
310
|
+
cursor: 0,
|
|
311
|
+
}));
|
|
312
|
+
let focusedIdx = Math.max(0, Math.min(panes.length - 1, initialPane));
|
|
313
|
+
|
|
314
|
+
const reflectPaneFocus = () => {
|
|
315
|
+
state.forEach((s, i) => {
|
|
316
|
+
s.paneEl?.classList.toggle(
|
|
317
|
+
'twm-landing__pane--focused', i === focusedIdx);
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const repaintCursor = (idx = focusedIdx) => {
|
|
322
|
+
const s = state[idx];
|
|
323
|
+
if (!s) return;
|
|
324
|
+
const tbody = s.tableHost?.querySelector('tbody');
|
|
325
|
+
if (!tbody) return;
|
|
326
|
+
const rows = s.spec.getRows?.() || [];
|
|
327
|
+
if (rows.length === 0) { s.cursor = 0; return; }
|
|
328
|
+
if (s.cursor >= rows.length) s.cursor = rows.length - 1;
|
|
329
|
+
if (s.cursor < 0) s.cursor = 0;
|
|
330
|
+
let active = null;
|
|
331
|
+
tbody.querySelectorAll('tr').forEach((tr) => {
|
|
332
|
+
const on = tr.__rowIndex === s.cursor;
|
|
333
|
+
tr.classList.toggle('twm-row-cursor', on);
|
|
334
|
+
if (on) active = tr;
|
|
335
|
+
});
|
|
336
|
+
if (active && idx === focusedIdx) {
|
|
337
|
+
active.scrollIntoView({ block: 'nearest' });
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const move = (delta) => {
|
|
342
|
+
const s = state[focusedIdx];
|
|
343
|
+
if (!s) return;
|
|
344
|
+
const rows = s.spec.getRows?.() || [];
|
|
345
|
+
if (rows.length === 0) return;
|
|
346
|
+
s.cursor = Math.max(0, Math.min(rows.length - 1, s.cursor + delta));
|
|
347
|
+
repaintCursor();
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const jump = (toEnd) => {
|
|
351
|
+
const s = state[focusedIdx];
|
|
352
|
+
if (!s) return;
|
|
353
|
+
const rows = s.spec.getRows?.() || [];
|
|
354
|
+
s.cursor = toEnd ? Math.max(0, rows.length - 1) : 0;
|
|
355
|
+
repaintCursor();
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const currentRow = () => {
|
|
359
|
+
const s = state[focusedIdx];
|
|
360
|
+
if (!s) return null;
|
|
361
|
+
const rows = s.spec.getRows?.() || [];
|
|
362
|
+
return rows[s.cursor] ?? null;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const activate = () => {
|
|
366
|
+
const row = currentRow();
|
|
367
|
+
if (!row) return;
|
|
368
|
+
try { state[focusedIdx].spec.onActivate?.(row); }
|
|
369
|
+
catch (err) { console.warn('[landing-shell] activate failed', err); }
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/** Open the current row in a new tab on the tile. Pane spec opts in
|
|
373
|
+
* by providing `onActivateInTab(row)`; the default-Enter handler
|
|
374
|
+
* remains `onActivate`. Falls back to `onActivate` so panes that
|
|
375
|
+
* haven't been migrated still respond to Ctrl+Enter. */
|
|
376
|
+
const activateInTab = () => {
|
|
377
|
+
const row = currentRow();
|
|
378
|
+
if (!row) return;
|
|
379
|
+
const spec = state[focusedIdx].spec;
|
|
380
|
+
const fn = spec.onActivateInTab || spec.onActivate;
|
|
381
|
+
try { fn?.(row); }
|
|
382
|
+
catch (err) { console.warn('[landing-shell] activate-tab failed', err); }
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/** Open the current row in a fresh managed window. Same opt-in
|
|
386
|
+
* pattern as activateInTab — panes provide `onActivateInWindow`
|
|
387
|
+
* for Shift+Enter, fall back to onActivate when absent. */
|
|
388
|
+
const activateInWindow = () => {
|
|
389
|
+
const row = currentRow();
|
|
390
|
+
if (!row) return;
|
|
391
|
+
const spec = state[focusedIdx].spec;
|
|
392
|
+
const fn = spec.onActivateInWindow || spec.onActivate;
|
|
393
|
+
try { fn?.(row); }
|
|
394
|
+
catch (err) { console.warn('[landing-shell] activate-window failed', err); }
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const expandToggle = (expand) => {
|
|
398
|
+
const s = state[focusedIdx];
|
|
399
|
+
if (!s?.spec.onExpandToggle) return false;
|
|
400
|
+
const row = currentRow();
|
|
401
|
+
if (!row) return false;
|
|
402
|
+
if (s.spec.isExpandable && !s.spec.isExpandable(row)) return false;
|
|
403
|
+
const currentlyExpanded = s.spec.isExpanded
|
|
404
|
+
? !!s.spec.isExpanded(row)
|
|
405
|
+
: false;
|
|
406
|
+
if (expand === currentlyExpanded) return false;
|
|
407
|
+
try { s.spec.onExpandToggle(row, expand); }
|
|
408
|
+
catch (err) { console.warn('[landing-shell] expand failed', err); }
|
|
409
|
+
return true;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const focusPane = (idx) => {
|
|
413
|
+
if (idx < 0 || idx >= state.length) return;
|
|
414
|
+
if (idx === focusedIdx) return;
|
|
415
|
+
focusedIdx = idx;
|
|
416
|
+
reflectPaneFocus();
|
|
417
|
+
repaintCursor();
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const cyclePane = (dir) => {
|
|
421
|
+
if (state.length < 2) return;
|
|
422
|
+
focusPane((focusedIdx + dir + state.length) % state.length);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const onMouseDown = (ev) => {
|
|
426
|
+
// Find which pane the click landed in. Inline action buttons
|
|
427
|
+
// and the like still focus the surrounding pane.
|
|
428
|
+
const paneEl = ev.target.closest?.('.twm-landing__pane');
|
|
429
|
+
if (!paneEl) return;
|
|
430
|
+
const idx = state.findIndex((s) => s.paneEl === paneEl);
|
|
431
|
+
if (idx < 0 || idx === focusedIdx) return;
|
|
432
|
+
focusedIdx = idx;
|
|
433
|
+
reflectPaneFocus();
|
|
434
|
+
repaintCursor();
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const onKey = (ev) => {
|
|
438
|
+
if (!hostEl.isConnected) return;
|
|
439
|
+
// Scope is owned by the panel-key router: this handler only runs
|
|
440
|
+
// when our tile is the selected one. We still skip editable
|
|
441
|
+
// targets so typing in a field inside the tile isn't hijacked.
|
|
442
|
+
const t = ev.target;
|
|
443
|
+
const editable = t && (
|
|
444
|
+
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
|
445
|
+
t.tagName === 'SELECT' || t.isContentEditable);
|
|
446
|
+
if (editable) return;
|
|
447
|
+
// Browser-style activation chords (caught BEFORE the modifier
|
|
448
|
+
// guard so the WM keymap doesn't swallow them):
|
|
449
|
+
// Ctrl/Cmd + Enter → open in new TAB on this tile
|
|
450
|
+
// Shift + Enter → open in new managed WINDOW
|
|
451
|
+
if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey) && !ev.altKey) {
|
|
452
|
+
ev.preventDefault();
|
|
453
|
+
activateInTab();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (ev.key === 'Enter' && ev.shiftKey && !ev.altKey
|
|
457
|
+
&& !ev.metaKey && !ev.ctrlKey) {
|
|
458
|
+
ev.preventDefault();
|
|
459
|
+
activateInWindow();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
// Other modifier chords belong to the WM / browser.
|
|
463
|
+
if (ev.metaKey || ev.ctrlKey || ev.altKey || ev.shiftKey) return;
|
|
464
|
+
|
|
465
|
+
switch (ev.key) {
|
|
466
|
+
case 'ArrowDown':
|
|
467
|
+
ev.preventDefault(); move(+1); return;
|
|
468
|
+
case 'ArrowUp':
|
|
469
|
+
ev.preventDefault(); move(-1); return;
|
|
470
|
+
case 'Home':
|
|
471
|
+
ev.preventDefault(); jump(false); return;
|
|
472
|
+
case 'End':
|
|
473
|
+
ev.preventDefault(); jump(true); return;
|
|
474
|
+
case 'Enter':
|
|
475
|
+
ev.preventDefault(); activate(); return;
|
|
476
|
+
case 'Tab':
|
|
477
|
+
// Cycle panes; single-pane case still preventDefault so
|
|
478
|
+
// focus doesn't escape into the browser chrome.
|
|
479
|
+
ev.preventDefault();
|
|
480
|
+
cyclePane(ev.shiftKey ? -1 : +1);
|
|
481
|
+
return;
|
|
482
|
+
case 'ArrowRight':
|
|
483
|
+
if (expandToggle(true)) ev.preventDefault();
|
|
484
|
+
return;
|
|
485
|
+
case 'ArrowLeft':
|
|
486
|
+
if (expandToggle(false)) ev.preventDefault();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
hostEl.addEventListener('mousedown', onMouseDown);
|
|
492
|
+
const offKey = registerPanelKeys(hostEl, onKey);
|
|
493
|
+
reflectPaneFocus();
|
|
494
|
+
// Initial cursor paint runs after the synchronous mount returns so
|
|
495
|
+
// tables that render asynchronously already have their tbody.
|
|
496
|
+
setTimeout(() => state.forEach((_, i) => repaintCursor(i)), 0);
|
|
497
|
+
|
|
498
|
+
// Park native focus on hostEl when our leaf is the active one. The
|
|
499
|
+
// panel-key router no longer depends on this for arrow routing (it
|
|
500
|
+
// keys off the focused leaf, not document.activeElement), but parking
|
|
501
|
+
// focus keeps native focus semantics — scroll-into-view, :focus-within
|
|
502
|
+
// styling, and screen-reader context — anchored to the live tile.
|
|
503
|
+
//
|
|
504
|
+
// Guarded by the WM's `.twm-leaf--focused` class so we don't steal
|
|
505
|
+
// focus when another tile owns the user's attention. The
|
|
506
|
+
// requestAnimationFrame second pass catches the case where the
|
|
507
|
+
// focus class is applied a frame after mount.
|
|
508
|
+
const parkFocusIfActive = () => {
|
|
509
|
+
const leaf = hostEl.closest?.('.twm-leaf');
|
|
510
|
+
// Page is rendered outside a tile (managed window / floating
|
|
511
|
+
// shell) — there is no focused-leaf concept; safe to take focus.
|
|
512
|
+
const inTile = !!leaf;
|
|
513
|
+
if (inTile && !leaf.classList.contains('twm-leaf--focused')) return;
|
|
514
|
+
if (hostEl.contains(document.activeElement)) return;
|
|
515
|
+
if (hostEl.tabIndex == null || hostEl.tabIndex < -1) hostEl.tabIndex = -1;
|
|
516
|
+
try { hostEl.focus({ preventScroll: true }); } catch {}
|
|
517
|
+
};
|
|
518
|
+
parkFocusIfActive();
|
|
519
|
+
requestAnimationFrame(parkFocusIfActive);
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
teardown: () => {
|
|
523
|
+
hostEl.removeEventListener('mousedown', onMouseDown);
|
|
524
|
+
offKey();
|
|
525
|
+
},
|
|
526
|
+
refresh: () => state.forEach((_, i) => repaintCursor(i)),
|
|
527
|
+
focusPane,
|
|
528
|
+
currentRow,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
/** Standardized action-footer renderer.
|
|
534
|
+
*
|
|
535
|
+
* Each action: `{ id, label, icon, shortcut?, title?, onClick }`.
|
|
536
|
+
*
|
|
537
|
+
* When `shortcut` is set (single letter), the button label gets a
|
|
538
|
+
* `[N]` suffix and pressing that key (without modifiers, outside any
|
|
539
|
+
* text input, while focus is in `hostEl` or on the body) fires the
|
|
540
|
+
* action. The visible chip + the keydown handler are the same source
|
|
541
|
+
* of truth, so users can never wonder which letter triggers what.
|
|
542
|
+
*
|
|
543
|
+
* Returns `{ teardown, refresh(actions) }`. */
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
// ─── #143 — shared landing shell ────────────────────────────────────
|
|
547
|
+
/** Mount the boilerplate every landing repeats: a detail-header with
|
|
548
|
+
* title + stat fields, a single `.ea-sfc-landing__pane` with title +
|
|
549
|
+
* keyboard-shortcut hint, the DataTable host, and a footer slot for
|
|
550
|
+
* `mountLandingActions` to fill.
|
|
551
|
+
*
|
|
552
|
+
* Callers receive `{paneEl, tableHost, footerEl, setStat(key, value)}`
|
|
553
|
+
* — `paneEl` feeds straight into `attachLandingShell({panes: [{paneEl,
|
|
554
|
+
* …}]})`; `tableHost` is where `new DataTable(host, …)` mounts;
|
|
555
|
+
* `footerEl` is the second arg to `mountLandingActions`; `setStat`
|
|
556
|
+
* patches a single stat by `data-role` without rebuilding the header.
|
|
557
|
+
*
|
|
558
|
+
* HTML output is identical to what each landing built by hand before
|
|
559
|
+
* this helper existed — only the source location moves.
|
|
560
|
+
*
|
|
561
|
+
* @param {HTMLElement} hostEl
|
|
562
|
+
* @param {object} opts
|
|
563
|
+
* @param {string} opts.title — `<h2>` text on the left of the header.
|
|
564
|
+
* @param {Array<{key,label,initial?}>} [opts.stats] — header chip per stat. `key` is the `data-role` for setStat(); `label` is the muted label above the value; `initial` defaults to `0`.
|
|
565
|
+
* @param {object} opts.pane
|
|
566
|
+
* @param {string} opts.pane.key — the `data-pane="…"` value (also doubles as the table-host's `data-role="<key>-host"`).
|
|
567
|
+
* @param {string} opts.pane.title — pane-head title.
|
|
568
|
+
* @param {string} opts.pane.hint — pane-head keyboard-shortcut hint.
|
|
569
|
+
* @returns {{headerEl: HTMLElement, paneEl: HTMLElement, tableHost: HTMLElement, footerEl: HTMLElement, setStat: (key: string, value: any) => void}}
|
|
570
|
+
*/
|
|
571
|
+
export function mountLandingShell(hostEl, opts) {
|
|
572
|
+
const {
|
|
573
|
+
title = '',
|
|
574
|
+
stats = [],
|
|
575
|
+
pane = { key: 'rows', title: '', hint: '' },
|
|
576
|
+
} = opts || {};
|
|
577
|
+
const paneKey = String(pane.key || 'rows');
|
|
578
|
+
|
|
579
|
+
const _esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
580
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
581
|
+
}[c]));
|
|
582
|
+
|
|
583
|
+
const statsHtml = (stats || []).map((s) => `
|
|
584
|
+
<span class="twm-landing__stat twm-entity-header__field">
|
|
585
|
+
<span>${_esc(s.label || '')}</span>
|
|
586
|
+
<strong data-role="${_esc(s.key)}">${_esc(s.initial != null ? s.initial : 0)}</strong>
|
|
587
|
+
</span>
|
|
588
|
+
`).join('');
|
|
589
|
+
|
|
590
|
+
hostEl.classList.add('twm-landing');
|
|
591
|
+
hostEl.innerHTML = `
|
|
592
|
+
<header class="twm-entity-header">
|
|
593
|
+
<h2>${_esc(title)}</h2>
|
|
594
|
+
${statsHtml}
|
|
595
|
+
<span class="twm-entity-header__spacer"></span>
|
|
596
|
+
</header>
|
|
597
|
+
<div class="twm-landing__split twm-landing__split--single">
|
|
598
|
+
<section class="twm-landing__pane" data-pane="${_esc(paneKey)}">
|
|
599
|
+
<div class="twm-landing__pane-head">
|
|
600
|
+
<span class="twm-landing__pane-title">${_esc(pane.title || '')}</span>
|
|
601
|
+
<span class="twm-landing__pane-hint">${_esc(pane.hint || '')}</span>
|
|
602
|
+
</div>
|
|
603
|
+
<div class="twm-landing__table" data-role="${_esc(paneKey)}-host"></div>
|
|
604
|
+
</section>
|
|
605
|
+
</div>
|
|
606
|
+
<footer class="twm-landing__actions" data-role="actions"></footer>
|
|
607
|
+
`;
|
|
608
|
+
|
|
609
|
+
const headerEl = hostEl.querySelector('.twm-entity-header');
|
|
610
|
+
const paneEl = hostEl.querySelector(`[data-pane="${paneKey}"]`);
|
|
611
|
+
const tableHost = hostEl.querySelector(`[data-role="${paneKey}-host"]`);
|
|
612
|
+
const footerEl = hostEl.querySelector('[data-role="actions"]');
|
|
613
|
+
|
|
614
|
+
const setStat = (key, value) => {
|
|
615
|
+
const el = hostEl.querySelector(`[data-role="${key}"]`);
|
|
616
|
+
if (el) el.textContent = String(value);
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
return { headerEl, paneEl, tableHost, footerEl, setStat };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
export function mountLandingActions(footerEl, actions, hostEl) {
|
|
624
|
+
if (!footerEl) return { teardown: () => {}, refresh: () => {} };
|
|
625
|
+
let current = [];
|
|
626
|
+
const renderInto = (list) => {
|
|
627
|
+
current = (list || []).filter(Boolean);
|
|
628
|
+
footerEl.innerHTML = '';
|
|
629
|
+
for (const a of current) {
|
|
630
|
+
const btn = document.createElement('button');
|
|
631
|
+
btn.type = 'button';
|
|
632
|
+
btn.className = 'twm-action-btn';
|
|
633
|
+
if (a.id) btn.dataset.action = a.id;
|
|
634
|
+
if (a.title) btn.title = a.title;
|
|
635
|
+
const icon = a.icon
|
|
636
|
+
? `<span class="material-symbols-outlined">${_esc(a.icon)}</span>`
|
|
637
|
+
: '';
|
|
638
|
+
const sc = a.shortcut
|
|
639
|
+
? ` <span class="twm-action-btn__kbd">[${_esc(a.shortcut.toUpperCase())}]</span>`
|
|
640
|
+
: '';
|
|
641
|
+
btn.innerHTML = `${icon}<span>${_esc(a.label || '')}</span>${sc}`;
|
|
642
|
+
btn.addEventListener('click', (ev) => {
|
|
643
|
+
ev.preventDefault();
|
|
644
|
+
try { a.onClick?.(ev); }
|
|
645
|
+
catch (err) { console.warn('[landing-actions] click failed', err); }
|
|
646
|
+
});
|
|
647
|
+
footerEl.appendChild(btn);
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const onKey = (ev) => {
|
|
652
|
+
if (!footerEl.isConnected) return;
|
|
653
|
+
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
|
654
|
+
// Scope is owned by the panel-key router; we only guard editable
|
|
655
|
+
// targets so single-letter shortcuts don't fire while typing.
|
|
656
|
+
const t = ev.target;
|
|
657
|
+
const editable = t && (
|
|
658
|
+
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
|
659
|
+
t.tagName === 'SELECT' || t.isContentEditable);
|
|
660
|
+
if (editable) return;
|
|
661
|
+
const k = ev.key.length === 1 ? ev.key.toLowerCase() : ev.key;
|
|
662
|
+
const hit = current.find((a) =>
|
|
663
|
+
a.shortcut && String(a.shortcut).toLowerCase() === k);
|
|
664
|
+
if (!hit) return;
|
|
665
|
+
ev.preventDefault();
|
|
666
|
+
try { hit.onClick?.(ev); }
|
|
667
|
+
catch (err) { console.warn('[landing-actions] shortcut failed', err); }
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
renderInto(actions);
|
|
671
|
+
const scopeEl = hostEl || footerEl.closest('.twm-landing') || footerEl;
|
|
672
|
+
const offKey = registerPanelKeys(scopeEl, onKey);
|
|
673
|
+
return {
|
|
674
|
+
teardown: () => offKey(),
|
|
675
|
+
refresh: renderInto,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
/** A DataTable `renderCell` hook that paints inline Edit + Delete icon
|
|
681
|
+
* buttons in the configured action column. Use as:
|
|
682
|
+
*
|
|
683
|
+
* renderCell: actionsCellRenderer(actionsColIdx, { edit: true, delete: true })
|
|
684
|
+
*
|
|
685
|
+
* Other columns fall through to the default text renderer. */
|
|
686
|
+
export function actionsCellRenderer(actionsColIdx, options = { edit: true, delete: true }) {
|
|
687
|
+
return (td, _value, colIdx) => {
|
|
688
|
+
if (colIdx !== actionsColIdx) return false;
|
|
689
|
+
const parts = [];
|
|
690
|
+
if (options.edit) {
|
|
691
|
+
parts.push(`<button type="button" class="twm-row-action"
|
|
692
|
+
data-twm-action="edit"
|
|
693
|
+
title="Edit"
|
|
694
|
+
aria-label="Edit">
|
|
695
|
+
<span class="material-symbols-outlined">edit</span>
|
|
696
|
+
</button>`);
|
|
697
|
+
}
|
|
698
|
+
if (options.delete) {
|
|
699
|
+
parts.push(`<button type="button" class="twm-row-action twm-row-action--danger"
|
|
700
|
+
data-twm-action="delete"
|
|
701
|
+
title="Delete"
|
|
702
|
+
aria-label="Delete">
|
|
703
|
+
<span class="material-symbols-outlined">delete</span>
|
|
704
|
+
</button>`);
|
|
705
|
+
}
|
|
706
|
+
td.classList.add('twm-row-actions-cell');
|
|
707
|
+
td.innerHTML = parts.join('');
|
|
708
|
+
return true;
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
function _esc(s) {
|
|
714
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
715
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
716
|
+
}[c]));
|
|
717
|
+
}
|