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
@@ -0,0 +1,422 @@
1
+ /**
2
+ * createCombobox — A TEXT BOX THAT ALSO OFFERS A LIST. (0.18.0)
3
+ *
4
+ * `createSelect` picks from a list; a bare `<input>` has nothing to pick from. A
5
+ * value that may be anything but usually is one of a few known ones (a code, a
6
+ * unit, a name) needs both at once.
7
+ *
8
+ * THE BOX HOLDS THE VALUE. The text in the input is what the caller gets back. An
9
+ * option's `label` is what the LIST shows for it, and choosing a row puts the row's
10
+ * `value` in the box. A field whose value is an opaque id with a separate name is
11
+ * `createSelect`'s job: free text into an id is not a feature.
12
+ *
13
+ * THE TYPED TEXT IS REPORTED ON EVERY KEYSTROKE. In a dialog, "type a value, click
14
+ * Save" is the normal gesture, and a control that only reports on blur is one click
15
+ * from saving the previous value. `normalise` is therefore applied to what is
16
+ * reported every time but rewrites the BOX only when the text is settled (on a pick,
17
+ * Enter, Tab or blur), because rewriting an input's value moves the caret. Once, at
18
+ * construction, a value `normalise` changed is reported as well: otherwise the box
19
+ * shows one thing and the caller's model holds another.
20
+ *
21
+ * WHICH ROW ENTER TAKES. `autoHighlight` makes the first surviving row active so
22
+ * Enter completes what was typed, and the list searches the label, the description
23
+ * and `keywords` too. Together those have a wrong answer in them: a well-formed code
24
+ * typed in full can be a substring of another row's description, and Enter would
25
+ * take that row. Whether the typed text is ITSELF a well-formed value separates the
26
+ * two cases, and only the caller knows that shape: `isCandidate`.
27
+ *
28
+ * ESCAPE AND ENTER ARE SWALLOWED WHILE THE LIST IS OPEN, and only then, so they
29
+ * close the list and not the dialog around it. Closed, both bubble untouched.
30
+ *
31
+ * `validate` WARNS, it does not refuse: what is wrong with the settled value is said
32
+ * under the box and the value is still reported.
33
+ */
34
+ import { openAnchoredLayer } from './anchored_layer.js';
35
+ import { fireEvent } from '../utils/fire_event.js';
36
+ import { choiceSections, filterSections, shownValue } from './choice_controls.js';
37
+ import { createOptionList } from './option_list.js';
38
+
39
+ /** Ms between a keystroke and asking a function of options, by default. */
40
+ const DEBOUNCE_MS = 150;
41
+
42
+ let SEQ = 0;
43
+
44
+ /**
45
+ * The list under a text box of your own, and everything that makes it behave: the
46
+ * panel, the filter, async answers, the keys, `aria-*` on the box. `createCombobox`
47
+ * and `createTagInput`'s suggestions are this.
48
+ *
49
+ * `options` is an array (filtered here) or `(query, { signal }) => array | Promise`,
50
+ * which does its own filtering: it is asked after `debounce` ms; an answer that is
51
+ * no longer the latest is dropped, and `signal` aborts when a newer one is asked for.
52
+ *
53
+ * Attach it BEFORE your own `keydown` listeners on the box: with a row active,
54
+ * Enter is taken here and yours do not see it.
55
+ *
56
+ * @param {HTMLInputElement} input
57
+ * @param {object} o
58
+ * @param {Array|Function} o.options
59
+ * @param {(option: object) => void} o.onPick A row was chosen (normalised; the entry
60
+ * as you gave it is `option.source`).
61
+ * @param {HTMLElement} [o.anchor] What the list hangs from; else the box.
62
+ * @param {(option: object) => boolean} [o.isCurrent] Is this row the current value?
63
+ * @param {boolean} [o.autoHighlight] Make a row active as the reader types.
64
+ * @param {(typed: string) => boolean} [o.isCandidate] See the file's header.
65
+ * @param {(text: string) => string} [o.normalise] What a typed text and a row's value are compared as.
66
+ * @param {boolean} [o.openOnFocus] Focus opens the list.
67
+ * @param {boolean} [o.passEnter] Enter with no row active closes the list and is left to
68
+ * your listeners; by default it is swallowed.
69
+ * @param {string} [o.loadingText] While a function of options is answering, with no rows to show.
70
+ * @param {string} [o.errorText] When it failed.
71
+ * @param {number} [o.debounce]
72
+ * @param {string} [o.panelClassName]
73
+ * @param {() => void} [o.onSettle] Enter with no row active, or Tab: the text stays as typed.
74
+ * @returns {{isOpen: boolean, open: Function, close: Function, takeActive: () => boolean,
75
+ * setOptions: Function, destroy: Function}}
76
+ * `takeActive` chooses the active row and says whether there was one, for a key of
77
+ * your own that also means "this one" (the tag input's comma).
78
+ */
79
+ export function attachInputOptions(input, {
80
+ options = [], onPick, anchor = null, isCurrent = null, autoHighlight = false, isCandidate = null,
81
+ normalise = (text) => text, openOnFocus = false, passEnter = false, loadingText = 'Loading…',
82
+ errorText = 'Could not load the options.', debounce = DEBOUNCE_MS, panelClassName = '', onSettle = null,
83
+ } = {}) {
84
+ let source = options;
85
+ let layer = null;
86
+ /** The reader wants the list open; a late answer must not open what they closed. */
87
+ let wanted = false;
88
+ /** `null`: opened without typing, which shows everything. A string filters. */
89
+ let query = null;
90
+ let asked = 0;
91
+ let timer = null;
92
+ let controller = null;
93
+ /** A pick is settling: its `input.focus()` is not a reader asking for the list. */
94
+ let picking = false;
95
+
96
+ const list = createOptionList({
97
+ onPick: (option) => take(option),
98
+ onActive: (id) => {
99
+ if (id) input.setAttribute('aria-activedescendant', id);
100
+ else input.removeAttribute('aria-activedescendant');
101
+ },
102
+ });
103
+ const panel = document.createElement('div');
104
+ panel.className = `twm-option-panel${panelClassName ? ` ${panelClassName}` : ''}`;
105
+ panel.appendChild(list.el);
106
+
107
+ // Not the browser's own autofill list on top of ours.
108
+ input.setAttribute('autocomplete', 'off');
109
+ input.setAttribute('role', 'combobox');
110
+ input.setAttribute('aria-autocomplete', 'list');
111
+ input.setAttribute('aria-expanded', 'false');
112
+ input.setAttribute('aria-controls', list.id);
113
+
114
+ const show = () => {
115
+ if (layer) { layer.place(); return; }
116
+ layer = openAnchoredLayer({
117
+ anchor: anchor || input, el: panel, gap: 4, matchWidth: true, returnFocus: false,
118
+ onClose() {
119
+ layer = null;
120
+ wanted = false;
121
+ input.setAttribute('aria-expanded', 'false');
122
+ input.removeAttribute('aria-activedescendant');
123
+ },
124
+ });
125
+ // The box is not on the page: there is no list, and nothing to come back to.
126
+ if (!layer) { wanted = false; return; }
127
+ input.setAttribute('aria-expanded', 'true');
128
+ };
129
+
130
+ /** Whatever is being asked is no longer wanted. */
131
+ function forget() {
132
+ clearTimeout(timer);
133
+ asked += 1;
134
+ controller?.abort();
135
+ controller = null;
136
+ }
137
+
138
+ function close(reason = 'closed') {
139
+ wanted = false;
140
+ forget();
141
+ layer?.close(reason);
142
+ }
143
+
144
+ /** Which row Enter would take, when `autoHighlight` is on. See the file's header. */
145
+ function highlight(typed) {
146
+ const text = normalise(typed.trim());
147
+ if (!autoHighlight || !text) { list.setActive(-1); return; }
148
+ const exact = list.options().findIndex((o) => normalise(shownValue(o.value)) === text);
149
+ if (exact >= 0) list.setActive(exact);
150
+ else if (isCandidate?.(text)) list.setActive(-1);
151
+ else list.moveToEnd(1);
152
+ }
153
+
154
+ function drawRows(sections) {
155
+ if (!wanted) return;
156
+ // NOTHING TO SUGGEST, NOTHING TO SHOW. An empty panel under the box reads as a
157
+ // broken control; an unlisted value is fine. Still wanted: the next answer may have rows.
158
+ if (!sections.length) {
159
+ layer?.close('empty');
160
+ wanted = true;
161
+ return;
162
+ }
163
+ const text = query ?? '';
164
+ list.setRows(sections, { mark: normalise(text.trim()), isCurrent });
165
+ show();
166
+ highlight(text);
167
+ }
168
+
169
+ function ask(text) {
170
+ controller = new AbortController();
171
+ const token = asked;
172
+ const failed = (err) => {
173
+ if (token !== asked) return;
174
+ console.warn('[createCombobox] options failed', err);
175
+ if (!wanted) return;
176
+ list.setMessage(errorText);
177
+ show();
178
+ };
179
+ let answer;
180
+ try { answer = source(text, { signal: controller.signal }); }
181
+ catch (err) { failed(err); return; }
182
+ if (typeof answer?.then !== 'function') { drawRows(choiceSections(answer)); return; }
183
+ // Rows already drawn stay until the answer, so the list does not flicker per keystroke.
184
+ if (!list.size && wanted) { list.setMessage(loadingText); show(); }
185
+ answer.then((rows) => { if (token === asked) drawRows(choiceSections(rows)); }, failed);
186
+ }
187
+
188
+ /** The list, for the current query. */
189
+ function refresh() {
190
+ forget();
191
+ if (typeof source !== 'function') {
192
+ const all = choiceSections(source);
193
+ drawRows(query === null ? all : filterSections(all, query));
194
+ return;
195
+ }
196
+ const text = query ?? '';
197
+ if (query === null || debounce <= 0) { ask(text); return; }
198
+ const token = asked;
199
+ timer = setTimeout(() => { if (token === asked) ask(text); }, debounce);
200
+ }
201
+
202
+ function take(option) {
203
+ close('pick');
204
+ picking = true;
205
+ onPick?.(option);
206
+ picking = false;
207
+ }
208
+
209
+ /** The active row is chosen, if the list is open and has one. */
210
+ function takeActive() {
211
+ const option = layer && list.activeOption();
212
+ if (!option) return false;
213
+ take(option);
214
+ return true;
215
+ }
216
+
217
+ const openFor = (text) => { query = text; wanted = true; refresh(); };
218
+
219
+ const onInput = () => openFor(input.value);
220
+ const onClick = () => openFor(null);
221
+ const onFocus = () => { if (openOnFocus && !layer && !picking) openFor(input.value || null); };
222
+ const onBlur = () => close('blur');
223
+ const onKey = (ev) => {
224
+ if (ev.isComposing) return;
225
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
226
+ ev.preventDefault();
227
+ ev.stopPropagation();
228
+ if (!layer) openFor(null);
229
+ list.move(ev.key === 'ArrowDown' ? 1 : -1);
230
+ return;
231
+ }
232
+ if (ev.key === 'Enter') {
233
+ if (!layer) return; // the dialog's Enter, untouched
234
+ if (takeActive()) {
235
+ ev.preventDefault();
236
+ ev.stopImmediatePropagation();
237
+ return;
238
+ }
239
+ close();
240
+ if (passEnter) return;
241
+ ev.preventDefault();
242
+ ev.stopPropagation();
243
+ onSettle?.();
244
+ return;
245
+ }
246
+ if (ev.key === 'Tab') {
247
+ close('blur');
248
+ onSettle?.();
249
+ }
250
+ };
251
+
252
+ input.addEventListener('input', onInput);
253
+ input.addEventListener('click', onClick);
254
+ input.addEventListener('focus', onFocus);
255
+ input.addEventListener('blur', onBlur);
256
+ input.addEventListener('keydown', onKey);
257
+
258
+ return {
259
+ get isOpen() { return !!layer; },
260
+ /** Open it showing everything, or for `text`. */
261
+ open(text = null) { openFor(text); },
262
+ close,
263
+ takeActive,
264
+ /** Replace the options; an open list redraws. */
265
+ setOptions(next) {
266
+ source = next;
267
+ if (wanted) refresh();
268
+ },
269
+ destroy() {
270
+ close();
271
+ input.removeEventListener('input', onInput);
272
+ input.removeEventListener('click', onClick);
273
+ input.removeEventListener('focus', onFocus);
274
+ input.removeEventListener('blur', onBlur);
275
+ input.removeEventListener('keydown', onKey);
276
+ },
277
+ };
278
+ }
279
+
280
+ /**
281
+ * @param {object} config
282
+ * @param {Array|Function} [config.options] What `createSelect` takes: the list is a
283
+ * SUGGESTION, never a constraint. Or `(query, { signal }) => array | Promise` for
284
+ * options found as the reader types: it filters them itself, and is asked with `''`
285
+ * when the list opens without typing.
286
+ * @param {string} [config.value]
287
+ * @param {(value: string, option: object|null) => void} [config.onChange] On every
288
+ * keystroke and pick, and once at construction when `normalise` changed the value it
289
+ * was handed. `option` is the entry as you gave it when one was picked, else `null`.
290
+ * @param {string} [config.className] Extra classes on the root.
291
+ * @param {string} [config.panelClassName]
292
+ * @param {string} [config.placeholder]
293
+ * @param {string} [config.title] The input's tooltip.
294
+ * @param {string} [config.ariaLabel] When no `<label>` names the control.
295
+ * @param {string} [config.name] The input's name.
296
+ * @param {boolean} [config.disabled]
297
+ * @param {(text: string) => string} [config.normalise] Upper-casing a code, say.
298
+ * @param {boolean} [config.autoHighlight=true] See the file's header.
299
+ * @param {(typed: string) => boolean} [config.isCandidate] See the file's header.
300
+ * @param {(value: string) => string|null} [config.validate] What is wrong with the settled value.
301
+ * @param {string} [config.loadingText]
302
+ * @param {string} [config.errorText]
303
+ * @param {number} [config.debounce=150] Ms before a function of options is asked.
304
+ * @returns {{el: HTMLElement, input: HTMLInputElement, value: string, error: string|null,
305
+ * isOpen: boolean, setOptions: Function, open: Function, close: Function,
306
+ * focus: Function, destroy: Function}}
307
+ */
308
+ export function createCombobox({
309
+ options = [], value = '', onChange = null, className = '', panelClassName = '', placeholder = '', title = '',
310
+ ariaLabel = '', name = null, disabled = false, normalise = (text) => text, autoHighlight = true, isCandidate = null,
311
+ validate = null, loadingText, errorText, debounce,
312
+ } = {}) {
313
+ const handed = String(value ?? '');
314
+ let current = normalise(handed);
315
+
316
+ const el = document.createElement('div');
317
+ el.className = `twm-combobox${className ? ` ${className}` : ''}`;
318
+
319
+ const input = document.createElement('input');
320
+ input.type = 'text';
321
+ input.className = 'twm-combobox__input';
322
+ input.spellcheck = false;
323
+ input.value = current;
324
+ input.disabled = !!disabled;
325
+ if (name) input.name = name;
326
+ if (placeholder) input.placeholder = placeholder;
327
+ if (title) input.title = title;
328
+ if (ariaLabel) input.setAttribute('aria-label', ariaLabel);
329
+
330
+ const error = document.createElement('p');
331
+ error.className = 'twm-combobox__error';
332
+ error.hidden = true;
333
+ error.id = `twm-combobox-error-${++SEQ}`;
334
+ el.append(input, error);
335
+
336
+ function report(next, option) {
337
+ const settled = normalise(String(next ?? ''));
338
+ if (settled === current) return;
339
+ current = settled;
340
+ onChange?.(current, option ?? null);
341
+ }
342
+
343
+ const clearValidity = () => {
344
+ error.hidden = true;
345
+ error.textContent = '';
346
+ input.removeAttribute('aria-invalid');
347
+ input.removeAttribute('aria-describedby');
348
+ };
349
+
350
+ /** The complaint appears when the text is settled: "too short" is noise mid-word. */
351
+ function paintValidity() {
352
+ const message = validate && current ? (validate(current) || null) : null;
353
+ if (!message) { clearValidity(); return; }
354
+ error.textContent = message;
355
+ error.hidden = false;
356
+ input.setAttribute('aria-invalid', 'true');
357
+ input.setAttribute('aria-describedby', error.id);
358
+ }
359
+
360
+ /** Put the canonical text in the box. Only when it is settled: see the file's header. */
361
+ function settle() {
362
+ report(input.value, null);
363
+ if (input.value !== current) input.value = current;
364
+ paintValidity();
365
+ }
366
+
367
+ // Before the list's own listener: it marks the current value in the rows it draws.
368
+ input.addEventListener('input', () => {
369
+ // EVERY KEYSTROKE REACHES THE CALLER. See the file's header.
370
+ report(input.value, null);
371
+ clearValidity();
372
+ });
373
+
374
+ const menu = attachInputOptions(input, {
375
+ options,
376
+ autoHighlight,
377
+ isCandidate,
378
+ normalise,
379
+ panelClassName,
380
+ loadingText,
381
+ errorText,
382
+ debounce,
383
+ isCurrent: (o) => normalise(shownValue(o.value)) === current,
384
+ onSettle: settle,
385
+ onPick(option) {
386
+ input.value = shownValue(option.value);
387
+ report(option.value, option.source);
388
+ settle();
389
+ input.focus();
390
+ // A value set by script fires nothing; a surrounding form listens for this.
391
+ fireEvent(input, 'change', { bubbles: true });
392
+ },
393
+ });
394
+
395
+ input.addEventListener('blur', settle);
396
+
397
+ paintValidity();
398
+ if (onChange && current !== handed) onChange(current, null);
399
+
400
+ return {
401
+ el,
402
+ input,
403
+ get value() { return current; },
404
+ set value(next) {
405
+ current = normalise(String(next ?? ''));
406
+ input.value = current;
407
+ paintValidity();
408
+ },
409
+ /** The complaint on screen, or `null`. A caller that wants to refuse Save has this. */
410
+ get error() { return error.hidden ? null : error.textContent; },
411
+ get isOpen() { return menu.isOpen; },
412
+ setOptions: menu.setOptions,
413
+ /** Open the list showing everything, or for `text`. */
414
+ open: menu.open,
415
+ close: () => menu.close(),
416
+ focus() { input.focus(); },
417
+ destroy() {
418
+ menu.destroy();
419
+ el.remove();
420
+ },
421
+ };
422
+ }
@@ -37,6 +37,7 @@ import { placeMenuInViewport, moveMenuFocus, focusMenuItem } from '../utils/menu
37
37
 
