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,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loading_overlay.js — the canonical "content is loading" overlay.
|
|
3
|
+
*
|
|
4
|
+
* An absolutely-positioned spinner layer dropped over a slot while an async
|
|
5
|
+
* mount is in flight, with an 80ms grace period so cache hits / instant
|
|
6
|
+
* renders never strobe a spinner. Returns a `done()` that removes it.
|
|
7
|
+
*
|
|
8
|
+
* Uses the global `twm-tile-loading` styles (ui/css/tiling/host.css). Shared
|
|
9
|
+
* by the WM page-stub shell and the agent Code tab's split view.
|
|
10
|
+
*
|
|
11
|
+
* const done = makeLoadingOverlay(slot);
|
|
12
|
+
* try { await mount(); } finally { done(); }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Drop a loading overlay over `slot` and return a `done()` to remove it.
|
|
16
|
+
* The overlay stays invisible for `delay` ms (default 80) so fast renders
|
|
17
|
+
* don't flash a spinner. `slot` must be a positioned ancestor (or pass one
|
|
18
|
+
* that is); the overlay is `position:absolute; inset:0`. */
|
|
19
|
+
export function makeLoadingOverlay(slot, { delay = 80 } = {}) {
|
|
20
|
+
const overlay = document.createElement('div');
|
|
21
|
+
overlay.className = 'twm-tile-loading twm-tile-loading--pending';
|
|
22
|
+
overlay.innerHTML =
|
|
23
|
+
'<div class="twm-tile-loading__spinner" aria-label="Loading"></div>';
|
|
24
|
+
slot.appendChild(overlay);
|
|
25
|
+
// Promote to visible only if the mount hasn't finished within the grace.
|
|
26
|
+
const showTimer = setTimeout(() => {
|
|
27
|
+
overlay.classList.remove('twm-tile-loading--pending');
|
|
28
|
+
}, delay);
|
|
29
|
+
return () => {
|
|
30
|
+
clearTimeout(showTimer);
|
|
31
|
+
overlay.remove();
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nav_panel.js — the left navigation panel-tile: a mode strip, a shared
|
|
3
|
+
* filter box, and one lazily-mounted pane per mode.
|
|
4
|
+
*
|
|
5
|
+
* ┌─[ mode ][ mode ][ mode ]─────┐ mode tabs
|
|
6
|
+
* │ ┌──────────────────────────┐ │
|
|
7
|
+
* │ │ Filter… │ │ unified filter (sidebar-controls)
|
|
8
|
+
* │ └──────────────────────────┘ │
|
|
9
|
+
* │ ┌──────────────────────────┐ │
|
|
10
|
+
* │ │ (pane body) │ │
|
|
11
|
+
* │ └──────────────────────────┘ │
|
|
12
|
+
*
|
|
13
|
+
* The panel owns the strip, the filter and the pane hosts. It owns NONE of
|
|
14
|
+
* the panes: the embedder passes them in. There used to be three hardcoded
|
|
15
|
+
* modes here (a file tree, an object tree, a git view) plus a dead category
|
|
16
|
+
* browser; all of that is the embedder's, and lives on its side now.
|
|
17
|
+
*
|
|
18
|
+
* A mode:
|
|
19
|
+
*
|
|
20
|
+
* {
|
|
21
|
+
* id, icon, label,
|
|
22
|
+
* mount(paneEl, { wm, eventBus }) -> {
|
|
23
|
+
* activate?(), // called every time the mode is selected (may be async)
|
|
24
|
+
* filter?(query), // the shared filter box changed
|
|
25
|
+
* destroy?(),
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* `mount()` runs at most once, the first time its mode is selected.
|
|
30
|
+
* `activate()` runs on every selection, INCLUDING the first — a pane that
|
|
31
|
+
* has to rebuild on each visit (a git status list, say) does its work there.
|
|
32
|
+
* The filter query is remembered per mode.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export function mountNavPanel(hostEl, {
|
|
36
|
+
modes = [],
|
|
37
|
+
wm = null,
|
|
38
|
+
eventBus = null,
|
|
39
|
+
filterPlaceholder = 'Filter…',
|
|
40
|
+
} = {}) {
|
|
41
|
+
if (!Array.isArray(modes) || modes.length === 0) {
|
|
42
|
+
throw new Error('mountNavPanel: at least one mode is required');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
hostEl.classList.add('twm-nav');
|
|
46
|
+
hostEl.innerHTML = `
|
|
47
|
+
<div class="twm-nav__modes" data-role="modes"></div>
|
|
48
|
+
<div class="twm-sidebar-controls twm-nav__filter">
|
|
49
|
+
<span class="material-symbols-outlined twm-sidebar-search-icon">search</span>
|
|
50
|
+
<input class="twm-data-page__search twm-nav__filter-input"
|
|
51
|
+
type="search" placeholder="${_esc(filterPlaceholder)}"
|
|
52
|
+
autocomplete="off" />
|
|
53
|
+
</div>
|
|
54
|
+
<div class="twm-nav__body" data-role="body"></div>
|
|
55
|
+
`;
|
|
56
|
+
const modesEl = hostEl.querySelector('[data-role="modes"]');
|
|
57
|
+
const bodyEl = hostEl.querySelector('[data-role="body"]');
|
|
58
|
+
const filterEl = hostEl.querySelector('.twm-nav__filter-input');
|
|
59
|
+
|
|
60
|
+
modesEl.innerHTML = modes.map((m) => `
|
|
61
|
+
<button class="twm-nav__mode twm-has-tooltip" data-mode="${_esc(m.id)}"
|
|
62
|
+
data-tooltip="${_esc(m.label)}">
|
|
63
|
+
<span class="material-symbols-outlined">${_esc(m.icon)}</span>
|
|
64
|
+
<span>${_esc(m.label)}</span>
|
|
65
|
+
</button>
|
|
66
|
+
`).join('');
|
|
67
|
+
|
|
68
|
+
const byId = new Map(modes.map((m) => [m.id, m]));
|
|
69
|
+
const hosts = {}; // mode id -> pane host element
|
|
70
|
+
const panes = {}; // mode id -> the object the mode's mount() returned
|
|
71
|
+
const filterByMode = Object.fromEntries(modes.map((m) => [m.id, '']));
|
|
72
|
+
let mode = modes[0].id;
|
|
73
|
+
|
|
74
|
+
const ensureHost = (id) => {
|
|
75
|
+
if (hosts[id]) return hosts[id];
|
|
76
|
+
const el = document.createElement('div');
|
|
77
|
+
el.className = `twm-nav__pane twm-nav__pane--${id}`;
|
|
78
|
+
el.style.display = 'none';
|
|
79
|
+
bodyEl.appendChild(el);
|
|
80
|
+
hosts[id] = el;
|
|
81
|
+
return el;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const activate = async (next) => {
|
|
85
|
+
const def = byId.get(next);
|
|
86
|
+
if (!def) return;
|
|
87
|
+
mode = next;
|
|
88
|
+
for (const m of modes) {
|
|
89
|
+
ensureHost(m.id).style.display = (m.id === next) ? 'flex' : 'none';
|
|
90
|
+
}
|
|
91
|
+
modesEl.querySelectorAll('[data-mode]').forEach((b) =>
|
|
92
|
+
b.classList.toggle('twm-nav__mode--on', b.dataset.mode === next));
|
|
93
|
+
filterEl.value = filterByMode[next] || '';
|
|
94
|
+
|
|
95
|
+
const paneEl = ensureHost(next);
|
|
96
|
+
if (!panes[next]) {
|
|
97
|
+
try { panes[next] = def.mount(paneEl, { wm, eventBus }) || {}; }
|
|
98
|
+
catch (err) {
|
|
99
|
+
console.error('[nav] mode mount failed', next, err);
|
|
100
|
+
panes[next] = {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// `activate()` runs on EVERY selection, including the first. A pane that
|
|
104
|
+
// must rebuild on each visit (a git status list) does its work here.
|
|
105
|
+
try { await panes[next].activate?.(); }
|
|
106
|
+
catch (err) { console.error('[nav] mode activate failed', next, err); }
|
|
107
|
+
_applyFilter();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const _applyFilter = () => {
|
|
111
|
+
const q = filterByMode[mode] || '';
|
|
112
|
+
try { panes[mode]?.filter?.(q); }
|
|
113
|
+
catch (err) { console.warn('[nav] filter failed', mode, err); }
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
filterEl.addEventListener('input', () => {
|
|
117
|
+
filterByMode[mode] = filterEl.value;
|
|
118
|
+
_applyFilter();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
modesEl.addEventListener('click', (e) => {
|
|
122
|
+
const btn = e.target.closest('[data-mode]');
|
|
123
|
+
if (btn) activate(btn.dataset.mode);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
activate(mode);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
refresh: () => activate(mode),
|
|
130
|
+
destroy: () => {
|
|
131
|
+
for (const p of Object.values(panes)) {
|
|
132
|
+
try { p?.destroy?.(); } catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _esc(s) {
|
|
139
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
|
140
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
141
|
+
));
|
|
142
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* page_factory.js — the MECHANISM behind a tile's content, with no
|
|
3
|
+
* knowledge of what that content is about.
|
|
4
|
+
*
|
|
5
|
+
* `createPageFactories()` returns the four wrappers an embedder uses to
|
|
6
|
+
* turn its own page/tab modules into content factories:
|
|
7
|
+
*
|
|
8
|
+
* tabFactory(kind, factory) breadcrumb page-shell + a mounted tab
|
|
9
|
+
* bareTabFactory(kind, factory) same, minus the breadcrumb (transient forms)
|
|
10
|
+
* stubPageFactory(kind, render) breadcrumb + a plain render fn + loading overlay
|
|
11
|
+
* mountTabBody / makeShim the pieces the three are built from
|
|
12
|
+
*
|
|
13
|
+
* ── makeShim: the navigation contract ─────────────────────────────────
|
|
14
|
+
* Mounted content receives a `workspaceTabs` object. That object IS the
|
|
15
|
+
* library's public navigation API in embryo:
|
|
16
|
+
*
|
|
17
|
+
* openTab open a kind in THIS tile (openFromContext)
|
|
18
|
+
* openInTab open it as a new TAB in this tile (openInTabFromContext)
|
|
19
|
+
* openInWindow open it in a fresh managed window
|
|
20
|
+
* updateProps persist editor sub-state into this tile's tab props
|
|
21
|
+
* closeTab close this tab (falls back to closing the tile)
|
|
22
|
+
* notifyChanged broadcast "I mutated the project" so sidebars refresh
|
|
23
|
+
*
|
|
24
|
+
* ── refreshOn / events: no bus-name literals here ─────────────────────
|
|
25
|
+
* This module used to hardcode five bus names (`<domain>:run:tick`, …) and
|
|
26
|
+
* one mutation event. Both are now INJECTED:
|
|
27
|
+
*
|
|
28
|
+
* refreshOn { [busEvent]: reasonString } fanned into tab.refresh(reason)
|
|
29
|
+
* events { projectChanged? } emitted by close/notifyChanged
|
|
30
|
+
*
|
|
31
|
+
* An absent key means an absent subscription / an absent emit, so an
|
|
32
|
+
* embedder with no run loop simply passes `{}` and nothing fires.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { makeLoadingOverlay } from './loading_overlay.js';
|
|
36
|
+
import { mountTileBreadcrumb } from './tile_breadcrumb.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} cfg
|
|
40
|
+
* @param {object} cfg.eventBus the embedder's bus (may be null)
|
|
41
|
+
* @param {object} cfg.events { projectChanged?: string }
|
|
42
|
+
* @param {object} cfg.refreshOn { [busEventName]: reason } -> tab.refresh(reason)
|
|
43
|
+
*/
|
|
44
|
+
export function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } = {}) {
|
|
45
|
+
const projectChanged = events?.projectChanged || null;
|
|
46
|
+
|
|
47
|
+
// The `workspaceTabs` API tile content calls to open/close tabs and
|
|
48
|
+
// broadcast mutations. Shared by breadcrumbed and bare tabs.
|
|
49
|
+
const makeShim = (ctx) => ({
|
|
50
|
+
// Route through openFromContext so navigation triggered from a
|
|
51
|
+
// split tile / window stays in that container.
|
|
52
|
+
openTab: ({ kind: k, entityId, label, icon, subTab }) =>
|
|
53
|
+
ctx.wm?.openFromContext(ctx, k, { id: entityId, label, icon, subTab }),
|
|
54
|
+
// Open the same content as a NEW TAB in the current tile.
|
|
55
|
+
openInTab: ({ kind: k, entityId, label, icon, subTab }) =>
|
|
56
|
+
ctx.wm?.openInTabFromContext(ctx, k, { id: entityId, label, icon, subTab }),
|
|
57
|
+
// Open the same content in a fresh managed window.
|
|
58
|
+
openInWindow: ({ kind: k, entityId, label, icon, subTab }) =>
|
|
59
|
+
ctx.wm?.navigate(k, { id: entityId, label, icon, subTab }, { ctx, dest: 'window' }),
|
|
60
|
+
// Persist editor sub-state into this tile's WM tab props.
|
|
61
|
+
updateProps: (patch) => {
|
|
62
|
+
try { ctx.wm?.updateActiveTabProps?.(ctx.leafId, patch); }
|
|
63
|
+
catch (err) { console.warn('[shim] updateProps failed', err); }
|
|
64
|
+
},
|
|
65
|
+
// Close just THIS tab (not the whole tile); falls back to closing
|
|
66
|
+
// the tile when it's the last tab. Also broadcasts so sidebars /
|
|
67
|
+
// landings / nav refresh against the mutated project state.
|
|
68
|
+
closeTab: (_id) => {
|
|
69
|
+
if (projectChanged) {
|
|
70
|
+
try { eventBus?.emit?.(projectChanged, { source: 'twm-tab-close' }); } catch {}
|
|
71
|
+
}
|
|
72
|
+
try { ctx.wm?.closeActiveTab?.(ctx.leafId); } catch (err) {
|
|
73
|
+
console.warn('[shim] closeTab failed', err);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
// Non-closing mutations (Save/New/Rename) broadcast so listeners refresh.
|
|
77
|
+
notifyChanged: (domain) => {
|
|
78
|
+
if (!projectChanged) return;
|
|
79
|
+
try {
|
|
80
|
+
eventBus?.emit?.(projectChanged,
|
|
81
|
+
{ source: 'tab-mutation', domain: domain || null });
|
|
82
|
+
} catch {}
|
|
83
|
+
},
|
|
84
|
+
registerProvider: () => {},
|
|
85
|
+
getActiveTab: () => null,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Run a content factory inside a content slot: build the shim, mount
|
|
89
|
+
// the tab, drive the loading overlay, and fan the embedder's declared
|
|
90
|
+
// events into the tab's refresh(reason) hook (tabs that don't
|
|
91
|
+
// self-subscribe would otherwise freeze mid-run). Returns a teardown
|
|
92
|
+
// that removes listeners + disposes the tab.
|
|
93
|
+
const mountTabBody = (factory, contentSlot, props, ctx) => {
|
|
94
|
+
const shim = makeShim(ctx);
|
|
95
|
+
const tab = factory(contentSlot, props?.id ?? props?.entityId ?? null, {
|
|
96
|
+
logger: null, eventBus, workspaceTabs: shim,
|
|
97
|
+
wm: ctx.wm, leafId: ctx.leafId, windowId: ctx.windowId,
|
|
98
|
+
});
|
|
99
|
+
const hideLoading = makeLoadingOverlay(contentSlot);
|
|
100
|
+
try {
|
|
101
|
+
const ret = tab?.mount?.(props);
|
|
102
|
+
if (ret && typeof ret.then === 'function') {
|
|
103
|
+
ret.then(hideLoading, (err) => {
|
|
104
|
+
console.error('[page] mount failed', err);
|
|
105
|
+
hideLoading();
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
requestAnimationFrame(hideLoading);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error('[page] mount failed', err);
|
|
112
|
+
hideLoading();
|
|
113
|
+
}
|
|
114
|
+
const listeners = {};
|
|
115
|
+
if (typeof tab?.refresh === 'function' && eventBus?.on) {
|
|
116
|
+
for (const [busEvent, reason] of Object.entries(refreshOn || {})) {
|
|
117
|
+
const fn = () => {
|
|
118
|
+
try { tab.refresh(reason); }
|
|
119
|
+
catch (err) { console.warn('[page] refresh failed', busEvent, err); }
|
|
120
|
+
};
|
|
121
|
+
listeners[busEvent] = fn;
|
|
122
|
+
eventBus.on(busEvent, fn);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return () => {
|
|
126
|
+
for (const [n, fn] of Object.entries(listeners)) {
|
|
127
|
+
try { eventBus?.off?.(n, fn); } catch {}
|
|
128
|
+
}
|
|
129
|
+
try { tab?.dispose?.(); tab?.unmount?.(); } catch {}
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Wrap a tab factory in a "page shell" — breadcrumb strip at the top,
|
|
134
|
+
* content fills the rest. `kind` is captured at build time so the
|
|
135
|
+
* breadcrumb knows which crumbs to build. */
|
|
136
|
+
const tabFactory = (kind, factory) => (host, props, ctx) => {
|
|
137
|
+
host.classList.add('twm-page-shell');
|
|
138
|
+
host.innerHTML = '';
|
|
139
|
+
const contentSlot = document.createElement('div');
|
|
140
|
+
contentSlot.className = 'twm-page-shell__content';
|
|
141
|
+
// Focusable via JS so `document.activeElement` lands inside the
|
|
142
|
+
// page when the tile is activated.
|
|
143
|
+
contentSlot.tabIndex = -1;
|
|
144
|
+
|
|
145
|
+
// A breadcrumb navigates the host TILE — meaningless for an entity
|
|
146
|
+
// opened in a standalone managed window, where we want only the
|
|
147
|
+
// entity view. Skip the crumb when mounted in a window.
|
|
148
|
+
let crumb = null;
|
|
149
|
+
if (!ctx?.windowId) {
|
|
150
|
+
const breadcrumbSlot = document.createElement('div');
|
|
151
|
+
breadcrumbSlot.className = 'twm-page-shell__breadcrumb';
|
|
152
|
+
host.appendChild(breadcrumbSlot);
|
|
153
|
+
crumb = mountTileBreadcrumb(kind, props, { ...ctx, eventBus });
|
|
154
|
+
breadcrumbSlot.appendChild(crumb.el);
|
|
155
|
+
}
|
|
156
|
+
host.appendChild(contentSlot);
|
|
157
|
+
|
|
158
|
+
const teardown = mountTabBody(factory, contentSlot, props, ctx);
|
|
159
|
+
return {
|
|
160
|
+
destroy: () => {
|
|
161
|
+
teardown();
|
|
162
|
+
try { crumb?.destroy(); } catch {}
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Like tabFactory but with NO breadcrumb chrome — for transient /
|
|
168
|
+
// form content (e.g. an add-row entry mask) that isn't an entity
|
|
169
|
+
// view. Content still gets the full ctx (wm/leafId) + shim, so it
|
|
170
|
+
// can self-close via wm.closeActiveTab.
|
|
171
|
+
const bareTabFactory = (kind, factory) => (host, props, ctx) => {
|
|
172
|
+
host.classList.add('twm-page-shell');
|
|
173
|
+
host.innerHTML = '';
|
|
174
|
+
const contentSlot = document.createElement('div');
|
|
175
|
+
contentSlot.className = 'twm-page-shell__content';
|
|
176
|
+
contentSlot.tabIndex = -1;
|
|
177
|
+
host.appendChild(contentSlot);
|
|
178
|
+
return { destroy: mountTabBody(factory, contentSlot, props, ctx) };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Page with a breadcrumb at the top and a plain `render(slot, props, ctx)`
|
|
182
|
+
* body — for content that isn't a full tab object. If `render` returns a
|
|
183
|
+
* `ready` promise the loading overlay waits on it. */
|
|
184
|
+
const stubPageFactory = (kind, render) => (host, props, ctx) => {
|
|
185
|
+
host.classList.add('twm-page-shell');
|
|
186
|
+
host.innerHTML = '';
|
|
187
|
+
const breadcrumbSlot = document.createElement('div');
|
|
188
|
+
breadcrumbSlot.className = 'twm-page-shell__breadcrumb';
|
|
189
|
+
const contentSlot = document.createElement('div');
|
|
190
|
+
contentSlot.className = 'twm-page-shell__content';
|
|
191
|
+
contentSlot.tabIndex = -1;
|
|
192
|
+
host.appendChild(breadcrumbSlot);
|
|
193
|
+
host.appendChild(contentSlot);
|
|
194
|
+
const crumb = mountTileBreadcrumb(kind, props, { ...ctx, eventBus });
|
|
195
|
+
breadcrumbSlot.appendChild(crumb.el);
|
|
196
|
+
const hideLoading = makeLoadingOverlay(contentSlot);
|
|
197
|
+
const ret = render(contentSlot, props, ctx) || {};
|
|
198
|
+
// Most landings kick off their own async refresh after the
|
|
199
|
+
// synchronous render returns. If render exposes a `ready`
|
|
200
|
+
// promise we await it; otherwise hide on the next frame.
|
|
201
|
+
if (ret.ready && typeof ret.ready.then === 'function') {
|
|
202
|
+
ret.ready.then(hideLoading, hideLoading);
|
|
203
|
+
} else {
|
|
204
|
+
requestAnimationFrame(hideLoading);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
title: ret.title,
|
|
208
|
+
destroy: () => { try { crumb.destroy(); } catch {} ret.destroy?.(); },
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return Object.freeze({
|
|
213
|
+
makeShim, mountTabBody, tabFactory, bareTabFactory, stubPageFactory,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panel_keys.js — central keyboard router for panel-local navigation.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM IT SOLVES
|
|
5
|
+
* ---------------------
|
|
6
|
+
* Several panels want plain-key navigation (↑/↓ to move a cursor, Enter to
|
|
7
|
+
* open, single letters for actions): the landing tables, the top-nav
|
|
8
|
+
* overview pages, and any future browser panel. Each one used to attach its
|
|
9
|
+
* OWN `document` keydown listener gated by the heuristic
|
|
10
|
+
*
|
|
11
|
+
* hostEl.contains(document.activeElement) || activeElement === <body>
|
|
12
|
+
*
|
|
13
|
+
* That heuristic is wrong as soon as there is more than one tile. When the
|
|
14
|
+
* user selects a different tile, focus usually rests on `<body>`, so EVERY
|
|
15
|
+
* mounted landing's listener still fires — the "main" panel keeps eating
|
|
16
|
+
* arrow keys even though another panel is selected, and two panels can move
|
|
17
|
+
* their cursors at once. The selected tile is never actually consulted.
|
|
18
|
+
*
|
|
19
|
+
* THE CONCEPT
|
|
20
|
+
* -----------
|
|
21
|
+
* One listener, one authority. Panels do not listen on `document`
|
|
22
|
+
* themselves; they REGISTER a handler bound to their host element. On each
|
|
23
|
+
* keydown the router asks the window manager which leaf is currently
|
|
24
|
+
* focused (the single source of truth — `WindowManager.focusedLeafEl()`)
|
|
25
|
+
* and dispatches the event to the handler whose host lives inside that leaf
|
|
26
|
+
* — and to no one else. A panel automatically receives keys exactly when,
|
|
27
|
+
* and only when, it is the selected tile.
|
|
28
|
+
*
|
|
29
|
+
* Content rendered OUTSIDE the tile tree (a promoted managed window or a
|
|
30
|
+
* floating shell) has no focused-leaf concept; for those hosts the router
|
|
31
|
+
* falls back to the focus-containment scope so they keep working.
|
|
32
|
+
*
|
|
33
|
+
* USAGE
|
|
34
|
+
* -----
|
|
35
|
+
* import { registerPanelKeys } from './panel_keys.js';
|
|
36
|
+
* const off = registerPanelKeys(hostEl, (ev) => { ... });
|
|
37
|
+
* // later, on teardown:
|
|
38
|
+
* off();
|
|
39
|
+
*
|
|
40
|
+
* The handler is responsible for its own key semantics (which keys, whether
|
|
41
|
+
* to skip editable targets, when to preventDefault). The router only decides
|
|
42
|
+
* WHETHER this panel is the active keyboard target. A handler that calls
|
|
43
|
+
* `ev.preventDefault()` stops the event from reaching any other handler in
|
|
44
|
+
* the same leaf.
|
|
45
|
+
*
|
|
46
|
+
* When no router has been installed (unit tests, or a landing used outside
|
|
47
|
+
* the WM), `registerPanelKeys` degrades to a self-contained listener using
|
|
48
|
+
* the legacy focus-containment scope, so callers work in isolation too.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
let _router = null;
|
|
52
|
+
|
|
53
|
+
/** Install the singleton router. Called once by the WindowManager. */
|
|
54
|
+
export function installPanelKeyRouter(wm) {
|
|
55
|
+
if (_router) _router.destroy();
|
|
56
|
+
_router = new PanelKeyRouter(wm);
|
|
57
|
+
return _router;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The installed router, or null if the WM hasn't built one yet. */
|
|
61
|
+
export function getPanelKeyRouter() { return _router; }
|
|
62
|
+
|
|
63
|
+
/** Tear down the singleton (test cleanup / shell teardown). */
|
|
64
|
+
export function uninstallPanelKeyRouter() {
|
|
65
|
+
_router?.destroy();
|
|
66
|
+
_router = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Register `handler` as the key handler for `hostEl`'s panel. Returns an
|
|
71
|
+
* unregister function — call it on teardown. Routes through the installed
|
|
72
|
+
* router when present, otherwise falls back to a legacy scoped listener so
|
|
73
|
+
* the panel still works outside the WM.
|
|
74
|
+
*/
|
|
75
|
+
export function registerPanelKeys(hostEl, handler) {
|
|
76
|
+
if (!hostEl || typeof handler !== 'function') return () => {};
|
|
77
|
+
if (_router) return _router.register(hostEl, handler);
|
|
78
|
+
return _legacyRegister(hostEl, handler);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class PanelKeyRouter {
|
|
82
|
+
constructor(wm) {
|
|
83
|
+
this._wm = wm;
|
|
84
|
+
this._entries = []; // { hostEl, handler }
|
|
85
|
+
this._onKey = this._dispatch.bind(this);
|
|
86
|
+
document.addEventListener('keydown', this._onKey);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
register(hostEl, handler) {
|
|
90
|
+
const entry = { hostEl, handler };
|
|
91
|
+
this._entries.push(entry);
|
|
92
|
+
return () => {
|
|
93
|
+
const i = this._entries.indexOf(entry);
|
|
94
|
+
if (i >= 0) this._entries.splice(i, 1);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
destroy() {
|
|
99
|
+
document.removeEventListener('keydown', this._onKey);
|
|
100
|
+
this._entries.length = 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Is `hostEl` the panel that should receive keys right now? */
|
|
104
|
+
_isActiveTarget(hostEl) {
|
|
105
|
+
if (!hostEl?.isConnected) return false;
|
|
106
|
+
const leaf = hostEl.closest?.('.twm-leaf');
|
|
107
|
+
if (leaf) {
|
|
108
|
+
// Inside the tile tree: only the focused leaf is live. This is
|
|
109
|
+
// the whole point — compare against the WM's single source of
|
|
110
|
+
// truth, not against `document.activeElement`.
|
|
111
|
+
return leaf === this._wm?.focusedLeafEl?.();
|
|
112
|
+
}
|
|
113
|
+
// Outside any tile (managed window / floating shell): no focused
|
|
114
|
+
// leaf to consult, so fall back to focus containment.
|
|
115
|
+
return hostEl.contains(document.activeElement)
|
|
116
|
+
|| document.activeElement === document.body;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_dispatch(ev) {
|
|
120
|
+
// Snapshot: a handler may unregister (e.g. navigate away) mid-loop.
|
|
121
|
+
for (const entry of [...this._entries]) {
|
|
122
|
+
if (!this._isActiveTarget(entry.hostEl)) continue;
|
|
123
|
+
try { entry.handler(ev); }
|
|
124
|
+
catch (err) { console.warn('[panel-keys] handler threw', err); }
|
|
125
|
+
// A handler that consumed the key ends the chain so a second
|
|
126
|
+
// handler in the same leaf (e.g. the action-footer shortcuts)
|
|
127
|
+
// doesn't double-fire on the same press.
|
|
128
|
+
if (ev.defaultPrevented) return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Legacy fallback used only when no router is installed. Mirrors the old
|
|
134
|
+
* per-panel scope check so isolated/test usage keeps its previous
|
|
135
|
+
* behavior. */
|
|
136
|
+
function _legacyRegister(hostEl, handler) {
|
|
137
|
+
const wrapped = (ev) => {
|
|
138
|
+
if (!hostEl.isConnected) return;
|
|
139
|
+
if (!hostEl.contains(document.activeElement)
|
|
140
|
+
&& document.activeElement !== document.body) return;
|
|
141
|
+
handler(ev);
|
|
142
|
+
};
|
|
143
|
+
document.addEventListener('keydown', wrapped);
|
|
144
|
+
return () => document.removeEventListener('keydown', wrapped);
|
|
145
|
+
}
|