flexdesk 0.17.18 → 0.18.1

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 (126) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7547 -5891
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +30 -1
  6. package/dist/charts.js +83 -13
  7. package/dist/charts.js.map +2 -2
  8. package/dist/chunk-3BLIVOFD.js +778 -0
  9. package/dist/chunk-3BLIVOFD.js.map +7 -0
  10. package/dist/{chunk-7OL6F2AM.js → chunk-4X4SY3CP.js} +7 -5
  11. package/dist/chunk-4X4SY3CP.js.map +7 -0
  12. package/dist/chunk-DNJ3N4T4.js +82 -0
  13. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  14. package/dist/{chunk-PGZ4EHSE.js → chunk-HL3IIHU3.js} +501 -359
  15. package/dist/chunk-HL3IIHU3.js.map +7 -0
  16. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  17. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  18. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  19. package/dist/{chunk-XJNADRGR.js → chunk-NFZJ6P42.js} +188 -96
  20. package/dist/chunk-NFZJ6P42.js.map +7 -0
  21. package/dist/chunk-PAI2ZUWV.js +260 -0
  22. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  23. package/dist/chunk-Q7BALEPX.js +527 -0
  24. package/dist/chunk-Q7BALEPX.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-QRBOIUU3.js} +7 -8
  26. package/dist/chunk-QRBOIUU3.js.map +7 -0
  27. package/dist/chunk-QXAOTGCC.js +40 -0
  28. package/dist/chunk-QXAOTGCC.js.map +7 -0
  29. package/dist/chunk-UKP22RUG.js +219 -0
  30. package/dist/chunk-UKP22RUG.js.map +7 -0
  31. package/dist/chunk-VLOAI3HS.js +43 -0
  32. package/dist/chunk-VLOAI3HS.js.map +7 -0
  33. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  34. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7547 -5891
  38. package/dist/flow.js +29 -97
  39. package/dist/flow.js.map +3 -3
  40. package/dist/git.js +1356 -0
  41. package/dist/git.js.map +7 -0
  42. package/dist/reset.css +1 -1
  43. package/dist/tiles.js +20 -15
  44. package/dist/tiles.js.map +2 -2
  45. package/dist/tokens.css +30 -1
  46. package/dist/widgets.js +4146 -1199
  47. package/dist/widgets.js.map +4 -4
  48. package/dist/wm.js +120 -32
  49. package/dist/wm.js.map +2 -2
  50. package/docs/README.md +9 -4
  51. package/docs/best-practices.md +2 -2
  52. package/docs/components/charts.md +40 -0
  53. package/docs/components/chat-shell.md +204 -0
  54. package/docs/components/conversation.md +10 -0
  55. package/docs/components/filter-builder.md +292 -0
  56. package/docs/components/forms-and-dialogs.md +30 -0
  57. package/docs/components/git.md +355 -0
  58. package/docs/components/input-fields.md +191 -2
  59. package/docs/components/keymap.md +8 -0
  60. package/docs/components/menus.md +86 -2
  61. package/docs/components/panels-and-desktops.md +16 -5
  62. package/docs/components/shell.md +108 -1
  63. package/docs/components/workspace-picker.md +193 -0
  64. package/docs/concepts/building-and-packaging.md +2 -2
  65. package/docs/concepts/packages-and-entry-points.md +14 -13
  66. package/docs/concepts/state-guard.md +3 -3
  67. package/docs/quickstart.md +1 -1
  68. package/git.js +13 -0
  69. package/package.json +6 -1
  70. package/src/charts/plot_config_editor.js +92 -6
  71. package/src/charts/plot_grid.js +6 -1
  72. package/src/flow/kit/dom.js +28 -119
  73. package/src/flow/kit/step_picker.js +1 -1
  74. package/src/flow/kit/value_picker.js +1 -1
  75. package/src/flow/outline/menu.js +1 -1
  76. package/src/git/diff_viewer.js +212 -0
  77. package/src/git/diff_window.js +51 -0
  78. package/src/git/git_graph.js +401 -0
  79. package/src/git/git_ops.js +61 -0
  80. package/src/git/git_panel.js +755 -0
  81. package/src/git/index.js +9 -0
  82. package/src/git/line_diff.js +202 -0
  83. package/src/tiles/tile_base.js +5 -4
  84. package/src/tiling/desktops.js +10 -8
  85. package/src/tiling/keymap.js +6 -5
  86. package/src/tiling/kind_taxonomy.js +15 -2
  87. package/src/tiling/shell.js +124 -17
  88. package/src/tiling/tile_renderer.js +2 -3
  89. package/src/ui/components/action_dropdown.js +4 -78
  90. package/src/ui/components/anchored_layer.js +255 -0
  91. package/src/ui/components/chat_shell.js +926 -0
  92. package/src/ui/components/chat_state_machine.js +223 -0
  93. package/src/ui/components/choice_controls.js +45 -2
  94. package/src/ui/components/color_field.js +3 -2
  95. package/src/ui/components/color_list_field.js +3 -2
  96. package/src/ui/components/combobox.js +422 -0
  97. package/src/ui/components/context_menu.js +8 -2
  98. package/src/ui/components/conversation_view.js +17 -2
  99. package/src/ui/components/data_table.js +2 -2
  100. package/src/ui/components/filter_builder.js +791 -0
  101. package/src/ui/components/filter_model.js +682 -0
  102. package/src/ui/components/inline_renamer.js +10 -5
  103. package/src/ui/components/managed_window.js +24 -47
  104. package/src/ui/components/modal.js +104 -117
  105. package/src/ui/components/option_list.js +160 -0
  106. package/src/ui/components/popover.js +94 -0
  107. package/src/ui/components/recent_items.js +169 -0
  108. package/src/ui/components/select.js +322 -0
  109. package/src/ui/components/tag_input.js +77 -8
  110. package/src/ui/components/workspace_picker.js +1253 -0
  111. package/src/ui/utils/fire_event.js +17 -0
  112. package/src/ui/utils/focusable.js +25 -0
  113. package/src/ui/utils/modal_host.js +27 -0
  114. package/src/ui/utils/tooltip_service.js +81 -14
  115. package/widgets.js +12 -2
  116. package/css/base.css +0 -10547
  117. package/css/overrides.css +0 -2596
  118. package/dist/chunk-2Z2ENHR3.js.map +0 -7
  119. package/dist/chunk-7JI7HHSY.js +0 -107
  120. package/dist/chunk-7JI7HHSY.js.map +0 -7
  121. package/dist/chunk-7OL6F2AM.js.map +0 -7
  122. package/dist/chunk-MIW7D6WC.js +0 -526
  123. package/dist/chunk-MIW7D6WC.js.map +0 -7
  124. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  125. package/dist/chunk-XJNADRGR.js.map +0 -7
  126. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -32,11 +32,12 @@ export function attachInlineRenamer(options = {}) {
32
32
  let originalText = label.textContent || '';
33
33
 
34
34
  const cleanup = () => {
35
- if (input && input.parentNode === label) {
36
- input.remove();
37
- }
35
+ // `input` goes first: taking a focused field off the page fires its blur,
36
+ // and a blur with the field still held would commit an edit that just ended.
37
+ const gone = input;
38
38
  input = null;
39
39
  editing = false;
40
+ if (gone && gone.parentNode === label) gone.remove();
40
41
  };
41
42
 
42
43
  const cancel = () => {
@@ -68,8 +69,8 @@ export function attachInlineRenamer(options = {}) {
68
69
  return;
69
70
  }
70
71
  const finalValue = result.newValue ?? next ?? '';
71
- label.textContent = finalValue;
72
72
  cleanup();
73
+ label.textContent = finalValue;
73
74
  options.onFinish?.({ value: finalValue });
74
75
  } catch (err) {
75
76
  window.logger?.warn('inline-renamer', 'onCommit failed', err);
@@ -89,6 +90,11 @@ export function attachInlineRenamer(options = {}) {
89
90
  input.addEventListener('mousedown', stopEvents);
90
91
  input.addEventListener('click', stopEvents);
91
92
 
93
+ // Before the renamer's own keys, so that what it attaches to the input
94
+ // hears them first: `attachInputOptions` takes Enter on a highlighted row
95
+ // and Esc with its list open, and the renamer never sees either.
96
+ options.onStart?.({ value: originalText, triggerEvent, input });
97
+
92
98
  input.addEventListener('keydown', (ev) => {
93
99
  if (ev.key === 'Enter') {
94
100
  ev.preventDefault();
@@ -101,7 +107,6 @@ export function attachInlineRenamer(options = {}) {
101
107
 
102
108
  input.addEventListener('blur', () => commit('blur'));
103
109
 
104
- options.onStart?.({ value: originalText, triggerEvent });
105
110
  setTimeout(() => {
106
111
  input?.focus();
107
112
  input?.select?.();
@@ -18,6 +18,8 @@
18
18
  */
19
19
 
20
20
  import { getSetting } from '../../core/settings.js';
21
+ import { isInAnchoredLayer } from './anchored_layer.js';
22
+ import { collectFocusable } from '../utils/focusable.js';
21
23
 
22
24
  /** Where window geometry lives when the embedder names no key. An embedder
23
25
  * that saved geometry under another key keeps it by passing that key to
@@ -70,29 +72,6 @@ let _activeWindows = new Map(); // id -> ManagedWindow instance
70
72
  * the same event reaches it — one Esc must close one window. */
71
73
  const _escAnswered = new WeakSet();
72
74
 
73
- /** Collect the focusable elements inside `root`, in DOM order. Used
74
- * by the modal focus trap so Tab cycles within the modal frame and
75
- * can't escape into the underlying tiles. */
76
- function _collectFocusable(root) {
77
- if (!root) return [];
78
- const sel = [
79
- 'a[href]',
80
- 'button:not([disabled])',
81
- 'input:not([disabled]):not([type="hidden"])',
82
- 'select:not([disabled])',
83
- 'textarea:not([disabled])',
84
- '[tabindex]:not([tabindex="-1"])',
85
- ].join(',');
86
- return Array.from(root.querySelectorAll(sel)).filter((el) => {
87
- // Skip hidden / zero-size elements — Tab visits only what the
88
- // user can actually see.
89
- if (el.hidden) return false;
90
- if (el.closest('[hidden]')) return false;
91
- const r = el.getBoundingClientRect();
92
- return r.width > 0 || r.height > 0;
93
- });
94
- }
95
-
96
75
  // Global state cache
97
76
  let _stateCache = null;
98
77
 
@@ -548,12 +527,17 @@ export class ManagedWindow {
548
527
  // taskbar can filter on receipt: an in-panel taskbar shows only the
549
528
  // windows mounted in its own panel, and the viewport taskbar shows only
550
529
  // the ones with no container.
551
- window.dispatchEvent(new CustomEvent('managed-window-closed', {
552
- detail: { id: this.id, container: this.container }
553
- }));
530
+ this._announce('managed-window-closed', { id: this.id, container: this.container });
554
531
  this._release();
555
532
  }
556
533
 
534
+ /** Every window event goes out on the page's window, and a window takes only
535
+ * an event built by its own `CustomEvent`: Node's global one is refused
536
+ * under jsdom, in the middle of the close that fired it. */
537
+ _announce(name, detail) {
538
+ window.dispatchEvent(new window.CustomEvent(name, { detail }));
539
+ }
540
+
557
541
  /** BUG-0053. A closed window leaves the registry and lets go of what it
558
542
  * held: its content, its DOM and every consumer callback (an `onClose`
559
543
  * typically captures its owner). Until now the registry kept all of it
@@ -607,12 +591,10 @@ export class ManagedWindow {
607
591
  }
608
592
 
609
593
  // Dispatch event first so the taskbar button is created synchronously
610
- window.dispatchEvent(new CustomEvent('managed-window-minimized', {
611
- detail: {
612
- id: this.id, title: this.title, icon: this.icon,
613
- container: this.container,
614
- }
615
- }));
594
+ this._announce('managed-window-minimized', {
595
+ id: this.id, title: this.title, icon: this.icon,
596
+ container: this.container,
597
+ });
616
598
 
617
599
  if (!this.element) return;
618
600
 
@@ -744,9 +726,7 @@ export class ManagedWindow {
744
726
  this.bringToFront();
745
727
 
746
728
  // Notify taskbar (removes the taskbar button)
747
- window.dispatchEvent(new CustomEvent('managed-window-restored', {
748
- detail: { id: this.id, container: this.container }
749
- }));
729
+ this._announce('managed-window-restored', { id: this.id, container: this.container });
750
730
  }
751
731
 
752
732
  /**
@@ -825,7 +805,7 @@ export class ManagedWindow {
825
805
 
826
806
  // A CLASS THE STYLESHEET HAS ALWAYS KNOWN AND NOTHING EVER SET.
827
807
  // `.twm-managed-window--maximized .twm-managed-window__resize
828
- // { display: none }` is in `css/base.css` — "a maximised window is not
808
+ // { display: none }` is in `css/flexdesk.css` — "a maximised window is not
829
809
  // resized by dragging its edges" — and it has never matched anything,
830
810
  // because no code path adds the modifier. So a maximised window keeps
831
811
  // eight live resize handles hanging 3px outside its edges. Exactly the
@@ -841,9 +821,7 @@ export class ManagedWindow {
841
821
  // tiles — had no edge to hang the rule on and would have had to poll.
842
822
  // Same detail shape as the other three window events, `container`
843
823
  // included, so a per-region consumer can filter on receipt.
844
- window.dispatchEvent(new CustomEvent('managed-window-maximized', {
845
- detail: { id: this.id, maximized: this.isMaximized, container: this.container },
846
- }));
824
+ this._announce('managed-window-maximized', { id: this.id, maximized: this.isMaximized, container: this.container });
847
825
  }
848
826
 
849
827
  /**
@@ -965,7 +943,7 @@ export class ManagedWindow {
965
943
  }
966
944
  this.element.setAttribute('data-window-id', this.id);
967
945
  // BUG-0053. Focusable by script and by a press, never by Tab (the
968
- // modal trap's `_collectFocusable` skips tabindex -1): a press on the
946
+ // modal trap's `collectFocusable` skips tabindex -1): a press on the
969
947
  // title bar or on empty content keeps focus in the window, where a
970
948
  // non-modal window's Esc looks for it.
971
949
  this.element.tabIndex = -1;
@@ -1274,9 +1252,7 @@ export class ManagedWindow {
1274
1252
  this.width = bounds.width; this.height = bounds.height;
1275
1253
  }
1276
1254
  this._applyPosition();
1277
- window.dispatchEvent(new CustomEvent('managed-window-moved', {
1278
- detail: { id: this.id, container },
1279
- }));
1255
+ this._announce('managed-window-moved', { id: this.id, container });
1280
1256
  return true;
1281
1257
  }
1282
1258
 
@@ -1736,9 +1712,7 @@ export class ManagedWindow {
1736
1712
  this.height = rect.height;
1737
1713
  this._applyPosition();
1738
1714
  this.element?.classList.add('twm-managed-window--snapped');
1739
- window.dispatchEvent(new CustomEvent('managed-window-snapped', {
1740
- detail: { id: this.id, zone, container: this.container },
1741
- }));
1715
+ this._announce('managed-window-snapped', { id: this.id, zone, container: this.container });
1742
1716
  }
1743
1717
 
1744
1718
  /** Restore the geometry a snap replaced. Called when a snapped window is
@@ -1850,7 +1824,10 @@ export class ManagedWindow {
1850
1824
  // active element into the background — bad UX, and a
1851
1825
  // genuine WCAG focus-trap violation for accessibility tools.
1852
1826
  if (e.key === 'Tab' && this.modal && this.element) {
1853
- const focusables = _collectFocusable(this.element);
1827
+ // A popover opened from this modal is over it but outside its frame,
1828
+ // and sees to its own Tab.
1829
+ if (isInAnchoredLayer(document.activeElement)) return;
1830
+ const focusables = collectFocusable(this.element);
1854
1831
  if (focusables.length === 0) {
1855
1832
  e.preventDefault();
1856
1833
  return;
@@ -77,7 +77,7 @@
77
77
  * }
78
78
  *
79
79
  * Dependent-property pick-or-create — a `select` field that carries a
80
- * `create` spec renders as a **type-ahead combobox** instead of a
80
+ * `create` spec renders as a `createCombobox` instead of a
81
81
  * dropdown: the user picks an existing option OR types a new value.
82
82
  * On submit, a value that isn't an existing option is created via
83
83
  * `create.onCreate(value)` before the form resolves:
@@ -115,29 +115,11 @@ import { ToggleField } from '../../ui/components/toggle_field.js';
115
115
  import { ColorField } from './color_field.js';
116
116
  import { ColorListField } from './color_list_field.js';
117
117
  import { choiceList, findChoice, flatChoices, selectBox, toggleChips } from './choice_controls.js';
118
+ import { createCombobox } from './combobox.js';
119
+ import { modalHost, setModalHost } from '../utils/modal_host.js';
120
+ import { fireEvent } from '../utils/fire_event.js';
118
121
 
119
- /**
120
- * C25. WHERE A DIALOG OPENS, when the application spans more than one window.
121
- *
122
- * Every modal here is a `ManagedWindow` with no `container`, so it mounts into
123
- * `document.body` — and "document" is the document this MODULE was loaded in.
124
- * A consumer that opens a second browser window with `window.open` and builds
125
- * its DOM from the opener's realm (which is how a pop-out grid works: same
126
- * modules, same JS context, a different document) therefore gets its dialogs in
127
- * the window it popped OUT of. The user presses "Add column" on their second
128
- * monitor and a dialog appears on the first, behind whatever is there.
129
- *
130
- * Passing a container at each call site was the other option and it is worse:
131
- * twenty-one of them across eleven files, every one of which would have to be
132
- * given a document it has no other reason to know about, and any one missed is
133
- * this bug again with no way to see it from here.
134
- *
135
- * So the HOST is ambient and the consumer sets it when its focus moves. Null —
136
- * every consumer today — means `document.body`, exactly as before.
137
- */
138
- let _modalHost = null;
139
- export function setModalHost(el) { _modalHost = el || null; }
140
- export function modalHost() { return _modalHost; }
122
+ export { setModalHost, modalHost };
141
123
 
142
124
  let _modalSeq = 1;
143
125
 
@@ -198,7 +180,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
198
180
  `;
199
181
 
200
182
  const win = new ManagedWindow({
201
- container: _modalHost,
183
+ container: modalHost(),
202
184
  id: `twm-modal-${_modalSeq++}`,
203
185
  title: title || 'Dialog',
204
186
  icon: 'edit_note',
@@ -237,12 +219,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
237
219
  // The options each field offers NOW — `setOptions` replaces them, the
238
220
  // caller's field object is never written to.
239
221
  const optionsOf = new Map(formFields.map((f) => [f.name, f.options]));
240
- const comboHints = new Map();
241
222
  for (const f of formFields) {
242
- if (f.type === 'select' && f.create) {
243
- const update = _wireComboboxHint(body, f, () => optionsOf.get(f.name));
244
- if (update) comboHints.set(f.name, update);
245
- }
246
223
  if (f.type === 'picker') _wirePicker(body, f);
247
224
  }
248
225
  const rowHidden = (name) => !!body.querySelector(`[data-field-row="${name}"]`)?.hidden;
@@ -281,13 +258,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
281
258
  * read a combobox through this, so they agree (`'2'` is option `2`). */
282
259
  const comboOption = (f, text) => findChoice(flatChoices(optionsOf.get(f.name)), text, true);
283
260
  /** A disabled option's reason ('' with none) when one is the value, else null. */
284
- const unavailable = (f, value) => {
285
- const c = controls.get(f.name);
286
- if (c) return c.unavailable();
287
- if (f.type !== 'select' || !f.create || value == null || value === '') return null;
288
- const o = comboOption(f, value);
289
- return o?.disabled ? (o.reason || '') : null;
290
- };
261
+ const unavailable = (f) => controls.get(f.name)?.unavailable() ?? null;
291
262
 
292
263
  // Clear per-field errors on input so the user sees the row recover.
293
264
  for (const f of formFields) {
@@ -337,11 +308,6 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
337
308
  const el = body.querySelector(`[name="${name}"]`);
338
309
  if (!el) return undefined;
339
310
  if (el.type === 'checkbox') return !!el.checked;
340
- const f = fieldOf(name);
341
- if (f?.type === 'select' && f.create) {
342
- const match = comboOption(f, el.value);
343
- return match ? match.value : el.value;
344
- }
345
311
  return el.value;
346
312
  },
347
313
  set(name, value) {
@@ -350,29 +316,17 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
350
316
  if (!el) return;
351
317
  if (el.type === 'checkbox') { el.checked = !!value; return; }
352
318
  const f = fieldOf(name);
353
- // A combobox does not take a disabled option's text either.
354
- const shut = f?.type === 'select' && f.create && comboOption(f, value)?.disabled;
355
- el.value = value == null || shut ? '' : String(value);
356
- comboHints.get(name)?.();
319
+ el.value = value == null ? '' : String(value);
357
320
  if (f?.type === 'picker') {
358
321
  const display = body.querySelector(`[data-picker-display="${name}"]`);
359
322
  if (display) display.value = _pickerLabel(f, el.value);
360
323
  }
361
324
  },
362
325
  setOptions(name, options) {
363
- const f = fieldOf(name);
364
- if (!f) return;
365
326
  const c = controls.get(name);
366
- if (c?.setOptions) {
367
- optionsOf.set(name, options);
368
- c.setOptions(options);
369
- return;
370
- }
371
- if (f.type !== 'select' || !f.create) return;
327
+ if (!c?.setOptions) return;
372
328
  optionsOf.set(name, options);
373
- const list = body.querySelector(`[data-datalist-for="${name}"]`);
374
- if (list) list.innerHTML = _datalistOptions(options);
375
- comboHints.get(name)?.();
329
+ c.setOptions(options);
376
330
  },
377
331
  };
378
332
  const fire = (name) => {
@@ -444,7 +398,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
444
398
  if (rowHidden(f.name)) {
445
399
  // Not asked about — but a disabled option is still never
446
400
  // the value: a hidden row holding one submits no value.
447
- if (unavailable(f, data[f.name]) != null) data[f.name] = f.type === 'radio' ? null : '';
401
+ if (unavailable(f) != null) data[f.name] = f.type === 'radio' ? null : '';
448
402
  continue;
449
403
  }
450
404
  const v = data[f.name];
@@ -454,7 +408,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
454
408
  let msg = f.required && _missing(f, v) ? 'Required.' : null;
455
409
  if (!msg && _outOfRange(f, v)) msg = _rangeMessage(f);
456
410
  if (!msg) {
457
- const reason = unavailable(f, v);
411
+ const reason = unavailable(f);
458
412
  if (reason != null) msg = reason ? `Not available: ${reason}` : 'Not available.';
459
413
  }
460
414
  if (!msg) msg = controls.get(f.name)?.error?.() ?? null;
@@ -490,7 +444,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
490
444
 
491
445
  /**
492
446
  * `openConfirm` — a yes/no modal. Resolves `true` on confirm, `false` on
493
- * cancel, Esc, X or backdrop.
447
+ * cancel, Esc or X.
494
448
  *
495
449
  * `message`, `confirmLabel` and `cancelLabel` are TEXT, never markup: callers
496
450
  * put user-named things in them ("Delete flow "<name>"?"), so they are set
@@ -527,7 +481,7 @@ export function openConfirm({
527
481
 
528
482
  let _resolved = false;
529
483
  const win = new ManagedWindow({
530
- container: _modalHost,
484
+ container: modalHost(),
531
485
  id: `twm-confirm-${_modalSeq++}`,
532
486
  title: title || 'Confirm',
533
487
  icon: icon || (danger ? 'warning' : 'help'),
@@ -577,7 +531,8 @@ export function openConfirm({
577
531
  * omitted, defaults to a single `[Close]` action that
578
532
  * resolves with `null`. The first action with
579
533
  * `primary: true` is the rightmost; otherwise the
580
- * last action wins. Esc / X / backdrop resolve with
534
+ * last action wins. Esc / X (and, with
535
+ * `dismissOnBackdrop`, a click on the backdrop) resolve with
581
536
  * `null` regardless of what's in `actions`. `icon` is
582
537
  * an optional material-symbols-outlined ligature drawn
583
538
  * BEFORE the label; when it is given the label lives in
@@ -601,6 +556,12 @@ export function openConfirm({
601
556
  * existing caller has always got.
602
557
  * - `onMaximizeChange` `(maximized, contentEl) => void`, called on each
603
558
  * flip. Only useful with `maximizable`.
559
+ * - `beforeClose` `() => boolean | Promise<boolean>`: asked when Esc or
560
+ * the X would dismiss; answering false keeps the modal open. An
561
+ * action button does not ask it.
562
+ * - `dismissOnBackdrop` a click on the dimmed page beside the modal dismisses
563
+ * it, as Esc does, so `beforeClose` is asked. Default `false`:
564
+ * the backdrop only blocks.
604
565
  *
605
566
  * ══ C29. WHY A DIALOG'S MAXIMISE IS THE GEOMETRIC ONE ═══════════════════
606
567
  *
@@ -642,6 +603,8 @@ export function openModal({
642
603
  backdropOpacity = undefined,
643
604
  maximizable = false,
644
605
  onMaximizeChange = null,
606
+ beforeClose = null,
607
+ dismissOnBackdrop = false,
645
608
  } = {}) {
646
609
  return new Promise((resolve) => {
647
610
  const body = document.createElement('div');
@@ -652,8 +615,8 @@ export function openModal({
652
615
  if (content instanceof HTMLElement) bodyInner.appendChild(content);
653
616
  body.appendChild(bodyInner);
654
617
 
655
- // Actions row — default = single Close button. Esc / backdrop /
656
- // X all bypass this and resolve with null.
618
+ // Actions row — default = single Close button. Esc and X (and an
619
+ // opted-in backdrop) bypass this and resolve with null.
657
620
  const acts = Array.isArray(actions) && actions.length > 0
658
621
  ? actions
659
622
  : [{ label: 'Close', value: null }];
@@ -726,7 +689,7 @@ export function openModal({
726
689
  _onMax = null;
727
690
  };
728
691
  const win = new ManagedWindow({
729
- container: _modalHost,
692
+ container: modalHost(),
730
693
  id: `twm-modal-${_modalSeq++}`,
731
694
  title,
732
695
  icon,
@@ -747,16 +710,25 @@ export function openModal({
747
710
  canDrag: true,
748
711
  defaultWidth: width,
749
712
  defaultHeight: height,
750
- // Esc, the X and the backdrop all land here without passing through
751
- // `close`, so the listener is dropped here as well as there — the
752
- // three dismissals a user reaches for most are exactly the ones
753
- // that would otherwise leak it.
713
+ beforeClose,
714
+ // Esc, the X and an opted-in backdrop all land here without passing
715
+ // through `close`, so the listener is dropped here as well as there —
716
+ // the dismissals a user reaches for most are exactly the ones that
717
+ // would otherwise leak it.
754
718
  onClose: () => {
755
719
  _dropMaxListener();
756
720
  if (!_resolved) { _resolved = true; resolve(null); }
757
721
  },
758
722
  });
759
723
  win.show();
724
+ // A click, not a press: a drag that starts in the window and ends on the
725
+ // page (selecting text, resizing) has no click on the backdrop.
726
+ // The press takes no focus from the window: `beforeClose` may refuse, and
727
+ // the person goes on typing where they were.
728
+ if (dismissOnBackdrop) {
729
+ win.backdropElement.addEventListener('mousedown', (e) => e.preventDefault());
730
+ win.backdropElement.addEventListener('click', () => win.close());
731
+ }
760
732
 
761
733
  // C29. THE STATE, FOR A CALLER WHOSE LAYOUT DEPENDS ON IT.
762
734
  //
@@ -859,7 +831,11 @@ export function openModal({
859
831
  // whose first control is a read-only statement panel (C27's disabled
860
832
  // primary is the sibling case) would swallow the caret into a box that
861
833
  // cannot take it.
834
+ //
835
+ // AN `onMount` THAT PLACED FOCUS KEEPS IT, with its selection: it ran in
836
+ // the frame before this one, and it knows the field better than "the first".
862
837
  requestAnimationFrame(() => {
838
+ if (bodyInner.contains(document.activeElement)) return;
863
839
  const field = bodyInner.querySelector(
864
840
  'input:not([type="hidden"]):not([disabled]):not([readonly]),'
865
841
  + ' textarea:not([disabled]):not([readonly]),'
@@ -953,14 +929,68 @@ function _offered(options) {
953
929
  return flatChoices(options).filter((o) => !o.disabled);
954
930
  }
955
931
 
956
- function _datalistOptions(options) {
957
- return _offered(options).map((o) => `<option value="${_esc(o.value)}"></option>`).join('');
932
+ /**
933
+ * A `select` with `create`: `createCombobox` over the enabled options. The box holds
934
+ * the text; `read` is the value of the option that text names, else the text. A
935
+ * disabled option is not offered, and a default or `write` naming one leaves the box
936
+ * empty; typed all the same, it is `unavailable`.
937
+ */
938
+ function _mountCombobox(slot, f, value) {
939
+ let options = f.options;
940
+ const named = (text) => findChoice(flatChoices(options), text, true);
941
+ const shown = (v) => (v == null || named(v)?.disabled ? '' : String(v));
942
+
943
+ const hint = document.createElement('span');
944
+ hint.className = 'twm-combobox__hint';
945
+ hint.hidden = true;
946
+ // With `create: true` the typed value simply IS the value — there is no
947
+ // entity being made, so promising to "create" one is a lie that reads as
948
+ // a warning.
949
+ const noun = f.create === true ? null : _createSpec(f).hint || 'new entry';
950
+ const updateHint = () => {
951
+ const v = c.value.trim();
952
+ const isNew = noun && v && !flatChoices(options).some((o) => String(o.value) === v);
953
+ if (isNew) hint.textContent = `↳ will create ${noun} “${v}”`;
954
+ hint.hidden = !isNew;
955
+ };
956
+
957
+ const c = createCombobox({
958
+ options: _offered(options),
959
+ value: shown(value),
960
+ name: f.name,
961
+ placeholder: f.placeholder ?? 'type to pick or create…',
962
+ // Enter keeps what was typed: this field exists to accept a value nobody listed.
963
+ autoHighlight: false,
964
+ onChange: () => updateHint(),
965
+ });
966
+ if (f.required) c.input.required = true;
967
+ c.el.appendChild(hint);
968
+ slot.replaceWith(c.el);
969
+ updateHint();
970
+ return {
971
+ read() {
972
+ const o = named(c.value);
973
+ return o ? o.value : c.value;
974
+ },
975
+ write(v) { c.value = shown(v); updateHint(); },
976
+ focus: c.focus,
977
+ unavailable() {
978
+ const o = c.value === '' ? undefined : named(c.value);
979
+ return o?.disabled ? (o.reason || '') : null;
980
+ },
981
+ setOptions(next) {
982
+ options = next;
983
+ c.setOptions(_offered(next));
984
+ updateHint();
985
+ },
986
+ dispose: c.destroy,
987
+ };
958
988
  }
959
989
 
960
990
  /**
961
991
  * Mount a field that is a component rather than markup — a `toggle`, a
962
- * `select` (not a combobox), `checklist` or `radio` drawn by
963
- * `choice_controls.js`, or a `color` / `colorList` — into the
992
+ * `select`, `checklist` or `radio` drawn by `choice_controls.js`, a `select`
993
+ * with `create` drawn by `createCombobox`, or a `color` / `colorList` — into the
964
994
  * `data-control-slot` its row left, and hand back what the form needs of it:
965
995
  * `{ read(), write(v), focus(), unavailable(), submit?(), error?(),
966
996
  * setOptions?(options), dispose?() }`. `unavailable()` is the reason (or '')
@@ -1018,6 +1048,7 @@ function _mountControl(host, f, value, onChange) {
1018
1048
  dispose: () => c.dispose(),
1019
1049
  };
1020
1050
  }
1051
+ if (f.type === 'select' && f.create) return _mountCombobox(slot, f, value);
1021
1052
  const common = { keepMissing: false, loose: true };
1022
1053
  const disabledReason = (entry) => (entry?.disabled ? (entry.reason || '') : null);
1023
1054
  if (f.type === 'select') {
@@ -1113,7 +1144,7 @@ function _wirePicker(host, f) {
1113
1144
  ? String(picked.label) : _pickerLabel(f, hidden.value);
1114
1145
  // A hidden input fires no `change` of its own; this is the one the
1115
1146
  // field-error clear and `onFieldChange` listen for.
1116
- hidden.dispatchEvent(new Event('change', { bubbles: true }));
1147
+ fireEvent(hidden, 'change', { bubbles: true });
1117
1148
  };
1118
1149
  btn.addEventListener('click', open);
1119
1150
  // The read-only box is the obvious thing to click; make it work too.
@@ -1121,33 +1152,6 @@ function _wirePicker(host, f) {
1121
1152
  }
1122
1153
 
1123
1154
 
1124
- /** Show / hide the "↳ will create …" hint as the user types into a
1125
- * create-capable combobox. */
1126
- function _wireComboboxHint(host, f, optionsOf) {
1127
- const input = host.querySelector(`input[name="${f.name}"]`);
1128
- const hintEl = host.querySelector(`.twm-combobox__hint[data-for="${f.name}"]`);
1129
- if (!input || !hintEl) return;
1130
- // With `create: true` the typed value simply IS the value — there is no
1131
- // entity being made, so promising to "create" one is a lie that reads as
1132
- // a warning.
1133
- if (f.create === true) return;
1134
- const label = _createSpec(f).hint || 'new entry';
1135
- const update = () => {
1136
- const v = input.value.trim();
1137
- const known = new Set(flatChoices(optionsOf()).map((o) => String(o.value)));
1138
- if (v && !known.has(v)) {
1139
- hintEl.textContent = `↳ will create ${label} “${v}”`;
1140
- hintEl.hidden = false;
1141
- } else {
1142
- hintEl.hidden = true;
1143
- }
1144
- };
1145
- input.addEventListener('input', update);
1146
- update();
1147
- return update;
1148
- }
1149
-
1150
-
1151
1155
  function _renderEntry(f, defaults) {
1152
1156
  if (f && f.section != null) {
1153
1157
  // A named section is addressable by `setVisible`, so a heading can be
@@ -1247,23 +1251,6 @@ function _renderField(f, value) {
1247
1251
  </div>
1248
1252
  `);
1249
1253
  }
1250
- if (f.type === 'select' && f.create) {
1251
- // A default naming a disabled option is not taken, as in a select.
1252
- const shown = findChoice(flatChoices(f.options), value, true)?.disabled ? '' : v;
1253
- const dlId = `ea-dl-${f.name}-${_modalSeq}`;
1254
- const opts = _datalistOptions(f.options);
1255
- const placeholder = f.placeholder
1256
- ? `placeholder="${_esc(f.placeholder)}"` : 'placeholder="type to pick or create…"';
1257
- return wrap(`
1258
- <div class="twm-combobox">
1259
- <input name="${f.name}" type="text" list="${dlId}"
1260
- value="${_esc(shown)}" ${placeholder} ${required}
1261
- autocomplete="off">
1262
- <datalist id="${dlId}" data-datalist-for="${f.name}">${opts}</datalist>
1263
- <span class="twm-combobox__hint" data-for="${f.name}" hidden></span>
1264
- </div>
1265
- `);
1266
- }
1267
1254
  if (f.type === 'select') return wrap(slot);
1268
1255
  const step = f.step != null ? `step="${f.step}"` : '';
1269
1256
  const placeholder = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : '';