flexdesk 0.17.18 → 0.18.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 (118) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7544 -5916
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +23 -1
  6. package/dist/charts.js +10 -6
  7. package/dist/charts.js.map +1 -1
  8. package/dist/chunk-5W4EEKSK.js +778 -0
  9. package/dist/chunk-5W4EEKSK.js.map +7 -0
  10. package/dist/chunk-DNJ3N4T4.js +82 -0
  11. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  12. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  13. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  14. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  15. package/dist/{chunk-7OL6F2AM.js → chunk-P74F6I6P.js} +6 -4
  16. package/dist/{chunk-7OL6F2AM.js.map → chunk-P74F6I6P.js.map} +1 -1
  17. package/dist/chunk-PAI2ZUWV.js +260 -0
  18. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  19. package/dist/chunk-Q7BALEPX.js +527 -0
  20. package/dist/chunk-Q7BALEPX.js.map +7 -0
  21. package/dist/chunk-QXAOTGCC.js +40 -0
  22. package/dist/chunk-QXAOTGCC.js.map +7 -0
  23. package/dist/{chunk-XJNADRGR.js → chunk-RKVYIXDC.js} +664 -91
  24. package/dist/chunk-RKVYIXDC.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-U5GKJUOC.js} +3 -3
  26. package/dist/{chunk-2Z2ENHR3.js.map → chunk-U5GKJUOC.js.map} +1 -1
  27. package/dist/chunk-UKP22RUG.js +219 -0
  28. package/dist/chunk-UKP22RUG.js.map +7 -0
  29. package/dist/chunk-VLOAI3HS.js +43 -0
  30. package/dist/chunk-VLOAI3HS.js.map +7 -0
  31. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  32. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  33. package/dist/{chunk-PGZ4EHSE.js → chunk-ZL3VKINP.js} +41 -390
  34. package/dist/chunk-ZL3VKINP.js.map +7 -0
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7544 -5916
  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 +23 -1
  46. package/dist/widgets.js +3967 -1188
  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/chat-shell.md +198 -0
  53. package/docs/components/conversation.md +10 -0
  54. package/docs/components/filter-builder.md +246 -0
  55. package/docs/components/forms-and-dialogs.md +22 -0
  56. package/docs/components/git.md +355 -0
  57. package/docs/components/input-fields.md +167 -2
  58. package/docs/components/keymap.md +8 -0
  59. package/docs/components/menus.md +82 -2
  60. package/docs/components/panels-and-desktops.md +16 -5
  61. package/docs/components/shell.md +104 -1
  62. package/docs/components/workspace-picker.md +149 -0
  63. package/docs/concepts/building-and-packaging.md +2 -2
  64. package/docs/concepts/packages-and-entry-points.md +14 -13
  65. package/docs/concepts/state-guard.md +3 -3
  66. package/docs/quickstart.md +1 -1
  67. package/git.js +13 -0
  68. package/package.json +6 -1
  69. package/src/flow/kit/dom.js +28 -119
  70. package/src/flow/kit/step_picker.js +1 -1
  71. package/src/flow/kit/value_picker.js +1 -1
  72. package/src/flow/outline/menu.js +1 -1
  73. package/src/git/diff_viewer.js +212 -0
  74. package/src/git/diff_window.js +51 -0
  75. package/src/git/git_graph.js +401 -0
  76. package/src/git/git_ops.js +61 -0
  77. package/src/git/git_panel.js +755 -0
  78. package/src/git/index.js +9 -0
  79. package/src/git/line_diff.js +202 -0
  80. package/src/tiles/tile_base.js +5 -4
  81. package/src/tiling/desktops.js +10 -8
  82. package/src/tiling/keymap.js +6 -5
  83. package/src/tiling/kind_taxonomy.js +15 -2
  84. package/src/tiling/shell.js +124 -17
  85. package/src/tiling/tile_renderer.js +2 -3
  86. package/src/ui/components/action_dropdown.js +4 -78
  87. package/src/ui/components/anchored_layer.js +255 -0
  88. package/src/ui/components/chat_shell.js +920 -0
  89. package/src/ui/components/chat_state_machine.js +223 -0
  90. package/src/ui/components/choice_controls.js +45 -2
  91. package/src/ui/components/color_field.js +3 -2
  92. package/src/ui/components/color_list_field.js +3 -2
  93. package/src/ui/components/combobox.js +422 -0
  94. package/src/ui/components/conversation_view.js +17 -2
  95. package/src/ui/components/data_table.js +2 -2
  96. package/src/ui/components/filter_builder.js +766 -0
  97. package/src/ui/components/filter_model.js +614 -0
  98. package/src/ui/components/managed_window.js +24 -47
  99. package/src/ui/components/modal.js +79 -109
  100. package/src/ui/components/option_list.js +160 -0
  101. package/src/ui/components/popover.js +94 -0
  102. package/src/ui/components/recent_items.js +169 -0
  103. package/src/ui/components/select.js +322 -0
  104. package/src/ui/components/tag_input.js +77 -8
  105. package/src/ui/components/workspace_picker.js +1155 -0
  106. package/src/ui/utils/fire_event.js +17 -0
  107. package/src/ui/utils/focusable.js +25 -0
  108. package/src/ui/utils/modal_host.js +27 -0
  109. package/widgets.js +12 -2
  110. package/css/base.css +0 -10547
  111. package/css/overrides.css +0 -2596
  112. package/dist/chunk-7JI7HHSY.js +0 -107
  113. package/dist/chunk-7JI7HHSY.js.map +0 -7
  114. package/dist/chunk-MIW7D6WC.js +0 -526
  115. package/dist/chunk-MIW7D6WC.js.map +0 -7
  116. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  117. package/dist/chunk-XJNADRGR.js.map +0 -7
  118. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -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;