38
38
  let _activeMenu = null;
39
39
  let _returnFocusTo = null;
40
+ let _armTimer = null;
40
41
 
41
42
  export function showContextMenu(x, y, items, onAction) {
42
43
  hideContextMenu();
@@ -104,8 +105,11 @@ export function showContextMenu(x, y, items, onAction) {
104
105
 
105
106
  // Close on outside click / scroll / Escape. A scroll INSIDE the menu is
106
107
  // the menu being read, not the page moving under it.
107
- setTimeout(() => {
108
- document.addEventListener('mousedown', _outsideHandler, { once: true, capture: true });
108
+ // Armed a tick later, so the press that opened it is not its first outside
109
+ // press, and held until the menu closes: a press inside (a separator, the
110
+ // padding, a disabled row) closes nothing and must leave the next one heard.
111
+ _armTimer = setTimeout(() => {
112
+ document.addEventListener('mousedown', _outsideHandler, { capture: true });
109
113
  }, 0);
110
114
  document.addEventListener('keydown', _keyHandler);
111
115
  window.addEventListener('scroll', _scrollHandler, { capture: true });
@@ -122,6 +126,8 @@ export function hideContextMenu() {
122
126
  _activeMenu.remove();
123
127
  _activeMenu = null;
124
128
  _returnFocusTo = null;
129
+ clearTimeout(_armTimer);
130
+ document.removeEventListener('mousedown', _outsideHandler, { capture: true });
125
131
  document.removeEventListener('keydown', _keyHandler);
126
132
  window.removeEventListener('scroll', _scrollHandler, { capture: true });
127
133
  // Only take focus back if the menu still had it. A click elsewhere has
@@ -18,8 +18,8 @@
18
18
  *
19
19
  * MESSAGES are `{ id?, role, text, meta? }`. `id` is assigned when absent;
20
20
  * `role` becomes a modifier class (`twm-chat__msg--<role>`); `meta` is the
21
- * caller's and is handed back untouched. `append`, `replaceAll` and `clear`
22
- * change the list; `messages()` reads it. An id already in the transcript (or
21
+ * caller's and is handed back untouched. `append`, `remove`, `replaceAll` and
22
+ * `clear` change the list; `messages()` reads it. An id already in the transcript (or
23
23
  * repeated within one `replaceAll`) is refused with a throw, and a refused
24
24
  * call changes nothing. Each message has an EXTRA SLOT
25
25
  * (`slot(id)`), an element below the body the caller fills with whatever
@@ -279,6 +279,20 @@ export function mountConversationView(host, {
279
279
  return entry.message.id;
280
280
  }
281
281
 
282
+ /**
283
+ * Takes one message out and leaves every other one, with its slot, as it is.
284
+ * An open stream on that message is over. False for an unknown id.
285
+ */
286
+ function remove(id) {
287
+ const entry = entries.get(id);
288
+ if (!entry) return false;
289
+ if (streamId === id) streamId = null;
290
+ entries.delete(id);
291
+ entry.row.remove();
292
+ settle();
293
+ return true;
294
+ }
295
+
282
296
  /** All or nothing: a repeated id throws before the transcript is touched. No list (undefined or null) empties it. */
283
297
  function replaceAll(messages) {
284
298
  const list = [...(messages ?? [])];
@@ -396,6 +410,7 @@ export function mountConversationView(host, {
396
410
  return {
397
411
  root,
398
412
  append,
413
+ remove,
399
414
  replaceAll,
400
415
  clear,
401
416
  beginStream,
@@ -4486,7 +4486,7 @@ export class DataTable {
4486
4486
 
4487
4487
  // ALL THREE PROPERTIES, not just `width`. `_setCellWidth` writes
4488
4488
  // `width` together with `min-width: 0` and `max-width: none`, precisely
4489
- // to escape the CSS floors on these cells (base.css pins the first
4489
+ // to escape the CSS floors on these cells (flexdesk.css pins the first
4490
4490
  // column at 120px/min 100px and gives every other column a 80px min).
4491
4491
  // Carrying the width across the rebuild and leaving the other two behind
4492
4492
  // let those floors back in — so a column measured narrower than its
@@ -6329,7 +6329,7 @@ export class DataTable {
6329
6329
  //
6330
6330
  // `_measureNaturalWidths` flips BOTH tables to
6331
6331
  // `table-layout:auto; width:max-content` with the CSS caps lifted, for
6332
- // a full synchronous reflow (`.twm-dt-measuring`, css/base.css). This
6332
+ // a full synchronous reflow (`.twm-dt-measuring`, css/flexdesk.css). This
6333
6333
  // method is the ResizeObserver's callback, so that reflow fires once
6334
6334
  // per animation frame for the whole of a splitter drag — and an
6335
6335
  // embedder that pages a thousand rows at a time across twenty columns