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,512 @@
1
+ /**
2
+ * Modal helpers — prompt with form, yes/no confirm.
3
+ *
4
+ * Built on the canonical `ManagedWindow` (with `modal: true`) so dialogs
5
+ * go through the same window system as everything else: same chrome,
6
+ * same backdrop, same Esc-to-close behaviour, same z-stacking.
7
+ *
8
+ * Returns Promises that resolve with the form data (or null on cancel)
9
+ * / a boolean (confirm).
10
+ *
11
+ * Field schema:
12
+ * { name, label, type='text', default, step?, options?, required?,
13
+ * placeholder?, hint?, validator?, rows?, create? }
14
+ *
15
+ * - type: 'text' | 'number' | 'password' | 'select' | 'textarea' |
16
+ * 'checkbox'
17
+ * - hint: small dim help line rendered under the field
18
+ * - validator: fn(value, allValues) => string | null (inline error)
19
+ * - rows: number of textarea rows (only used for type='textarea')
20
+ *
21
+ * Section dividers — entries of the form `{ section: 'Label' }` are
22
+ * rendered as a small uppercase heading that groups the fields below
23
+ * them. Useful for longer forms.
24
+ *
25
+ * Dependent-property pick-or-create — a `select` field that carries a
26
+ * `create` spec renders as a **type-ahead combobox** instead of a
27
+ * dropdown: the user picks an existing option OR types a new value.
28
+ * On submit, a value that isn't an existing option is created via
29
+ * `create.onCreate(value)` before the form resolves:
30
+ *
31
+ * {
32
+ * name: 'asset_kind', label: 'Asset kind', type: 'select',
33
+ * options: [...], // existing values (autocomplete)
34
+ * create: {
35
+ * onCreate: async (typed) => finalValue | null, // make the entity
36
+ * hint: 'new asset kind', // shown when the typed value is new
37
+ * },
38
+ * }
39
+ */
40
+
41
+ import { ManagedWindow } from '../../ui/components/managed_window.js';
42
+
43
+ let _modalSeq = 1;
44
+
45
+ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK' } = {}) {
46
+ return new Promise((resolve) => {
47
+ // Build the form body — same markup as the legacy overlay,
48
+ // minus the outer .ea-modal wrapper (ManagedWindow owns the
49
+ // chrome now).
50
+ const body = document.createElement('div');
51
+ body.className = 'twm-modal__body-host';
52
+ body.innerHTML = `
53
+ <form class="twm-modal__form">
54
+ ${fields.map((f) => _renderEntry(f, defaults[f.name] ?? f.default)).join('')}
55
+ <div class="twm-modal__error" data-role="form-error" hidden></div>
56
+ <div class="twm-modal__actions">
57
+ <button type="button" class="twm-btn" data-action="cancel">Cancel</button>
58
+ <button type="submit" class="twm-btn twm-btn--primary">${submitLabel}</button>
59
+ </div>
60
+ </form>
61
+ `;
62
+
63
+ const win = new ManagedWindow({
64
+ id: `twm-modal-${_modalSeq++}`,
65
+ title: title || 'Dialog',
66
+ icon: 'edit_note',
67
+ content: body,
68
+ modal: true,
69
+ canMinimize: false,
70
+ canMaximize: false,
71
+ canResize: false,
72
+ canDrag: true,
73
+ defaultWidth: _estimateWidth(fields),
74
+ defaultHeight: _estimateHeight(fields),
75
+ onClose: () => { if (!_resolved) { _resolved = true; resolve(null); } },
76
+ });
77
+ win.show();
78
+
79
+ let _resolved = false;
80
+ const close = (result) => {
81
+ if (_resolved) return;
82
+ _resolved = true;
83
+ resolve(result);
84
+ try { win.close({ force: true }); } catch {}
85
+ };
86
+
87
+ const errEl = body.querySelector('[data-role="form-error"]');
88
+ const showError = (msg) => {
89
+ if (!errEl) return;
90
+ errEl.textContent = msg;
91
+ errEl.hidden = !msg;
92
+ };
93
+ body.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(null));
94
+
95
+ const formFields = fields.filter((f) => !f.section);
96
+ for (const f of formFields) {
97
+ if (f.type === 'select' && f.create) _wireComboboxHint(body, f);
98
+ }
99
+
100
+ const clearFieldError = (name) => {
101
+ const row = body.querySelector(`[data-field-row="${name}"]`);
102
+ const errEl = body.querySelector(`[data-field-error="${name}"]`);
103
+ if (row) row.classList.remove('twm-is-invalid');
104
+ if (errEl) { errEl.textContent = ''; errEl.hidden = true; }
105
+ };
106
+ const setFieldError = (name, msg) => {
107
+ const row = body.querySelector(`[data-field-row="${name}"]`);
108
+ const errEl = body.querySelector(`[data-field-error="${name}"]`);
109
+ if (row) row.classList.add('twm-is-invalid');
110
+ if (errEl) { errEl.textContent = msg; errEl.hidden = false; }
111
+ };
112
+ // Clear per-field errors on input so the user sees the row recover.
113
+ for (const f of formFields) {
114
+ const el = body.querySelector(`[name="${f.name}"]`);
115
+ el?.addEventListener('input', () => clearFieldError(f.name));
116
+ el?.addEventListener('change', () => clearFieldError(f.name));
117
+ }
118
+
119
+ body.querySelector('form').addEventListener('submit', async (e) => {
120
+ e.preventDefault();
121
+ const submitBtn = body.querySelector('button[type="submit"]');
122
+ showError('');
123
+ const data = {};
124
+ for (const f of formFields) {
125
+ const el = body.querySelector(`[name="${f.name}"]`);
126
+ if (!el) continue;
127
+ let v;
128
+ if (f.type === 'checkbox') {
129
+ v = !!el.checked;
130
+ } else if (f.type === 'number') {
131
+ v = (el.value === '' ? null : Number(el.value));
132
+ } else {
133
+ v = el.value;
134
+ }
135
+ if (f.type === 'select' && f.create) {
136
+ const known = new Set(_optionValues(f.options));
137
+ if (v && !known.has(v)) {
138
+ if (submitBtn) submitBtn.disabled = true;
139
+ let created;
140
+ let failMsg = null;
141
+ try { created = await f.create.onCreate(v); }
142
+ catch (err) { created = null; failMsg = err?.message || String(err); }
143
+ if (submitBtn) submitBtn.disabled = false;
144
+ if (created == null) {
145
+ const hint = (f.create && f.create.hint) || f.label.toLowerCase();
146
+ showError(failMsg
147
+ ? `Couldn't create ${hint} "${v}": ${failMsg}`
148
+ : `Couldn't create ${hint} "${v}". See the toast for details.`);
149
+ return;
150
+ }
151
+ v = created;
152
+ }
153
+ }
154
+ data[f.name] = v;
155
+ }
156
+ // Per-field validators run after collection so they can see
157
+ // peer values (e.g. confirm-password matches password).
158
+ let firstBadName = null;
159
+ for (const f of formFields) {
160
+ clearFieldError(f.name);
161
+ if (typeof f.validator !== 'function') continue;
162
+ let msg = null;
163
+ try { msg = f.validator(data[f.name], data); } catch (err) { msg = err?.message || String(err); }
164
+ if (msg) {
165
+ setFieldError(f.name, msg);
166
+ if (!firstBadName) firstBadName = f.name;
167
+ }
168
+ }
169
+ if (firstBadName) {
170
+ body.querySelector(`[name="${firstBadName}"]`)?.focus();
171
+ return;
172
+ }
173
+ close(data);
174
+ });
175
+
176
+ // Focus first field on next frame so the managed-window mount
177
+ // settles before we steal focus.
178
+ requestAnimationFrame(() => {
179
+ body.querySelector('input, select, textarea')?.focus();
180
+ });
181
+ });
182
+ }
183
+
184
+
185
+ export function openConfirm({
186
+ title, message, confirmLabel = 'OK',
187
+ cancelLabel = 'Cancel',
188
+ danger = false, icon = null,
189
+ } = {}) {
190
+ return new Promise((resolve) => {
191
+ const body = document.createElement('div');
192
+ body.className = 'twm-modal__body-host';
193
+ const cls = danger ? 'twm-btn twm-btn--danger' : 'twm-btn twm-btn--primary';
194
+ body.innerHTML = `
195
+ <div class="twm-modal__body">${message}</div>
196
+ <div class="twm-modal__actions">
197
+ <button type="button" class="twm-btn" data-action="cancel">${cancelLabel}</button>
198
+ <button type="button" class="${cls}" data-action="confirm">${confirmLabel}</button>
199
+ </div>
200
+ `;
201
+
202
+ let _resolved = false;
203
+ const win = new ManagedWindow({
204
+ id: `twm-confirm-${_modalSeq++}`,
205
+ title: title || 'Confirm',
206
+ icon: icon || (danger ? 'warning' : 'help'),
207
+ content: body,
208
+ modal: true,
209
+ canMinimize: false,
210
+ canMaximize: false,
211
+ canResize: false,
212
+ canDrag: true,
213
+ defaultWidth: 380,
214
+ defaultHeight: 180,
215
+ onClose: () => { if (!_resolved) { _resolved = true; resolve(false); } },
216
+ });
217
+ win.show();
218
+ const close = (v) => {
219
+ if (_resolved) return;
220
+ _resolved = true;
221
+ resolve(v);
222
+ try { win.close({ force: true }); } catch {}
223
+ };
224
+ body.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
225
+ body.querySelector('[data-action="confirm"]').addEventListener('click', () => close(true));
226
+ requestAnimationFrame(() => {
227
+ body.querySelector(`[data-action="${danger ? 'cancel' : 'confirm'}"]`)?.focus();
228
+ });
229
+ });
230
+ }
231
+
232
+
233
+ /**
234
+ * `openModal` — rich-content modal. The third helper alongside
235
+ * `openForm` (field-based) and `openConfirm` (yes/no).
236
+ *
237
+ * Use when the modal body is arbitrary DOM the caller renders itself
238
+ * — a chart, a Monaco editor, a table, a diff view. The shell still
239
+ * goes through ManagedWindow so the chrome, focus trap, Esc handling,
240
+ * and z-stacking match every other modal in the app.
241
+ *
242
+ * Schema:
243
+ * - `title` window title (string)
244
+ * - `icon` optional material-symbols-outlined glyph for the
245
+ * title bar; defaults to `info`
246
+ * - `content` HTMLElement appended into the body. Caller owns
247
+ * rendering — `openModal` doesn't size or style the
248
+ * internal content beyond making it scrollable.
249
+ * - `actions` `[{ label, value, primary?, danger? }, …]`. If
250
+ * omitted, defaults to a single `[Close]` action that
251
+ * resolves with `null`. The first action with
252
+ * `primary: true` is the rightmost; otherwise the
253
+ * last action wins. Esc / X / backdrop resolve with
254
+ * `null` regardless of what's in `actions`.
255
+ * - `width`,
256
+ * `height` initial size in px. Defaults: 640 × 480.
257
+ * - `onMount` optional `(contentEl) => void` invoked once after
258
+ * the content is appended — handy when the caller
259
+ * needs the bounding rect to size a chart / Monaco /
260
+ * SVG inside the body.
261
+ * - `backdropBlur` backdrop blur radius in px (`0` disables it).
262
+ * - `backdropOpacity` backdrop dim, 0 (clear) … 1 (opaque). Use a low
263
+ * value (with `backdropBlur: 0`) to keep the background
264
+ * legible — e.g. a live-applied editor where you want to
265
+ * watch the content behind update. Omit both to inherit
266
+ * the default look (blur 2px over a 50% dim).
267
+ *
268
+ * Returns a Promise resolving with the chosen action's `value`, or
269
+ * `null` if the user dismissed the modal.
270
+ */
271
+ export function openModal({
272
+ title = 'Dialog',
273
+ icon = 'info',
274
+ content = null,
275
+ actions = null,
276
+ width = 640,
277
+ height = 480,
278
+ onMount = null,
279
+ backdropBlur = undefined,
280
+ backdropOpacity = undefined,
281
+ } = {}) {
282
+ return new Promise((resolve) => {
283
+ const body = document.createElement('div');
284
+ body.className = 'twm-modal__body-host';
285
+
286
+ const bodyInner = document.createElement('div');
287
+ bodyInner.className = 'twm-modal__body twm-modal__body--rich';
288
+ if (content instanceof HTMLElement) bodyInner.appendChild(content);
289
+ body.appendChild(bodyInner);
290
+
291
+ // Actions row — default = single Close button. Esc / backdrop /
292
+ // X all bypass this and resolve with null.
293
+ const acts = Array.isArray(actions) && actions.length > 0
294
+ ? actions
295
+ : [{ label: 'Close', value: null }];
296
+ const actionsEl = document.createElement('div');
297
+ actionsEl.className = 'twm-modal__actions';
298
+ acts.forEach((a, i) => {
299
+ const btn = document.createElement('button');
300
+ btn.type = 'button';
301
+ let cls = 'twm-btn';
302
+ if (a.primary) cls += ' twm-btn--primary';
303
+ if (a.danger) cls += ' twm-btn--danger';
304
+ btn.className = cls;
305
+ btn.textContent = String(a.label || '');
306
+ btn.dataset.actionIdx = String(i);
307
+ actionsEl.appendChild(btn);
308
+ });
309
+ body.appendChild(actionsEl);
310
+
311
+ let _resolved = false;
312
+ const win = new ManagedWindow({
313
+ id: `twm-modal-${_modalSeq++}`,
314
+ title,
315
+ icon,
316
+ content: body,
317
+ modal: true,
318
+ backdropBlur,
319
+ backdropOpacity,
320
+ canMinimize: false,
321
+ canMaximize: false,
322
+ canResize: true,
323
+ canDrag: true,
324
+ defaultWidth: width,
325
+ defaultHeight: height,
326
+ onClose: () => { if (!_resolved) { _resolved = true; resolve(null); } },
327
+ });
328
+ win.show();
329
+
330
+ const close = (value) => {
331
+ if (_resolved) return;
332
+ _resolved = true;
333
+ resolve(value);
334
+ try { win.close({ force: true }); } catch {}
335
+ };
336
+ actionsEl.addEventListener('click', (e) => {
337
+ const btn = e.target.closest('[data-action-idx]');
338
+ if (!btn) return;
339
+ const idx = Number(btn.dataset.actionIdx);
340
+ const a = acts[idx];
341
+ if (!a) return;
342
+ // `keepOpen` actions are side-effects (Copy, Apply
343
+ // Without Close, etc.): if the action's `onClick` returns
344
+ // anything (or just runs), the modal stays open. Use this
345
+ // for Copy buttons and the like.
346
+ if (a.keepOpen) {
347
+ // `onClick` receives `close` so the handler can
348
+ // validate, do an async side-effect, and only then
349
+ // dismiss the modal with whatever value it chooses.
350
+ // Returning early without calling `close` leaves the
351
+ // modal open (which is the whole point of keepOpen).
352
+ try { a.onClick?.(close); } catch (err) { console.warn(err); }
353
+ return;
354
+ }
355
+ close(a.value);
356
+ });
357
+
358
+ // onMount runs after the next frame so layout has settled and
359
+ // the inner content has a real bounding box for sizing.
360
+ if (typeof onMount === 'function') {
361
+ requestAnimationFrame(() => {
362
+ try { onMount(bodyInner); } catch (err) { console.warn(err); }
363
+ });
364
+ }
365
+ // Focus the primary button (or the last action) so Enter
366
+ // resolves the most-likely intended outcome.
367
+ requestAnimationFrame(() => {
368
+ const idx = acts.findIndex((a) => a.primary);
369
+ const which = idx >= 0 ? idx : acts.length - 1;
370
+ actionsEl.querySelector(`[data-action-idx="${which}"]`)?.focus();
371
+ });
372
+ });
373
+ }
374
+
375
+
376
+ // ─── helpers ────────────────────────────────────────────────────────
377
+
378
+ function _estimateHeight(fields) {
379
+ // Header (~36) + actions row (~46) + section dividers (~32 each)
380
+ // + per-field rows (~46 text, ~140 textarea, ~32 checkbox)
381
+ // + body padding. Clamped to 200..80vh.
382
+ let body = 0;
383
+ for (const f of fields) {
384
+ if (f.section) body += 32;
385
+ else if (f.type === 'textarea') {
386
+ const rows = Number(f.rows) || 4;
387
+ body += 32 + rows * 18;
388
+ if (f.hint) body += 16;
389
+ }
390
+ else if (f.type === 'checkbox') body += 32;
391
+ else { body += 46; if (f.hint) body += 16; }
392
+ }
393
+ const base = 36 + 46 + 28 + body;
394
+ const max = Math.floor(window.innerHeight * 0.8);
395
+ return Math.max(200, Math.min(base, max));
396
+ }
397
+
398
+ function _estimateWidth(fields) {
399
+ // Forms with a textarea or section divider get the wider layout so
400
+ // multi-line content + grouped fields breathe; otherwise 480 px is
401
+ // enough for label + single-line input pairs.
402
+ const wide = fields.some((f) => f && (f.type === 'textarea' || f.section));
403
+ return wide ? 640 : 480;
404
+ }
405
+
406
+ function _optionValues(options) {
407
+ return (options || []).map((o) => (typeof o === 'object') ? o.value : o);
408
+ }
409
+
410
+
411
+ /** Show / hide the "↳ will create …" hint as the user types into a
412
+ * create-capable combobox. */
413
+ function _wireComboboxHint(host, f) {
414
+ const input = host.querySelector(`input[name="${f.name}"]`);
415
+ const hintEl = host.querySelector(`.twm-combobox__hint[data-for="${f.name}"]`);
416
+ if (!input || !hintEl) return;
417
+ const known = new Set(_optionValues(f.options));
418
+ const label = (f.create && f.create.hint) || 'new entry';
419
+ const update = () => {
420
+ const v = input.value.trim();
421
+ if (v && !known.has(v)) {
422
+ hintEl.textContent = `↳ will create ${label} “${v}”`;
423
+ hintEl.hidden = false;
424
+ } else {
425
+ hintEl.hidden = true;
426
+ }
427
+ };
428
+ input.addEventListener('input', update);
429
+ update();
430
+ }
431
+
432
+
433
+ function _renderEntry(f, value) {
434
+ if (f && f.section != null) {
435
+ return `<div class="twm-modal__section">${_esc(f.section)}</div>`;
436
+ }
437
+ return _renderField(f, value);
438
+ }
439
+
440
+ function _renderField(f, value) {
441
+ const required = f.required ? 'required' : '';
442
+ const rowMod = (f.type === 'textarea') ? ' twm-modal__row--multiline' : '';
443
+ const hintHtml = f.hint
444
+ ? `<div class="twm-modal__hint--field">${_esc(f.hint)}</div>` : '';
445
+ const errHtml = `<div class="twm-modal__field-error" data-field-error="${f.name}" hidden></div>`;
446
+ const wrap = (inner) => `
447
+ <label class="twm-modal__row${rowMod}" data-field-row="${f.name}">
448
+ <span>${_esc(f.label || '')}</span>
449
+ ${inner}
450
+ ${hintHtml}
451
+ ${errHtml}
452
+ </label>
453
+ `;
454
+
455
+ if (f.type === 'checkbox') {
456
+ const checked = (value === true || value === 'true') ? 'checked' : '';
457
+ return `
458
+ <label class="twm-modal__row" data-field-row="${f.name}">
459
+ <span></span>
460
+ <span class="twm-modal__check">
461
+ <input name="${f.name}" type="checkbox" ${checked}>
462
+ <span>${_esc(f.label || '')}</span>
463
+ </span>
464
+ ${hintHtml}
465
+ ${errHtml}
466
+ </label>
467
+ `;
468
+ }
469
+
470
+ if (f.type === 'textarea') {
471
+ const v = value == null ? '' : String(value);
472
+ const rows = f.rows != null ? `rows="${Number(f.rows) || 4}"` : 'rows="4"';
473
+ const placeholder = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : '';
474
+ return wrap(`<textarea name="${f.name}" ${rows} ${placeholder} ${required}>${_esc(v)}</textarea>`);
475
+ }
476
+
477
+ const v = value == null ? '' : String(value);
478
+ if (f.type === 'select' && f.create) {
479
+ const dlId = `ea-dl-${f.name}-${_modalSeq}`;
480
+ const opts = _optionValues(f.options)
481
+ .map((ov) => `<option value="${_esc(ov)}"></option>`).join('');
482
+ const placeholder = f.placeholder
483
+ ? `placeholder="${_esc(f.placeholder)}"` : 'placeholder="type to pick or create…"';
484
+ return wrap(`
485
+ <div class="twm-combobox">
486
+ <input name="${f.name}" type="text" list="${dlId}"
487
+ value="${_esc(v)}" ${placeholder} ${required}
488
+ autocomplete="off">
489
+ <datalist id="${dlId}">${opts}</datalist>
490
+ <span class="twm-combobox__hint" data-for="${f.name}" hidden></span>
491
+ </div>
492
+ `);
493
+ }
494
+ if (f.type === 'select') {
495
+ const opts = (f.options || []).map((o) => {
496
+ const ov = (typeof o === 'object') ? o.value : o;
497
+ const ol = (typeof o === 'object') ? o.label : o;
498
+ return `<option value="${_esc(ov)}" ${ov === v ? 'selected' : ''}>${_esc(ol)}</option>`;
499
+ }).join('');
500
+ return wrap(`<select name="${f.name}" ${required}>${opts}</select>`);
501
+ }
502
+ const step = f.step != null ? `step="${f.step}"` : '';
503
+ const placeholder = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : '';
504
+ const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text';
505
+ return wrap(`<input name="${f.name}" type="${type}" value="${_esc(v)}" ${step} ${placeholder} ${required}>`);
506
+ }
507
+
508
+ function _esc(s) {
509
+ return String(s == null ? '' : s)
510
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
511
+ .replace(/"/g, '&quot;');
512
+ }