@@ -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'),
@@ -601,6 +555,9 @@ export function openConfirm({
601
555
  * existing caller has always got.
602
556
  * - `onMaximizeChange` `(maximized, contentEl) => void`, called on each
603
557
  * flip. Only useful with `maximizable`.
558
+ * - `beforeClose` `() => boolean | Promise<boolean>`: asked when Esc or
559
+ * the X would dismiss; answering false keeps the modal open. An
560
+ * action button does not ask it.
604
561
  *
605
562
  * ══ C29. WHY A DIALOG'S MAXIMISE IS THE GEOMETRIC ONE ═══════════════════
606
563
  *
@@ -642,6 +599,7 @@ export function openModal({
642
599
  backdropOpacity = undefined,
643
600
  maximizable = false,
644
601
  onMaximizeChange = null,
602
+ beforeClose = null,
645
603
  } = {}) {
646
604
  return new Promise((resolve) => {
647
605
  const body = document.createElement('div');
@@ -726,7 +684,7 @@ export function openModal({
726
684
  _onMax = null;
727
685
  };
728
686
  const win = new ManagedWindow({
729
- container: _modalHost,
687
+ container: modalHost(),
730
688
  id: `twm-modal-${_modalSeq++}`,
731
689
  title,
732
690
  icon,
@@ -747,6 +705,7 @@ export function openModal({
747
705
  canDrag: true,
748
706
  defaultWidth: width,
749
707
  defaultHeight: height,
708
+ beforeClose,
750
709
  // Esc, the X and the backdrop all land here without passing through
751
710
  // `close`, so the listener is dropped here as well as there — the
752
711
  // three dismissals a user reaches for most are exactly the ones
@@ -953,14 +912,68 @@ function _offered(options) {
953
912
  return flatChoices(options).filter((o) => !o.disabled);
954
913
  }
955
914
 
956
- function _datalistOptions(options) {
957
- return _offered(options).map((o) => `<option value="${_esc(o.value)}"></option>`).join('');
915
+ /**
916
+ * A `select` with `create`: `createCombobox` over the enabled options. The box holds
917
+ * the text; `read` is the value of the option that text names, else the text. A
918
+ * disabled option is not offered, and a default or `write` naming one leaves the box
919
+ * empty; typed all the same, it is `unavailable`.
920
+ */
921
+ function _mountCombobox(slot, f, value) {
922
+ let options = f.options;
923
+ const named = (text) => findChoice(flatChoices(options), text, true);
924
+ const shown = (v) => (v == null || named(v)?.disabled ? '' : String(v));
925
+
926
+ const hint = document.createElement('span');
927
+ hint.className = 'twm-combobox__hint';
928
+ hint.hidden = true;
929
+ // With `create: true` the typed value simply IS the value — there is no
930
+ // entity being made, so promising to "create" one is a lie that reads as
931
+ // a warning.
932
+ const noun = f.create === true ? null : _createSpec(f).hint || 'new entry';
933
+ const updateHint = () => {
934
+ const v = c.value.trim();
935
+ const isNew = noun && v && !flatChoices(options).some((o) => String(o.value) === v);
936
+ if (isNew) hint.textContent = `↳ will create ${noun} “${v}”`;
937
+ hint.hidden = !isNew;
938
+ };
939
+
940
+ const c = createCombobox({
941
+ options: _offered(options),
942
+ value: shown(value),
943
+ name: f.name,
944
+ placeholder: f.placeholder ?? 'type to pick or create…',
945
+ // Enter keeps what was typed: this field exists to accept a value nobody listed.
946
+ autoHighlight: false,
947
+ onChange: () => updateHint(),
948
+ });
949
+ if (f.required) c.input.required = true;
950
+ c.el.appendChild(hint);
951
+ slot.replaceWith(c.el);
952
+ updateHint();
953
+ return {
954
+ read() {
955
+ const o = named(c.value);
956
+ return o ? o.value : c.value;
957
+ },
958
+ write(v) { c.value = shown(v); updateHint(); },
959
+ focus: c.focus,
960
+ unavailable() {
961
+ const o = c.value === '' ? undefined : named(c.value);
962
+ return o?.disabled ? (o.reason || '') : null;
963
+ },
964
+ setOptions(next) {
965
+ options = next;
966
+ c.setOptions(_offered(next));
967
+ updateHint();
968
+ },
969
+ dispose: c.destroy,
970
+ };
958
971
  }
959
972
 
960
973
  /**
961
974
  * 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
975
+ * `select`, `checklist` or `radio` drawn by `choice_controls.js`, a `select`
976
+ * with `create` drawn by `createCombobox`, or a `color` / `colorList` — into the
964
977
  * `data-control-slot` its row left, and hand back what the form needs of it:
965
978
  * `{ read(), write(v), focus(), unavailable(), submit?(), error?(),
966
979
  * setOptions?(options), dispose?() }`. `unavailable()` is the reason (or '')
@@ -1018,6 +1031,7 @@ function _mountControl(host, f, value, onChange) {
1018
1031
  dispose: () => c.dispose(),
1019
1032
  };
1020
1033
  }
1034
+ if (f.type === 'select' && f.create) return _mountCombobox(slot, f, value);
1021
1035
  const common = { keepMissing: false, loose: true };
1022
1036
  const disabledReason = (entry) => (entry?.disabled ? (entry.reason || '') : null);
1023
1037
  if (f.type === 'select') {
@@ -1113,7 +1127,7 @@ function _wirePicker(host, f) {
1113
1127
  ? String(picked.label) : _pickerLabel(f, hidden.value);
1114
1128
  // A hidden input fires no `change` of its own; this is the one the
1115
1129
  // field-error clear and `onFieldChange` listen for.
1116
- hidden.dispatchEvent(new Event('change', { bubbles: true }));
1130
+ fireEvent(hidden, 'change', { bubbles: true });
1117
1131
  };
1118
1132
  btn.addEventListener('click', open);
1119
1133
  // The read-only box is the obvious thing to click; make it work too.
@@ -1121,33 +1135,6 @@ function _wirePicker(host, f) {
1121
1135
  }
1122
1136
 
1123
1137
 
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
1138
  function _renderEntry(f, defaults) {
1152
1139
  if (f && f.section != null) {
1153
1140
  // A named section is addressable by `setVisible`, so a heading can be
@@ -1247,23 +1234,6 @@ function _renderField(f, value) {
1247
1234
  </div>
1248
1235
  `);
1249
1236
  }
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
1237
  if (f.type === 'select') return wrap(slot);
1268
1238
  const step = f.step != null ? `step="${f.step}"` : '';
1269
1239
  const placeholder = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : '';
@@ -0,0 +1,160 @@
1
+ /**
2
+ * THE ROWS `createSelect`, `createCombobox` AND `createTagInput`'s SUGGESTIONS
3
+ * DRAW: a `role="listbox"` of options, each with a glyph, a label, a second line,
4
+ * a hint at the end of the row and, when disabled, its reason; `{ group, options }`
5
+ * sections under a heading; one active row that the arrow keys walk.
6
+ *
7
+ * Focus never comes here. The text box or trigger that holds it names the active
8
+ * row with `aria-activedescendant` (`onActive`), and a press on a row is
9
+ * prevented from taking focus, so that a list row can be chosen with the box
10
+ * still holding the caret, and no blur closes the list under the pointer.
11
+ *
12
+ * `aria-selected` says which row IS the current value; the active row is a class.
13
+ * They are different things in a select, where the highlight walks away from the
14
+ * value and comes back. Every word is built with `textContent`: these lists carry
15
+ * text users wrote.
16
+ *
17
+ * An internal part: not exported from `@flexdesk/widgets`.
18
+ */
19
+
20
+ let SEQ = 0;
21
+
22
+ function node(tag, className, text) {
23
+ const n = document.createElement(tag);
24
+ if (className) n.className = className;
25
+ if (text !== undefined && text !== null) n.textContent = String(text);
26
+ return n;
27
+ }
28
+
29
+ /** `text` with the first run of `needle` in a `<mark>`, so it is plain why a row is offered. */
30
+ function marked(target, text, needle) {
31
+ const at = needle ? text.toLowerCase().indexOf(needle.toLowerCase()) : -1;
32
+ if (at < 0) { target.textContent = text; return; }
33
+ target.append(text.slice(0, at), node('mark', 'twm-option-list__match', text.slice(at, at + needle.length)),
34
+ text.slice(at + needle.length));
35
+ }
36
+
37
+ /**
38
+ * @param {object} o
39
+ * @param {(option: object) => void} o.onPick A row was clicked. Never a disabled one.
40
+ * @param {(id: string|null) => void} [o.onActive] The active row's id changed.
41
+ */
42
+ export function createOptionList({ onPick, onActive = null }) {
43
+ const id = `twm-option-list-${++SEQ}`;
44
+ const el = node('div', 'twm-option-list');
45
+ el.id = id;
46
+ el.setAttribute('role', 'listbox');
47
+
48
+ /** Every row drawn: `{ option, row }`. */
49
+ let rows = [];
50
+ let active = -1;
51
+
52
+ const paintActive = () => {
53
+ rows.forEach(({ row }, i) => row.classList.toggle('twm-option-list__row--active', i === active));
54
+ const row = rows[active]?.row;
55
+ // jsdom does not implement it, and a test harness is no reason to drop it.
56
+ row?.scrollIntoView?.({ block: 'nearest' });
57
+ onActive?.(row ? row.id : null);
58
+ };
59
+
60
+ const rowFor = (option, { glyphs, mark, isCurrent }) => {
61
+ const row = node('div', 'twm-option-list__row');
62
+ row.id = `${id}-${rows.length}`;
63
+ row.setAttribute('role', 'option');
64
+ row.setAttribute('aria-selected', String(!!isCurrent?.(option)));
65
+ if (option.disabled) row.setAttribute('aria-disabled', 'true');
66
+ row.title = option.description ? `${option.label} — ${option.description}` : option.label;
67
+ if (glyphs) {
68
+ const glyph = node('span', 'twm-option-list__glyph material-symbols-outlined', option.icon || '');
69
+ glyph.setAttribute('aria-hidden', 'true');
70
+ row.appendChild(glyph);
71
+ }
72
+ const words = node('span', 'twm-option-list__words');
73
+ const label = node('span', 'twm-option-list__label');
74
+ marked(label, option.label, mark);
75
+ words.appendChild(label);
76
+ if (option.description) words.appendChild(node('span', 'twm-option-list__description', option.description));
77
+ if (option.disabled && option.reason) words.appendChild(node('span', 'twm-choice__reason', option.reason));
78
+ row.appendChild(words);
79
+ if (option.hint) row.appendChild(node('span', 'twm-option-list__hint', option.hint));
80
+ return row;
81
+ };
82
+
83
+ const enabled = () => rows.map((r, i) => (r.option.disabled ? -1 : i)).filter((i) => i >= 0);
84
+
85
+ // A press on the list leaves focus where it is. See the header.
86
+ el.addEventListener('mousedown', (ev) => ev.preventDefault());
87
+ el.addEventListener('click', (ev) => {
88
+ const row = ev.target.closest?.('[role="option"]');
89
+ const hit = rows.find((r) => r.row === row);
90
+ if (hit && !hit.option.disabled) onPick(hit.option);
91
+ });
92
+
93
+ return {
94
+ el,
95
+ id,
96
+ /**
97
+ * Draw `sections` (`[{ heading, items }]`, as `choiceSections` gives them).
98
+ * @param {object} [opts]
99
+ * @param {string} [opts.mark] The text to mark in each label.
100
+ * @param {(option: object) => boolean} [opts.isCurrent] Is this the current value?
101
+ */
102
+ setRows(sections, { mark = '', isCurrent = null } = {}) {
103
+ rows = [];
104
+ active = -1;
105
+ el.replaceChildren();
106
+ const glyphs = sections.some((s) => s.items.some((o) => o.icon));
107
+ for (const { heading, items } of sections) {
108
+ let parent = el;
109
+ if (heading != null) {
110
+ parent = node('div', 'twm-option-list__section');
111
+ parent.setAttribute('role', 'group');
112
+ parent.setAttribute('aria-label', heading);
113
+ const title = node('div', 'twm-choice__group', heading);
114
+ title.setAttribute('aria-hidden', 'true');
115
+ parent.appendChild(title);
116
+ el.appendChild(parent);
117
+ }
118
+ for (const option of items) {
119
+ const row = rowFor(option, { glyphs, mark, isCurrent });
120
+ rows.push({ option, row });
121
+ parent.appendChild(row);
122
+ }
123
+ }
124
+ paintActive();
125
+ },
126
+ /** One line of text in place of the rows: nothing matched, still loading, a failure. */
127
+ setMessage(text) {
128
+ rows = [];
129
+ active = -1;
130
+ el.replaceChildren(node('div', 'twm-option-list__message', text));
131
+ paintActive();
132
+ },
133
+ /** How many rows are drawn. */
134
+ get size() { return rows.length; },
135
+ /** The drawn options, in order. */
136
+ options: () => rows.map((r) => r.option),
137
+ /** The active row's option, or null. */
138
+ activeOption: () => rows[active]?.option ?? null,
139
+ /** Make row `index` active; -1, or a row that is disabled, makes none active. */
140
+ setActive(index) {
141
+ active = rows[index] && !rows[index].option.disabled ? index : -1;
142
+ paintActive();
143
+ },
144
+ /** The next row that may be chosen, by `step`, wrapping. From none: the first or the last. */
145
+ move(step) {
146
+ const stops = enabled();
147
+ if (!stops.length) return;
148
+ const at = stops.indexOf(active);
149
+ const next = at < 0 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
150
+ active = stops[next];
151
+ paintActive();
152
+ },
153
+ /** The first (`step` 1) or last (`step` -1) row that may be chosen. */
154
+ moveToEnd(step) {
155
+ const stops = enabled();
156
+ active = stops.length ? stops[step > 0 ? 0 : stops.length - 1] : -1;
157
+ paintActive();
158
+ },
159
+ };
160
+ }