flexdesk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,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
+ }