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,732 @@
1
+ /**
2
+ * Autocomplete Field Component
3
+ * Location: ui/components/autocomplete_field.js
4
+ *
5
+ * Purpose: Provide autocomplete dropdown for text input fields,
6
+ * specifically designed for variable reference inputs.
7
+ *
8
+ * Usage:
9
+ * const ac = new AutocompleteField({
10
+ * fieldId: 'my-input',
11
+ * field: { placeholder: '...', value: '...' },
12
+ * provider: ({ value }) => [{ label: 'foo', type: 'variable' }, ...],
13
+ * onChange: (value) => { ... }
14
+ * });
15
+ * container.appendChild(ac.render());
16
+ */
17
+
18
+ export class AutocompleteField {
19
+ constructor({
20
+ fieldId,
21
+ field = {},
22
+ provider = null,
23
+ onChange = null,
24
+ logger = null,
25
+ namespace = null,
26
+ namespaceOptions = [],
27
+ showNamespaceSelect = false,
28
+ variant = null,
29
+ showNamespaceChip = false,
30
+ allowScopeToggle = false,
31
+ namespaceLabel = null,
32
+ scopeMode = 'current',
33
+ skipNamespacePrefix = false,
34
+ // Material symbol per completion type, merged over the generic set. The
35
+ // framework's map used to include `sector: 'domain'` — an EcoAgent
36
+ // completion type — so the widget knew one application's ontology.
37
+ typeIcons = {},
38
+ } = {}) {
39
+ if (!fieldId) {
40
+ throw new Error('AutocompleteField requires a fieldId');
41
+ }
42
+ this.fieldId = fieldId;
43
+ this.field = field;
44
+ this.provider = typeof provider === 'function' ? provider : () => [];
45
+ this.onChange = typeof onChange === 'function' ? onChange : null;
46
+ this.logger = logger;
47
+ this.typeIcons = typeIcons ?? {};
48
+ this.namespace = namespace;
49
+ this.namespaceOptions = Array.isArray(namespaceOptions) ? [...namespaceOptions] : [];
50
+ // Ensure the active namespace is always present as an option, even if
51
+ // it was not provided (e.g., data manager not yet hydrated).
52
+ if (this.namespace && !this.namespaceOptions.some((opt) => opt.value === this.namespace)) {
53
+ const fallbackLabel = this.field.namespaceLabel || 'This Tab';
54
+ this.namespaceOptions.push({ value: this.namespace, label: fallbackLabel, token: fallbackLabel });
55
+ }
56
+ this.showNamespaceSelect = Boolean(showNamespaceSelect);
57
+ this.variant = variant;
58
+ this.showNamespaceChip = Boolean(showNamespaceChip);
59
+ this.allowScopeToggle = Boolean(allowScopeToggle);
60
+ this.namespaceLabel = namespaceLabel;
61
+ this.scope = scopeMode === 'all' ? 'all' : 'current';
62
+ this.skipNamespacePrefix = Boolean(skipNamespacePrefix);
63
+ this.disabled = Boolean(field.disabled);
64
+
65
+ const parsed = this.#parseInitialNamespace(field.value ?? '');
66
+ this.selectedNamespace = parsed.initialNamespace;
67
+ this.initialFragment = parsed.initialFragment;
68
+
69
+ this._lastDropdownRect = null;
70
+
71
+ this.root = null;
72
+ this.input = null;
73
+ this.dropdown = null;
74
+ this.helperEl = null;
75
+ this.scopeControl = null;
76
+ this.namespaceSelect = null;
77
+ this.ghost = null;
78
+
79
+ this._items = [];
80
+ this._activeIndex = -1;
81
+ this._isOpen = false;
82
+ this._disposers = [];
83
+ }
84
+
85
+ getValue() {
86
+ return this.#composeValue();
87
+ }
88
+
89
+ setComposedValue(value) {
90
+ if (!this.input) return;
91
+ const safeValue = typeof value === 'string' ? value : '';
92
+
93
+ // When namespace prefixing is disabled, store the raw value
94
+ if (this.skipNamespacePrefix) {
95
+ this.input.value = safeValue;
96
+ this.initialFragment = safeValue;
97
+ this.#renderGhost();
98
+ this.#close();
99
+ return;
100
+ }
101
+
102
+ const parsed = this.#parseInitialNamespace(safeValue);
103
+ this.selectedNamespace = parsed.initialNamespace;
104
+ this.initialFragment = parsed.initialFragment;
105
+
106
+ if (this.namespaceSelect) {
107
+ this.namespaceSelect.value = this.selectedNamespace;
108
+ }
109
+ this.#updateNamespaceChip();
110
+ this.input.value = parsed.initialFragment;
111
+ this.#renderGhost();
112
+ this.#close();
113
+ }
114
+
115
+ #parseInitialNamespace(rawValue) {
116
+ const value = typeof rawValue === 'string' ? rawValue.trim() : '';
117
+ const options = this.namespaceOptions;
118
+
119
+ // Default to provided namespace when available; otherwise fall back to first option or '__ALL__'.
120
+ const defaultNs = this.namespace
121
+ ? (options.find((opt) => opt.value === this.namespace)?.value ?? this.namespace)
122
+ : (options[0]?.value ?? '__ALL__');
123
+
124
+ if (!options.length) {
125
+ return { initialNamespace: defaultNs, initialFragment: value };
126
+ }
127
+ if (!value) {
128
+ return { initialNamespace: defaultNs, initialFragment: '' };
129
+ }
130
+
131
+ // Check for namespace prefix (by value OR label)
132
+ const dotIndex = value.indexOf('.');
133
+ if (dotIndex > 0) {
134
+ const nsCandidate = value.slice(0, dotIndex);
135
+ const match = options.find((opt) =>
136
+ (opt.value || '').toLowerCase() === nsCandidate.toLowerCase() ||
137
+ (opt.label || '').toLowerCase() === nsCandidate.toLowerCase()
138
+ );
139
+ if (match) {
140
+ return { initialNamespace: match.value, initialFragment: value.slice(dotIndex + 1) };
141
+ }
142
+ }
143
+ return { initialNamespace: defaultNs, initialFragment: value };
144
+ }
145
+
146
+ #resolveNamespaceLabel() {
147
+ if (this.scope === 'all') {
148
+ return 'All Namespaces';
149
+ }
150
+ if (typeof this.namespaceLabel === 'string' && this.namespaceLabel.trim()) {
151
+ return this.namespaceLabel.trim();
152
+ }
153
+ if (typeof this.field.namespaceLabel === 'string' && this.field.namespaceLabel.trim()) {
154
+ return this.field.namespaceLabel.trim();
155
+ }
156
+ if (typeof this.namespace === 'string' && this.namespace.trim()) {
157
+ return this.namespace.trim();
158
+ }
159
+ return 'This Tab';
160
+ }
161
+
162
+ #toggleScope() {
163
+ if (this.disabled) return;
164
+ this.scope = this.scope === 'all' ? 'current' : 'all';
165
+ this.root?.classList?.toggle('twm-autocomplete-field--scope-all', this.scope === 'all');
166
+ this.#updateNamespaceChip();
167
+ this.#updateSuggestions();
168
+ }
169
+
170
+ #updateNamespaceChip() {
171
+ if (!this.scopeControl) return;
172
+ this.scopeControl.textContent = this.#resolveNamespaceLabel();
173
+ this.scopeControl.dataset.scope = this.scope;
174
+ this.scopeControl.title = this.scope === 'all'
175
+ ? 'Searching across all namespaces (click to limit to this tab)'
176
+ : 'Searching this tab (click to search all namespaces)';
177
+ }
178
+
179
+ render() {
180
+ if (this.root) {
181
+ return this.root;
182
+ }
183
+
184
+ this.root = document.createElement('div');
185
+ this.root.className = 'twm-autocomplete-field';
186
+ if (this.variant) {
187
+ this.root.classList.add(`twm-autocomplete-field--${this.variant}`);
188
+ }
189
+ this.root.classList.toggle('twm-autocomplete-field--scope-all', this.scope === 'all');
190
+
191
+ const inputWrapper = document.createElement('div');
192
+ inputWrapper.className = 'twm-autocomplete-field__input-wrapper';
193
+
194
+ if (this.showNamespaceSelect && this.namespaceOptions.length) {
195
+ const select = document.createElement('select');
196
+ select.className = 'twm-autocomplete-field__namespace-select';
197
+ this.namespaceOptions.forEach((opt) => {
198
+ const option = document.createElement('option');
199
+ option.value = opt.value;
200
+ option.textContent = opt.label ?? opt.value;
201
+ if (opt.value === this.selectedNamespace) {
202
+ option.selected = true;
203
+ }
204
+ select.appendChild(option);
205
+ });
206
+ this.namespaceSelect = select;
207
+ inputWrapper.appendChild(select);
208
+ }
209
+
210
+ if (this.showNamespaceChip) {
211
+ const nsChip = document.createElement(this.allowScopeToggle ? 'button' : 'span');
212
+ nsChip.className = 'twm-autocomplete-field__namespace';
213
+ if (this.allowScopeToggle) {
214
+ nsChip.setAttribute('type', 'button');
215
+ }
216
+ if (this.allowScopeToggle) {
217
+ nsChip.classList.add('twm-autocomplete-field__namespace--toggle');
218
+ nsChip.addEventListener('click', () => this.#toggleScope());
219
+ }
220
+ this.scopeControl = nsChip;
221
+ this.#updateNamespaceChip();
222
+ inputWrapper.appendChild(nsChip);
223
+ }
224
+
225
+ this.input = document.createElement('input');
226
+ this.input.type = 'text';
227
+ this.input.className = 'twm-autocomplete-field__input';
228
+ this.input.id = this.fieldId;
229
+ this.input.autocomplete = 'off';
230
+ this.input.spellcheck = false;
231
+ if (this.field.placeholder) {
232
+ this.input.placeholder = this.field.placeholder;
233
+ }
234
+ if (this.field.value != null) {
235
+ this.input.value = this.initialFragment;
236
+ }
237
+ if (this.disabled) {
238
+ this.input.disabled = true;
239
+ this.root.classList.add('twm-autocomplete-field--disabled');
240
+ }
241
+ inputWrapper.appendChild(this.input);
242
+
243
+ // Ghost overlay disabled - namespace select provides visual context
244
+ this.ghost = null;
245
+
246
+ this.dropdown = document.createElement('div');
247
+ this.dropdown.className = 'twm-autocomplete-field__dropdown';
248
+ this.dropdown.setAttribute('role', 'listbox');
249
+ // Append dropdown to body to escape stacking context limitations
250
+ document.body.appendChild(this.dropdown);
251
+
252
+ this.helperEl = document.createElement('div');
253
+ this.helperEl.className = 'twm-autocomplete-field__helper twm-config-hint';
254
+ if (this.field.helperText) {
255
+ this.helperEl.textContent = this.field.helperText;
256
+ }
257
+
258
+ this.root.appendChild(inputWrapper);
259
+ this.root.appendChild(this.helperEl);
260
+
261
+ this.#wireEvents();
262
+ this.#renderGhost();
263
+ return this.root;
264
+ }
265
+
266
+ #wireEvents() {
267
+ const onInput = () => {
268
+ if (this.disabled) return;
269
+ this.#syncNamespaceFromInput();
270
+ this.#renderGhost();
271
+ this.#updateSuggestions();
272
+ };
273
+
274
+ const onChange = () => {
275
+ this.onChange?.(this.#composeValue(), { immediate: false, namespace: this.selectedNamespace });
276
+ };
277
+
278
+ const onPaste = () => {
279
+ // No special paste handling needed
280
+ };
281
+
282
+ const onKeyDown = (e) => {
283
+ if (this.disabled) return;
284
+ this.#handleKeyDown(e);
285
+ };
286
+
287
+ const onFocus = () => {
288
+ if (this.disabled) return;
289
+ this.#updateSuggestions();
290
+ };
291
+
292
+ const onBlur = (e) => {
293
+ // Delay close to allow click on dropdown items
294
+ setTimeout(() => {
295
+ if (!this.root?.contains(document.activeElement)) {
296
+ this.#close();
297
+ // Trigger save on blur (similar to change event)
298
+ this.onChange?.(this.#composeValue(), { immediate: false, namespace: this.selectedNamespace });
299
+ }
300
+ }, 150);
301
+ };
302
+
303
+ const onDropdownMouseDown = (e) => {
304
+ // Prevent blur when clicking dropdown
305
+ e.preventDefault();
306
+ };
307
+
308
+ const onDropdownClick = (e) => {
309
+ const item = e.target.closest('.twm-autocomplete-field__item');
310
+ if (item) {
311
+ const index = parseInt(item.dataset.index, 10);
312
+ if (!isNaN(index)) {
313
+ this.#selectItem(index);
314
+ }
315
+ }
316
+ };
317
+
318
+ this.input.addEventListener('input', onInput);
319
+ this.input.addEventListener('change', onChange);
320
+ this.input.addEventListener('paste', onPaste);
321
+ this.input.addEventListener('keydown', onKeyDown);
322
+ this.input.addEventListener('focus', onFocus);
323
+ this.input.addEventListener('blur', onBlur);
324
+ this.dropdown.addEventListener('mousedown', onDropdownMouseDown);
325
+ this.dropdown.addEventListener('click', onDropdownClick);
326
+
327
+ if (this.namespaceSelect) {
328
+ const onSelectChange = () => {
329
+ // Store the new namespace selection
330
+ this.selectedNamespace = this.namespaceSelect.value;
331
+ // Refresh suggestions and notify listener so selection can be persisted
332
+ this.#updateSuggestions();
333
+ this.onChange?.(this.#composeValue(), {
334
+ immediate: false,
335
+ namespace: this.selectedNamespace,
336
+ viewNamespace: this.selectedNamespace
337
+ });
338
+ };
339
+ this.namespaceSelect.addEventListener('change', onSelectChange);
340
+ this._disposers.push(() => this.namespaceSelect.removeEventListener('change', onSelectChange));
341
+ }
342
+
343
+ const onWindowResize = () => {
344
+ if (this._isOpen) {
345
+ this.#positionDropdown();
346
+ }
347
+ };
348
+ window.addEventListener('resize', onWindowResize);
349
+
350
+ // Close dropdown when user scrolls (dropdown is position: fixed so doesn't move with content)
351
+ const onScroll = (e) => {
352
+ if (!this._isOpen) return;
353
+ // Don't close if scrolling inside the dropdown itself
354
+ if (this.dropdown?.contains(e.target)) return;
355
+ this.#close();
356
+ };
357
+ document.addEventListener('scroll', onScroll, true); // capture phase catches all scroll events
358
+ this._disposers.push(() => {
359
+ document.removeEventListener('scroll', onScroll, true);
360
+ });
361
+
362
+ // Close dropdown when clicking outside (anywhere not in root or dropdown)
363
+ const onDocumentClick = (e) => {
364
+ if (!this._isOpen) return;
365
+ const clickedInRoot = this.root?.contains(e.target);
366
+ const clickedInDropdown = this.dropdown?.contains(e.target);
367
+ if (!clickedInRoot && !clickedInDropdown) {
368
+ this.#close();
369
+ }
370
+ };
371
+ document.addEventListener('click', onDocumentClick, true);
372
+
373
+ this._disposers.push(() => {
374
+ window.removeEventListener('resize', onWindowResize);
375
+ });
376
+ this._disposers.push(() => {
377
+ document.removeEventListener('click', onDocumentClick, true);
378
+ });
379
+
380
+ this._disposers.push(() => {
381
+ this.input.removeEventListener('input', onInput);
382
+ this.input.removeEventListener('change', onChange);
383
+ this.input.removeEventListener('paste', onPaste);
384
+ this.input.removeEventListener('keydown', onKeyDown);
385
+ this.input.removeEventListener('focus', onFocus);
386
+ this.input.removeEventListener('blur', onBlur);
387
+ this.dropdown.removeEventListener('mousedown', onDropdownMouseDown);
388
+ this.dropdown.removeEventListener('click', onDropdownClick);
389
+ });
390
+ }
391
+
392
+ #handleKeyDown(e) {
393
+ if (!this._isOpen) {
394
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
395
+ this.#updateSuggestions();
396
+ e.preventDefault();
397
+ }
398
+ return;
399
+ }
400
+
401
+ switch (e.key) {
402
+ case 'ArrowDown':
403
+ e.preventDefault();
404
+ this.#moveSelection(1);
405
+ break;
406
+ case 'ArrowUp':
407
+ e.preventDefault();
408
+ this.#moveSelection(-1);
409
+ break;
410
+ case 'Enter':
411
+ e.preventDefault();
412
+ if (this._activeIndex >= 0) {
413
+ this.#selectItem(this._activeIndex);
414
+ }
415
+ // Always blur on Enter to trigger save
416
+ this.input?.blur();
417
+ break;
418
+ case 'Tab':
419
+ if (this._activeIndex >= 0) {
420
+ e.preventDefault();
421
+ this.#selectItem(this._activeIndex);
422
+ }
423
+ break;
424
+ case 'Escape':
425
+ e.preventDefault();
426
+ this.#close();
427
+ break;
428
+ }
429
+ }
430
+
431
+ #updateSuggestions() {
432
+ // Pass the fragment (variable part) for filtering, not the full composed value
433
+ const fragment = this.#getInputFragment();
434
+ try {
435
+ const items = this.provider({
436
+ value: fragment,
437
+ scope: this.scope,
438
+ namespace: this.selectedNamespace
439
+ }) || [];
440
+ this._items = Array.isArray(items) ? items : [];
441
+ this._activeIndex = items.length > 0 ? 0 : -1;
442
+ this.#renderDropdown();
443
+ if (items.length > 0) {
444
+ this.#open();
445
+ } else {
446
+ this.#close();
447
+ }
448
+ } catch (err) {
449
+ this.logger?.warn?.('autocomplete', 'Provider error', { err });
450
+ this._items = [];
451
+ this.#close();
452
+ }
453
+ }
454
+
455
+ #renderDropdown() {
456
+ this.dropdown.innerHTML = '';
457
+ const showingAll = this.selectedNamespace === '__ALL__';
458
+
459
+ this._items.forEach((item, index) => {
460
+ const el = document.createElement('div');
461
+ el.className = 'twm-autocomplete-field__item';
462
+ el.setAttribute('role', 'option');
463
+ el.dataset.index = index;
464
+ el.dataset.type = item.type || item.iconType || 'variable';
465
+ if (index === this._activeIndex) {
466
+ el.classList.add('active');
467
+ }
468
+
469
+ // Icon based on type
470
+ const icon = document.createElement('span');
471
+ icon.className = 'twm-autocomplete-field__icon material-symbols-outlined';
472
+ icon.textContent = this.#getIconForType(item.type || item.iconType);
473
+ el.appendChild(icon);
474
+
475
+ // Label
476
+ const label = document.createElement('span');
477
+ label.className = 'twm-autocomplete-field__label';
478
+ label.textContent = item.label || item.insertText || '';
479
+ el.appendChild(label);
480
+
481
+ // Show namespace indicator when viewing all namespaces
482
+ if (showingAll && item.metadata?.namespaceId) {
483
+ const nsLabel = this.#resolveNamespaceToken(item.metadata.namespaceId);
484
+ if (nsLabel) {
485
+ const nsIndicator = document.createElement('span');
486
+ nsIndicator.className = 'twm-autocomplete-field__namespace-indicator';
487
+ nsIndicator.textContent = nsLabel;
488
+ el.appendChild(nsIndicator);
489
+ }
490
+ }
491
+
492
+ // Optional badge (type)
493
+ if (item.type && !showingAll) {
494
+ const badge = document.createElement('span');
495
+ badge.className = 'twm-autocomplete-field__badge';
496
+ badge.textContent = item.type;
497
+ el.appendChild(badge);
498
+ }
499
+
500
+ this.dropdown.appendChild(el);
501
+ });
502
+ if (this._isOpen) {
503
+ this.#positionDropdown();
504
+ }
505
+ }
506
+
507
+ #resolveNamespaceToken(namespaceId) {
508
+ if (!namespaceId) return null;
509
+ const opt = this.namespaceOptions.find((o) => o.value === namespaceId);
510
+ return opt?.token || opt?.label || null;
511
+ }
512
+
513
+ #getIconForType(type) {
514
+ const icons = {
515
+ variable: 'functions',
516
+ 'variable-qualified': 'link',
517
+ stock: 'account_balance',
518
+ account: 'savings',
519
+ function: 'code',
520
+ constant: 'numbers',
521
+ operator: 'calculate',
522
+ snippet: 'content_paste',
523
+ ...this.typeIcons,
524
+ };
525
+ return icons[type] || 'label';
526
+ }
527
+
528
+ #moveSelection(delta) {
529
+ if (this._items.length === 0) return;
530
+ let newIndex = this._activeIndex + delta;
531
+ if (newIndex < 0) newIndex = this._items.length - 1;
532
+ if (newIndex >= this._items.length) newIndex = 0;
533
+ this._activeIndex = newIndex;
534
+ this.#renderDropdown();
535
+ this.#scrollToActive();
536
+ }
537
+
538
+ #scrollToActive() {
539
+ const activeEl = this.dropdown.querySelector('.twm-autocomplete-field__item.active');
540
+ if (activeEl) {
541
+ activeEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
542
+ }
543
+ }
544
+
545
+ #selectItem(index) {
546
+ const item = this._items[index];
547
+ if (!item) return;
548
+
549
+ // Extract namespace from the selected item's metadata
550
+ const itemNamespace = item.metadata?.namespaceId || null;
551
+
552
+ // Put just the variable name in the input (not qualified)
553
+ const insertText = item.insertText || item.label || '';
554
+ this.input.value = insertText;
555
+
556
+ // When "All" is selected, do NOT change the dropdown - keep it at "All"
557
+ // Instead, pass the item's namespace via the callback so it's saved correctly
558
+ // This allows users to stay in "All" view while browsing/selecting variables
559
+ const viewNamespace = this.selectedNamespace; // What the dropdown shows ("__ALL__" or specific)
560
+ const resolvedNamespace = (viewNamespace === '__ALL__' && itemNamespace)
561
+ ? itemNamespace
562
+ : viewNamespace;
563
+
564
+ this.#close();
565
+ this.input.focus();
566
+ // Emit composed value with both namespace values and item metadata
567
+ this.onChange?.(this.#composeValue(), {
568
+ immediate: true,
569
+ namespace: resolvedNamespace,
570
+ viewNamespace: viewNamespace,
571
+ metadata: item.metadata || null,
572
+ });
573
+ }
574
+
575
+ #open() {
576
+ if (this._isOpen) {
577
+ return;
578
+ }
579
+ this._isOpen = true;
580
+ this.#positionDropdown();
581
+ this.dropdown.classList.add('visible');
582
+ }
583
+
584
+ #close() {
585
+ if (!this._isOpen) return;
586
+ this._isOpen = false;
587
+ this.dropdown.classList.remove('visible');
588
+ this._activeIndex = -1;
589
+ }
590
+
591
+ #syncNamespaceFromInput() {
592
+ // Auto-switch namespace dropdown when user types "Namespace." prefix
593
+ // But do NOT modify the input value - keep what user typed
594
+ // Also do NOT switch away from "__ALL__" unless user explicitly types a namespace prefix
595
+ if (!this.namespaceOptions.length) return;
596
+ const value = this.input?.value || '';
597
+ const dotIndex = value.indexOf('.');
598
+ if (dotIndex > 0) {
599
+ const nsCandidate = value.slice(0, dotIndex);
600
+ // Match by value OR display label
601
+ const match = this.namespaceOptions.find((opt) =>
602
+ opt.value !== '__ALL__' && (
603
+ (opt.value || '').toLowerCase() === nsCandidate.toLowerCase() ||
604
+ (opt.label || '').toLowerCase() === nsCandidate.toLowerCase() ||
605
+ (opt.token || '').toLowerCase() === nsCandidate.toLowerCase()
606
+ )
607
+ );
608
+ if (match && this.namespaceSelect && this.namespaceSelect.value !== match.value) {
609
+ this.namespaceSelect.value = match.value;
610
+ this.selectedNamespace = match.value;
611
+ }
612
+ }
613
+ // Note: We no longer auto-reset to a namespace when typing without a dot
614
+ // This allows "All" selection to remain stable
615
+ }
616
+
617
+ #getInputFragment() {
618
+ // Get the variable name fragment from input (strip namespace prefix if present)
619
+ const value = this.input?.value || '';
620
+ const dotIndex = value.indexOf('.');
621
+ if (dotIndex > 0) {
622
+ const nsCandidate = value.slice(0, dotIndex);
623
+ const match = this.namespaceOptions.find((opt) =>
624
+ opt.value !== '__ALL__' && (
625
+ (opt.value || '').toLowerCase() === nsCandidate.toLowerCase() ||
626
+ (opt.label || '').toLowerCase() === nsCandidate.toLowerCase()
627
+ )
628
+ );
629
+ if (match) {
630
+ return value.slice(dotIndex + 1);
631
+ }
632
+ }
633
+ return value;
634
+ }
635
+
636
+ #composeValue() {
637
+ // Compose the full reference value for saving
638
+ // Use the raw input value - it may already contain namespace prefix
639
+ const rawInput = this.input?.value || '';
640
+ if (!rawInput) return '';
641
+
642
+ // Skip namespace prefixing for simple value fields (e.g., sector, account name)
643
+ if (this.skipNamespacePrefix) {
644
+ return rawInput;
645
+ }
646
+
647
+ // If input already has a namespace prefix, return as-is
648
+ const dotIndex = rawInput.indexOf('.');
649
+ if (dotIndex > 0) {
650
+ const nsCandidate = rawInput.slice(0, dotIndex);
651
+ const match = this.namespaceOptions.find((opt) =>
652
+ opt.value !== '__ALL__' && (
653
+ (opt.value || '').toLowerCase() === nsCandidate.toLowerCase() ||
654
+ (opt.label || '').toLowerCase() === nsCandidate.toLowerCase()
655
+ )
656
+ );
657
+ if (match) {
658
+ // Input already has valid namespace prefix, return as-is
659
+ return rawInput;
660
+ }
661
+ }
662
+
663
+ // No namespace prefix in input - prepend from dropdown if not "All"
664
+ const ns = this.selectedNamespace;
665
+ if (!ns || ns === '__ALL__') {
666
+ return rawInput;
667
+ }
668
+
669
+ // Find display label for the namespace (case-insensitive search)
670
+ const nsOption = this.namespaceOptions.find((opt) =>
671
+ opt.value === ns || (opt.value || '').toLowerCase() === (ns || '').toLowerCase()
672
+ );
673
+
674
+ // Choose a human-friendly token; fall back to label. If we only have a UUID-looking
675
+ // token, avoid prefixing to prevent UUIDs leaking into the input.
676
+ const nsToken = nsOption?.token || nsOption?.label || '';
677
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
678
+ if (ns === '__ALL__' || (nsToken || '').toUpperCase() === '__ALL__') {
679
+ return rawInput;
680
+ }
681
+ if (!nsToken || uuidPattern.test(nsToken)) {
682
+ return rawInput;
683
+ }
684
+
685
+ return `${nsToken}.${rawInput}`;
686
+ }
687
+
688
+ #renderGhost() {
689
+ // Ghost overlay is disabled - it interferes with the input
690
+ // The namespace select already provides visual context
691
+ if (this.ghost) {
692
+ this.ghost.style.display = 'none';
693
+ }
694
+ }
695
+
696
+ #positionDropdown() {
697
+ if (!this.dropdown || !this.root) return;
698
+ const wrapper = this.root.querySelector('.twm-autocomplete-field__input-wrapper');
699
+ if (!wrapper) return;
700
+ const rect = wrapper.getBoundingClientRect();
701
+ this._lastDropdownRect = rect;
702
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
703
+ const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
704
+ const width = Math.min(rect.width, viewportWidth - rect.left - 8);
705
+ const maxHeight = Math.max(120, viewportHeight - rect.bottom - 12);
706
+ Object.assign(this.dropdown.style, {
707
+ position: 'fixed',
708
+ top: `${rect.bottom}px`,
709
+ left: `${rect.left}px`,
710
+ width: `${width}px`,
711
+ maxWidth: `${width}px`,
712
+ maxHeight: `${maxHeight}px`,
713
+ });
714
+ }
715
+
716
+ dispose() {
717
+ // Close dropdown before cleanup
718
+ this.#close();
719
+ this._disposers.forEach((fn) => {
720
+ try { fn(); } catch (e) { /* ignore */ }
721
+ });
722
+ this._disposers = [];
723
+ // Remove dropdown from body since we appended it there
724
+ if (this.dropdown && this.dropdown.parentNode) {
725
+ this.dropdown.parentNode.removeChild(this.dropdown);
726
+ }
727
+ this.root = null;
728
+ this.input = null;
729
+ this.dropdown = null;
730
+ this.helperEl = null;
731
+ }
732
+ }