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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotificationHistory - Bottom bar notification history panel
|
|
3
|
+
*
|
|
4
|
+
* Bell icon in the global-bottom-bar that opens a popover listing past notifications.
|
|
5
|
+
* Reads from NotificationCenter.history (no modifications to NotificationCenter needed).
|
|
6
|
+
* Tracks unread count via lastSeenCount delta and updates a badge on the bell icon.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class NotificationHistory {
|
|
10
|
+
constructor({ notificationCenter, eventBus, logger } = {}) {
|
|
11
|
+
this.notificationCenter = notificationCenter;
|
|
12
|
+
this.eventBus = eventBus || null;
|
|
13
|
+
this.logger = logger || console;
|
|
14
|
+
|
|
15
|
+
this.triggerEl = null;
|
|
16
|
+
this.badgeEl = null;
|
|
17
|
+
this.lastSeenCount = 0;
|
|
18
|
+
this._popoverCleanup = null;
|
|
19
|
+
this._subscriptions = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
mount() {
|
|
23
|
+
const barRight = document.querySelector('.twm-global-bottom-bar .twm-bar-right');
|
|
24
|
+
if (!barRight) {
|
|
25
|
+
this.logger.warn?.('[NotificationHistory] .bar-right not found');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Build trigger button
|
|
30
|
+
this.triggerEl = document.createElement('button');
|
|
31
|
+
this.triggerEl.className = 'twm-notification-history__trigger twm-has-tooltip';
|
|
32
|
+
this.triggerEl.setAttribute('data-tooltip', 'Notification history');
|
|
33
|
+
this.triggerEl.setAttribute('aria-label', 'Notification history');
|
|
34
|
+
this.triggerEl.innerHTML =
|
|
35
|
+
'<span class="material-symbols-outlined">notifications</span>' +
|
|
36
|
+
'<span class="twm-notification-history__badge" style="display:none">0</span>';
|
|
37
|
+
|
|
38
|
+
this.badgeEl = this.triggerEl.querySelector('.twm-notification-history__badge');
|
|
39
|
+
|
|
40
|
+
// Park the bell as the LAST child of .bar-right so it always
|
|
41
|
+
// sits at the far-right edge of the status bar — past the
|
|
42
|
+
// tick readout, streaming dot, and anything else that the
|
|
43
|
+
// global actions cluster mounts before it.
|
|
44
|
+
barRight.appendChild(this.triggerEl);
|
|
45
|
+
|
|
46
|
+
this.triggerEl.addEventListener('click', (e) => {
|
|
47
|
+
e.stopPropagation();
|
|
48
|
+
this.#togglePanel();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Sync initial count so existing history doesn't show as unread
|
|
52
|
+
this.lastSeenCount = this.notificationCenter?.history?.length ?? 0;
|
|
53
|
+
|
|
54
|
+
this.#subscribeEvents();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
unmount() {
|
|
58
|
+
this.#closePanel();
|
|
59
|
+
this._subscriptions.forEach(unsub => {
|
|
60
|
+
if (typeof unsub === 'function') unsub();
|
|
61
|
+
});
|
|
62
|
+
this._subscriptions = [];
|
|
63
|
+
if (this.triggerEl?.parentNode) {
|
|
64
|
+
this.triggerEl.parentNode.removeChild(this.triggerEl);
|
|
65
|
+
}
|
|
66
|
+
this.triggerEl = null;
|
|
67
|
+
this.badgeEl = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#subscribeEvents() {
|
|
71
|
+
if (!this.eventBus) return;
|
|
72
|
+
|
|
73
|
+
// Update badge whenever a new notification comes in
|
|
74
|
+
const badgeUpdate = () => queueMicrotask(() => this.#updateBadge());
|
|
75
|
+
const events = ['toast:show', 'notification:show', 'log:entry',
|
|
76
|
+
'workspace:save:failed', 'workspace:import:failed'];
|
|
77
|
+
|
|
78
|
+
events.forEach(name => {
|
|
79
|
+
this.eventBus.on(name, badgeUpdate);
|
|
80
|
+
this._subscriptions.push(() => this.eventBus.off(name, badgeUpdate));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#updateBadge() {
|
|
85
|
+
const historyLen = this.notificationCenter?.history?.length ?? 0;
|
|
86
|
+
const unread = Math.max(0, historyLen - this.lastSeenCount);
|
|
87
|
+
|
|
88
|
+
if (!this.badgeEl) return;
|
|
89
|
+
|
|
90
|
+
if (unread > 0) {
|
|
91
|
+
this.badgeEl.textContent = unread > 99 ? '99+' : String(unread);
|
|
92
|
+
this.badgeEl.style.display = '';
|
|
93
|
+
} else {
|
|
94
|
+
this.badgeEl.style.display = 'none';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#togglePanel() {
|
|
99
|
+
if (this._popoverCleanup) {
|
|
100
|
+
this.#closePanel();
|
|
101
|
+
} else {
|
|
102
|
+
this.#openPanel();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#closePanel() {
|
|
107
|
+
if (typeof this._popoverCleanup === 'function') {
|
|
108
|
+
try { this._popoverCleanup(); } catch (_) { /* ignore */ }
|
|
109
|
+
this._popoverCleanup = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#openPanel() {
|
|
114
|
+
this.#closePanel();
|
|
115
|
+
|
|
116
|
+
// Mark all as seen
|
|
117
|
+
this.lastSeenCount = this.notificationCenter?.history?.length ?? 0;
|
|
118
|
+
this.#updateBadge();
|
|
119
|
+
|
|
120
|
+
const panel = document.createElement('div');
|
|
121
|
+
panel.className = 'twm-notification-history__panel';
|
|
122
|
+
|
|
123
|
+
// Header
|
|
124
|
+
const header = document.createElement('div');
|
|
125
|
+
header.className = 'twm-notification-history__header';
|
|
126
|
+
header.innerHTML =
|
|
127
|
+
'<span class="twm-notification-history__title">Notifications</span>' +
|
|
128
|
+
'<button class="twm-notification-history__clear-btn twm-has-tooltip" data-tooltip="Clear all" aria-label="Clear all">' +
|
|
129
|
+
'<span class="material-symbols-outlined">delete_sweep</span>' +
|
|
130
|
+
'</button>' +
|
|
131
|
+
'<button class="twm-notification-history__close-btn twm-has-tooltip" data-tooltip="Close" aria-label="Close">' +
|
|
132
|
+
'<span class="material-symbols-outlined">close</span>' +
|
|
133
|
+
'</button>';
|
|
134
|
+
panel.appendChild(header);
|
|
135
|
+
|
|
136
|
+
// List
|
|
137
|
+
const list = document.createElement('div');
|
|
138
|
+
list.className = 'twm-notification-history__list';
|
|
139
|
+
panel.appendChild(list);
|
|
140
|
+
|
|
141
|
+
this.#renderList(list);
|
|
142
|
+
|
|
143
|
+
// Wire header buttons
|
|
144
|
+
header.querySelector('.twm-notification-history__close-btn')
|
|
145
|
+
.addEventListener('click', () => this.#closePanel());
|
|
146
|
+
|
|
147
|
+
header.querySelector('.twm-notification-history__clear-btn')
|
|
148
|
+
.addEventListener('click', () => {
|
|
149
|
+
this.notificationCenter.history.splice(0);
|
|
150
|
+
this.lastSeenCount = 0;
|
|
151
|
+
this.#updateBadge();
|
|
152
|
+
this.#renderList(list);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Position and attach
|
|
156
|
+
document.body.appendChild(panel);
|
|
157
|
+
this.#positionPanel(panel);
|
|
158
|
+
|
|
159
|
+
// Popover lifecycle (same pattern as ValidationStatus)
|
|
160
|
+
const cleanup = () => {
|
|
161
|
+
document.removeEventListener('mousedown', onDocDown, true);
|
|
162
|
+
document.removeEventListener('keydown', onKey, true);
|
|
163
|
+
window.removeEventListener('resize', onResize, true);
|
|
164
|
+
try { panel.remove(); } catch (_) { /* ignore */ }
|
|
165
|
+
this._popoverCleanup = null;
|
|
166
|
+
};
|
|
167
|
+
this._popoverCleanup = cleanup;
|
|
168
|
+
|
|
169
|
+
const onDocDown = (ev) => {
|
|
170
|
+
if (!panel.contains(ev.target) && !this.triggerEl.contains(ev.target)) cleanup();
|
|
171
|
+
};
|
|
172
|
+
const onKey = (ev) => { if (ev.key === 'Escape') cleanup(); };
|
|
173
|
+
const onResize = () => cleanup();
|
|
174
|
+
|
|
175
|
+
document.addEventListener('mousedown', onDocDown, true);
|
|
176
|
+
document.addEventListener('keydown', onKey, true);
|
|
177
|
+
window.addEventListener('resize', onResize, true);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#renderList(listEl) {
|
|
181
|
+
listEl.innerHTML = '';
|
|
182
|
+
const history = this.notificationCenter?.history ?? [];
|
|
183
|
+
|
|
184
|
+
if (history.length === 0) {
|
|
185
|
+
const empty = document.createElement('div');
|
|
186
|
+
empty.className = 'twm-notification-history__empty';
|
|
187
|
+
empty.textContent = 'No notifications';
|
|
188
|
+
listEl.appendChild(empty);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Reverse chronological
|
|
193
|
+
const fragment = document.createDocumentFragment();
|
|
194
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
195
|
+
const entry = history[i];
|
|
196
|
+
const item = document.createElement('div');
|
|
197
|
+
const severity = entry.severity || 'info';
|
|
198
|
+
item.className = `twm-notification-history__item notification-history__item--${severity}`;
|
|
199
|
+
|
|
200
|
+
const itemHeader = document.createElement('div');
|
|
201
|
+
itemHeader.className = 'twm-notification-history__item-header';
|
|
202
|
+
|
|
203
|
+
if (entry.title) {
|
|
204
|
+
const titleEl = document.createElement('span');
|
|
205
|
+
titleEl.className = 'twm-notification-history__item-title';
|
|
206
|
+
titleEl.textContent = entry.title;
|
|
207
|
+
itemHeader.appendChild(titleEl);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const timeEl = document.createElement('span');
|
|
211
|
+
timeEl.className = 'twm-notification-history__item-time';
|
|
212
|
+
timeEl.textContent = this.#formatTime(entry.timestamp);
|
|
213
|
+
itemHeader.appendChild(timeEl);
|
|
214
|
+
|
|
215
|
+
item.appendChild(itemHeader);
|
|
216
|
+
|
|
217
|
+
const body = document.createElement('div');
|
|
218
|
+
body.className = 'twm-notification-history__item-body';
|
|
219
|
+
body.textContent = entry.message || '';
|
|
220
|
+
item.appendChild(body);
|
|
221
|
+
|
|
222
|
+
fragment.appendChild(item);
|
|
223
|
+
}
|
|
224
|
+
listEl.appendChild(fragment);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#positionPanel(panel) {
|
|
228
|
+
if (!this.triggerEl) return;
|
|
229
|
+
|
|
230
|
+
const triggerRect = this.triggerEl.getBoundingClientRect();
|
|
231
|
+
const panelWidth = panel.getBoundingClientRect().width;
|
|
232
|
+
const gap = 8;
|
|
233
|
+
|
|
234
|
+
let left = triggerRect.right - panelWidth;
|
|
235
|
+
|
|
236
|
+
// Clamp horizontally to the viewport.
|
|
237
|
+
const vw = window.innerWidth;
|
|
238
|
+
left = Math.max(8, Math.min(left, vw - panelWidth - 8));
|
|
239
|
+
|
|
240
|
+
// Anchor by the panel's BOTTOM edge, pinned just above the bell.
|
|
241
|
+
// Anchoring by `top` leaves the box floating when its content shrinks
|
|
242
|
+
// (notifications cleared/removed): the top stays put while the bottom
|
|
243
|
+
// lifts away from the bell, stranding the panel in mid-air. Pinning
|
|
244
|
+
// the bottom keeps it glued to the bottom bar and lets it grow/shrink
|
|
245
|
+
// upward — no repositioning needed on re-render.
|
|
246
|
+
const bottom = window.innerHeight - (triggerRect.top - gap);
|
|
247
|
+
|
|
248
|
+
// Never let the panel run off the top of the viewport: cap its height
|
|
249
|
+
// to the space above the bell (within the design max). The list
|
|
250
|
+
// scrolls within whatever remains.
|
|
251
|
+
const available = triggerRect.top - gap - 8;
|
|
252
|
+
panel.style.maxHeight = `${Math.max(0, Math.min(420, Math.round(available)))}px`;
|
|
253
|
+
|
|
254
|
+
panel.style.left = `${Math.round(left)}px`;
|
|
255
|
+
panel.style.bottom = `${Math.round(bottom)}px`;
|
|
256
|
+
panel.style.top = 'auto';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#formatTime(timestamp) {
|
|
260
|
+
if (!timestamp) return '';
|
|
261
|
+
const d = new Date(timestamp);
|
|
262
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlideOutPanel
|
|
3
|
+
*
|
|
4
|
+
* Reusable inline slide-out config panel that overlays its host container
|
|
5
|
+
* from the right edge. Used by pages to show element-specific config
|
|
6
|
+
* (plot config, widget config, ETL node config) without the global right panel.
|
|
7
|
+
*
|
|
8
|
+
* Host container must have `position: relative; overflow: hidden`.
|
|
9
|
+
*/
|
|
10
|
+
import { ComponentBase } from '../base/component_base.js';
|
|
11
|
+
|
|
12
|
+
export class SlideOutPanel extends ComponentBase {
|
|
13
|
+
|
|
14
|
+
/** @type {HTMLElement} */ #container = null;
|
|
15
|
+
/** @type {HTMLElement} */ #backdrop = null;
|
|
16
|
+
/** @type {HTMLElement} */ #panel = null;
|
|
17
|
+
/** @type {HTMLElement} */ #headerIcon = null;
|
|
18
|
+
/** @type {HTMLElement} */ #headerTitle = null;
|
|
19
|
+
/** @type {HTMLElement} */ #body = null;
|
|
20
|
+
/** @type {number} */ #width;
|
|
21
|
+
/** @type {Function|null} */#onClose;
|
|
22
|
+
/** @type {boolean} */ #open = false;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {number} [opts.width=380] Panel width in px.
|
|
27
|
+
* @param {Function} [opts.onClose] Called after the panel closes.
|
|
28
|
+
*/
|
|
29
|
+
constructor({ width = 700, onClose } = {}) {
|
|
30
|
+
super();
|
|
31
|
+
this.#width = width;
|
|
32
|
+
this.#onClose = onClose ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mount the panel DOM into a host container.
|
|
39
|
+
* @param {HTMLElement} container Must have position:relative; overflow:hidden.
|
|
40
|
+
*/
|
|
41
|
+
mount(container) {
|
|
42
|
+
if (this._mounted) return;
|
|
43
|
+
this.#container = container;
|
|
44
|
+
|
|
45
|
+
// Backdrop (transparent click-catcher)
|
|
46
|
+
this.#backdrop = document.createElement('div');
|
|
47
|
+
this.#backdrop.className = 'twm-slide-out-panel__backdrop';
|
|
48
|
+
this.#backdrop.addEventListener('click', this.#handleBackdropClick);
|
|
49
|
+
|
|
50
|
+
// Panel
|
|
51
|
+
this.#panel = document.createElement('div');
|
|
52
|
+
this.#panel.className = 'twm-slide-out-panel';
|
|
53
|
+
this.#panel.style.width = `${this.#width}px`;
|
|
54
|
+
|
|
55
|
+
// Header
|
|
56
|
+
const header = document.createElement('div');
|
|
57
|
+
header.className = 'twm-slide-out-panel__header';
|
|
58
|
+
|
|
59
|
+
this.#headerIcon = document.createElement('span');
|
|
60
|
+
this.#headerIcon.className = 'material-symbols-outlined';
|
|
61
|
+
|
|
62
|
+
this.#headerTitle = document.createElement('span');
|
|
63
|
+
this.#headerTitle.className = 'twm-slide-out-panel__title';
|
|
64
|
+
|
|
65
|
+
const closeBtn = document.createElement('button');
|
|
66
|
+
closeBtn.className = 'twm-slide-out-panel__close twm-btn-icon';
|
|
67
|
+
closeBtn.type = 'button';
|
|
68
|
+
closeBtn.title = 'Close';
|
|
69
|
+
closeBtn.innerHTML = '<span class="material-symbols-outlined">close</span>';
|
|
70
|
+
closeBtn.addEventListener('click', this.#handleCloseClick);
|
|
71
|
+
|
|
72
|
+
header.append(this.#headerIcon, this.#headerTitle, closeBtn);
|
|
73
|
+
|
|
74
|
+
// Body (scrollable content area)
|
|
75
|
+
this.#body = document.createElement('div');
|
|
76
|
+
this.#body.className = 'twm-slide-out-panel__body';
|
|
77
|
+
|
|
78
|
+
this.#panel.append(header, this.#body);
|
|
79
|
+
this.#container.append(this.#backdrop, this.#panel);
|
|
80
|
+
|
|
81
|
+
this._mounted = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
dispose() {
|
|
85
|
+
this.close();
|
|
86
|
+
this.#backdrop?.remove();
|
|
87
|
+
this.#panel?.remove();
|
|
88
|
+
this.#backdrop = null;
|
|
89
|
+
this.#panel = null;
|
|
90
|
+
this.#body = null;
|
|
91
|
+
this.#container = null;
|
|
92
|
+
super.dispose();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Public API ─────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** The inner content element — callers render config into this. */
|
|
98
|
+
get contentEl() { return this.#body; }
|
|
99
|
+
|
|
100
|
+
get isOpen() { return this.#open; }
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Open (or re-open) the panel with a new title/icon.
|
|
104
|
+
* Clears previous content. Returns contentEl for rendering.
|
|
105
|
+
* @param {string} title
|
|
106
|
+
* @param {string} icon Material Symbols icon name.
|
|
107
|
+
* @returns {HTMLElement} contentEl
|
|
108
|
+
*/
|
|
109
|
+
open(title, icon) {
|
|
110
|
+
if (!this._mounted) return null;
|
|
111
|
+
|
|
112
|
+
this.#body.innerHTML = '';
|
|
113
|
+
this.#headerIcon.textContent = icon || '';
|
|
114
|
+
this.#headerIcon.style.display = icon ? '' : 'none';
|
|
115
|
+
this.#headerTitle.textContent = title || '';
|
|
116
|
+
|
|
117
|
+
if (!this.#open) {
|
|
118
|
+
this.#open = true;
|
|
119
|
+
this.#backdrop.classList.add('twm-slide-out-panel__backdrop--visible');
|
|
120
|
+
this.#panel.classList.add('twm-slide-out-panel--open');
|
|
121
|
+
document.addEventListener('keydown', this.#handleEsc);
|
|
122
|
+
|
|
123
|
+
// The host may be a scroll container — absolutely-positioned
|
|
124
|
+
// descendants of one DO scroll along with the content
|
|
125
|
+
// (Web spec: a position:absolute element is clipped + offset
|
|
126
|
+
// by its containing scroll context). We escape that by
|
|
127
|
+
// switching the panel to `position: fixed` while open and
|
|
128
|
+
// tracking the host's bounding rect on scroll / resize, so
|
|
129
|
+
// the panel stays pinned to the host's visible frame at
|
|
130
|
+
// full height regardless of how the user scrolls inside.
|
|
131
|
+
this.#trackGeometry();
|
|
132
|
+
window.addEventListener('scroll', this.#trackGeometry, true);
|
|
133
|
+
window.addEventListener('resize', this.#trackGeometry);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return this.#body;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Close the panel and clear content. */
|
|
140
|
+
close() {
|
|
141
|
+
if (!this.#open) return;
|
|
142
|
+
this.#open = false;
|
|
143
|
+
|
|
144
|
+
this.#panel.classList.remove('twm-slide-out-panel--open');
|
|
145
|
+
this.#backdrop.classList.remove('twm-slide-out-panel__backdrop--visible');
|
|
146
|
+
document.removeEventListener('keydown', this.#handleEsc);
|
|
147
|
+
window.removeEventListener('scroll', this.#trackGeometry, true);
|
|
148
|
+
window.removeEventListener('resize', this.#trackGeometry);
|
|
149
|
+
// Leave the inline position:fixed geometry in place. The panel
|
|
150
|
+
// slides off-screen via the transform animation (translateX
|
|
151
|
+
// 100%); the next open() re-runs #trackGeometry which will
|
|
152
|
+
// refresh top/left/height from the host's current rect.
|
|
153
|
+
|
|
154
|
+
this.#body.innerHTML = '';
|
|
155
|
+
this.#onClose?.();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Event handlers (arrow fns for stable `this`) ───────────────────────
|
|
159
|
+
|
|
160
|
+
#handleBackdropClick = () => { this.close(); };
|
|
161
|
+
|
|
162
|
+
#handleCloseClick = () => { this.close(); };
|
|
163
|
+
|
|
164
|
+
#handleEsc = (e) => {
|
|
165
|
+
if (e.key === 'Escape') {
|
|
166
|
+
e.stopPropagation();
|
|
167
|
+
this.close();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Pin the panel to the right edge of its host's *visible* frame
|
|
172
|
+
* using `position: fixed`. Re-run on every scroll/resize so the
|
|
173
|
+
* panel always overlays the host correctly even when the host
|
|
174
|
+
* itself sits inside a scrolled ancestor. */
|
|
175
|
+
#trackGeometry = () => {
|
|
176
|
+
if (!this.#open || !this.#panel || !this.#container) return;
|
|
177
|
+
const r = this.#container.getBoundingClientRect();
|
|
178
|
+
const s = this.#panel.style;
|
|
179
|
+
s.position = 'fixed';
|
|
180
|
+
s.top = `${Math.round(r.top)}px`;
|
|
181
|
+
s.left = `${Math.round(r.right - this.#width)}px`;
|
|
182
|
+
s.right = 'auto';
|
|
183
|
+
s.height = `${Math.round(r.height)}px`;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export class SliderField {
|
|
2
|
+
constructor({ fieldId, field = {}, onChange = null, logger = null } = {}) {
|
|
3
|
+
if (!fieldId) {
|
|
4
|
+
throw new Error('SliderField requires a fieldId');
|
|
5
|
+
}
|
|
6
|
+
this.fieldId = fieldId;
|
|
7
|
+
this.field = field;
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
this.onChange = typeof onChange === 'function' ? onChange : null;
|
|
10
|
+
|
|
11
|
+
this.root = null;
|
|
12
|
+
this.sliderInput = null;
|
|
13
|
+
this.helperEl = null;
|
|
14
|
+
this.emitOnInput = Boolean(field.emitOnInput ?? field.emitsOnInput);
|
|
15
|
+
this._disposers = [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
render() {
|
|
19
|
+
if (this.root) {
|
|
20
|
+
return this.root;
|
|
21
|
+
}
|
|
22
|
+
this.root = document.createElement('div');
|
|
23
|
+
this.root.className = 'twm-slider-field';
|
|
24
|
+
|
|
25
|
+
this.sliderInput = document.createElement('input');
|
|
26
|
+
this.sliderInput.type = 'range';
|
|
27
|
+
this.sliderInput.className = 'twm-slider-field__input';
|
|
28
|
+
this.sliderInput.min = this.#coerceNumber(this.field.min, 0);
|
|
29
|
+
this.sliderInput.max = this.#coerceNumber(this.field.max, 100);
|
|
30
|
+
this.sliderInput.step = this.#coercePositiveNumber(this.field.step, 1);
|
|
31
|
+
this.sliderInput.value = this.#coerceNumber(this.field.value, Number(this.sliderInput.min));
|
|
32
|
+
if (this.field.disabled) {
|
|
33
|
+
this.sliderInput.disabled = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Prevent parent interactions from interfering with slider dragging
|
|
37
|
+
// Use stopImmediatePropagation to ensure no other handlers on this element run
|
|
38
|
+
// DO NOT call preventDefault - allow native slider drag behavior
|
|
39
|
+
const stopDrag = (evt) => {
|
|
40
|
+
evt.stopPropagation();
|
|
41
|
+
evt.stopImmediatePropagation();
|
|
42
|
+
};
|
|
43
|
+
// Use capture phase to intercept before any parent handlers
|
|
44
|
+
this.sliderInput.addEventListener('mousedown', stopDrag, { capture: true });
|
|
45
|
+
this.sliderInput.addEventListener('touchstart', stopDrag, { passive: false, capture: true });
|
|
46
|
+
this._disposers.push(() => this.sliderInput.removeEventListener('mousedown', stopDrag, { capture: true }));
|
|
47
|
+
this._disposers.push(() => this.sliderInput.removeEventListener('touchstart', stopDrag, { passive: false, capture: true }));
|
|
48
|
+
|
|
49
|
+
this.helperEl = document.createElement('div');
|
|
50
|
+
this.helperEl.className = 'twm-slider-field__helper';
|
|
51
|
+
if (this.field.helperText) {
|
|
52
|
+
this.helperEl.textContent = this.field.helperText;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.root.appendChild(this.sliderInput);
|
|
56
|
+
this.root.appendChild(this.helperEl);
|
|
57
|
+
|
|
58
|
+
// Throttle onInput to animation frames to avoid re-render stutter while dragging
|
|
59
|
+
let rafToken = null;
|
|
60
|
+
let pendingValue = null;
|
|
61
|
+
const flushPending = () => {
|
|
62
|
+
if (rafToken) {
|
|
63
|
+
cancelAnimationFrame(rafToken);
|
|
64
|
+
rafToken = null;
|
|
65
|
+
}
|
|
66
|
+
if (pendingValue === null) return;
|
|
67
|
+
const valueToEmit = pendingValue;
|
|
68
|
+
pendingValue = null;
|
|
69
|
+
if (this.emitOnInput) {
|
|
70
|
+
this.onChange?.(valueToEmit, { immediate: true });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleInput = (event) => {
|
|
75
|
+
pendingValue = event.target.value;
|
|
76
|
+
if (!rafToken) {
|
|
77
|
+
rafToken = requestAnimationFrame(() => {
|
|
78
|
+
rafToken = null;
|
|
79
|
+
flushPending();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const handleChange = (event) => {
|
|
84
|
+
flushPending();
|
|
85
|
+
const value = event.target.value;
|
|
86
|
+
this.onChange?.(value, { immediate: false });
|
|
87
|
+
};
|
|
88
|
+
this.sliderInput.addEventListener('input', handleInput);
|
|
89
|
+
this.sliderInput.addEventListener('change', handleChange);
|
|
90
|
+
this._disposers.push(() => this.sliderInput.removeEventListener('input', handleInput));
|
|
91
|
+
this._disposers.push(() => this.sliderInput.removeEventListener('change', handleChange));
|
|
92
|
+
|
|
93
|
+
return this.root;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
dispose() {
|
|
97
|
+
this._disposers.forEach((dispose) => {
|
|
98
|
+
try {
|
|
99
|
+
dispose?.();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
this.logger?.warn?.('twm-slider-field', 'Failed to dispose slider listener', { error });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
this._disposers = [];
|
|
105
|
+
this.root = null;
|
|
106
|
+
this.sliderInput = null;
|
|
107
|
+
this.helperEl = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#coerceNumber(value, fallback) {
|
|
111
|
+
const numeric = Number(value);
|
|
112
|
+
return Number.isFinite(numeric) ? numeric : fallback;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#coercePositiveNumber(value, fallback) {
|
|
116
|
+
const numeric = this.#coerceNumber(value, fallback);
|
|
117
|
+
if (numeric <= 0) {
|
|
118
|
+
return fallback;
|
|
119
|
+
}
|
|
120
|
+
return numeric;
|
|
121
|
+
}
|
|
122
|
+
}
|