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,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
|
+
}
|