flexdesk 0.17.15 → 0.17.17

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 (49) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/css/base.css +236 -5
  3. package/css/flexdesk.css +326 -13
  4. package/css/tokens.css +3 -0
  5. package/dist/charts.js +104 -34
  6. package/dist/charts.js.map +3 -3
  7. package/dist/{chunk-2PM6ZMNS.js → chunk-2Z2ENHR3.js} +2 -2
  8. package/dist/{chunk-AD7FIA3Z.js → chunk-7JI7HHSY.js} +2 -2
  9. package/dist/{chunk-TLIV6QSU.js → chunk-7OL6F2AM.js} +3 -3
  10. package/dist/{chunk-MADMGRX2.js → chunk-PGZ4EHSE.js} +156 -9
  11. package/dist/chunk-PGZ4EHSE.js.map +7 -0
  12. package/dist/{chunk-OEG6NPAN.js → chunk-XJNADRGR.js} +2 -2
  13. package/dist/flexdesk.css +326 -13
  14. package/dist/flow.js +3 -3
  15. package/dist/tiles.js +84 -9
  16. package/dist/tiles.js.map +2 -2
  17. package/dist/tokens.css +3 -0
  18. package/dist/widgets.js +4559 -4091
  19. package/dist/widgets.js.map +4 -4
  20. package/dist/wm.js +212 -40
  21. package/dist/wm.js.map +2 -2
  22. package/docs/README.md +1 -1
  23. package/docs/components/charts.md +70 -3
  24. package/docs/components/forms-and-dialogs.md +61 -0
  25. package/docs/components/menus.md +85 -1
  26. package/docs/components/notifications.md +23 -1
  27. package/docs/components/panels-and-desktops.md +22 -0
  28. package/docs/components/shell.md +91 -0
  29. package/docs/components/tiles.md +37 -0
  30. package/package.json +1 -1
  31. package/src/charts/plot_config_editor.js +125 -29
  32. package/src/tiles/tile_base.js +48 -2
  33. package/src/tiles/tile_grid.js +42 -1
  34. package/src/tiling/kind_taxonomy.js +66 -13
  35. package/src/tiling/shell.js +4 -1
  36. package/src/tiling/wm.js +205 -35
  37. package/src/ui/components/category_picker.js +556 -0
  38. package/src/ui/components/computing_status_window.js +3 -0
  39. package/src/ui/components/confirm_dialog.js +3 -0
  40. package/src/ui/components/managed_window.js +204 -9
  41. package/src/ui/components/notification_history.js +11 -1
  42. package/src/ui/components/wizard.js +4 -1
  43. package/src/ui/notification_center.js +47 -1
  44. package/widgets.js +2 -1
  45. package/dist/chunk-MADMGRX2.js.map +0 -7
  46. /package/dist/{chunk-2PM6ZMNS.js.map → chunk-2Z2ENHR3.js.map} +0 -0
  47. /package/dist/{chunk-AD7FIA3Z.js.map → chunk-7JI7HHSY.js.map} +0 -0
  48. /package/dist/{chunk-TLIV6QSU.js.map → chunk-7OL6F2AM.js.map} +0 -0
  49. /package/dist/{chunk-OEG6NPAN.js.map → chunk-XJNADRGR.js.map} +0 -0
@@ -0,0 +1,556 @@
1
+ /**
2
+ * category_picker.js
3
+ *
4
+ * A categorised picker popover (STORY-0126): a search field over three
5
+ * columns — the categories (colour dot, label, item count), the active
6
+ * category's items (icon, label) and a detail pane for the highlighted item
7
+ * (title, description, an optional monospace preview, an Add button).
8
+ *
9
+ * ┌──────────────────────────────────────────────────────────┐
10
+ * │ [ Search… ] │
11
+ * ├──────────────┬──────────────────┬────────────────────────┤
12
+ * │ ● Text 3 │ [H] Heading │ Delay / Lag │
13
+ * │ ● Model 5 │ [Δt] Delay / Lag │ The value of an │
14
+ * │ ● Functions 6│ [Tb] Schedule │ expression some time… │
15
+ * │ │ │ ┌────────────────────┐ │
16
+ * │ │ │ │ lag(inflation, 2) │ │
17
+ * │ │ │ └────────────────────┘ │
18
+ * │ │ │ [Add] │
19
+ * └──────────────┴──────────────────┴────────────────────────┘
20
+ *
21
+ * Usage:
22
+ * ```javascript
23
+ * const picker = new CategoryPicker({
24
+ * trigger: addButton,
25
+ * categories: [
26
+ * { id: 'text', label: 'Text', color: 'var(--accent-light)', items: [
27
+ * { id: 'heading', label: 'Heading', badge: 'H',
28
+ * description: 'A section heading.', preview: '## Debt dynamics' },
29
+ * ] },
30
+ * ],
31
+ * onSelect: (item, category) => insert(category.id, item.id),
32
+ * });
33
+ * picker.open(); picker.close(); picker.toggle(); picker.destroy();
34
+ * ```
35
+ *
36
+ * It is anchored and dismissed like `ActionDropdown`: the trigger's click
37
+ * toggles it, it is placed by `ActionDropdown.position` (position: fixed,
38
+ * under the trigger or flipped above it, kept 8px inside the window), drawn
39
+ * on `--z-over-windows`, and an outside click or Escape closes it. Escape and
40
+ * a pick give focus back to the trigger.
41
+ *
42
+ * Keyboard. Focus sits in the search field; typing filters. With no query,
43
+ * Up/Down move within the current column (the categories, or the active
44
+ * category's items) and Left/Right move between the two. With a query, the
45
+ * items column is a flat hit list across every category, grouped under the
46
+ * category's label; Up/Down walk it and Left/Right edit the query. Enter
47
+ * picks the highlighted item, Escape closes. A double-click on an item picks
48
+ * it; hovering or clicking one shows it in the detail pane.
49
+ */
50
+ import { ActionDropdown } from './action_dropdown.js';
51
+
52
+ let _seq = 0;
53
+
54
+ /** Lower-cased words of a query; empty for a blank one. */
55
+ function _terms(query) {
56
+ return String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
57
+ }
58
+
59
+ /** What a query is matched against: the label, the description, the keywords. */
60
+ function _haystack(item) {
61
+ const kw = Array.isArray(item.keywords) ? item.keywords.join(' ') : (item.keywords || '');
62
+ return `${item.label || ''} ${item.description || ''} ${kw}`.toLowerCase();
63
+ }
64
+
65
+ function _el(tag, className, text) {
66
+ const n = document.createElement(tag);
67
+ if (className) n.className = className;
68
+ if (text != null) n.textContent = text;
69
+ return n;
70
+ }
71
+
72
+ export class CategoryPicker {
73
+ /**
74
+ * @param {Object} config
75
+ * @param {HTMLElement} config.trigger - The element the picker opens from; its click toggles it
76
+ * @param {Array} config.categories - `[{ id, label, color?, items: [{ id, label, icon?, badge?,
77
+ * description?, preview?, keywords? }] }]`. `icon` is a Material Symbols ligature; `badge` a
78
+ * short text drawn as a monospace badge in the category's colour; `preview` is text shown
79
+ * monospace in the detail pane; `keywords` (array or string) are searched but not shown.
80
+ * @param {Function} config.onSelect - `(item, category)` when an item is picked
81
+ * @param {string} [config.className=''] - Additional CSS class for the popover
82
+ * @param {string} [config.placeholder='Search…'] - The search field's placeholder
83
+ * @param {string} [config.addLabel='Add'] - The detail pane's button
84
+ * @param {string} [config.previewLabel] - A caption over the preview block (none when omitted)
85
+ * @param {'start'|'end'} [config.align='start'] - Which trigger edge it lines up with first
86
+ * @param {string} [config.label='Pick'] - The popover's accessible name (its `aria-label`)
87
+ */
88
+ constructor(config) {
89
+ this.trigger = config.trigger;
90
+ this.categories = config.categories || [];
91
+ this.onSelect = config.onSelect;
92
+ this.className = config.className || '';
93
+ this.placeholder = config.placeholder ?? 'Search…';
94
+ this.addLabel = config.addLabel ?? 'Add';
95
+ this.previewLabel = config.previewLabel || '';
96
+ this.align = config.align === 'end' ? 'end' : 'start';
97
+ this.label = config.label || 'Pick';
98
+
99
+ this.isOpen = false;
100
+ this.el = null;
101
+
102
+ this._id = `twm-category-picker-${++_seq}`;
103
+ this._query = '';
104
+ this._activeCat = 0; // index of the category whose items show (no query)
105
+ this._column = 'items'; // 'categories' | 'items' — where Up/Down go
106
+ this._hl = null; // { c, i } — the highlighted item
107
+ this._rows = []; // the items column's rows, in order: { c, i, el }
108
+
109
+ this._onDocClick = this._onDocClick.bind(this);
110
+ this._onKeydown = this._onKeydown.bind(this);
111
+ this._onTriggerClick = this._onTriggerClick.bind(this);
112
+
113
+ if (!this.trigger) {
114
+ console.warn('[CategoryPicker] No trigger element provided');
115
+ return;
116
+ }
117
+ this._build();
118
+ this.trigger.setAttribute('aria-haspopup', 'dialog');
119
+ this.trigger.setAttribute('aria-expanded', 'false');
120
+ this.trigger.addEventListener('click', this._onTriggerClick);
121
+ }
122
+
123
+ // ── DOM ────────────────────────────────────────────────────────────────
124
+
125
+ _build() {
126
+ const root = _el('div', `twm-category-picker ${this.className}`.trim());
127
+ root.setAttribute('role', 'dialog');
128
+ root.setAttribute('aria-label', this.label);
129
+ root.hidden = true;
130
+ // FOCUS STAYS IN THE SEARCH FIELD. A press on anything but the field
131
+ // (a row, a group header, the detail text, padding, a disabled button)
132
+ // would otherwise move focus to <body>, outside the popover, and the
133
+ // arrow keys, Enter and typing would stop reaching it while it is
134
+ // open. The click still happens; only the focus move is cancelled
135
+ // (which also means the preview's text cannot be drag-selected).
136
+ root.addEventListener('mousedown', (e) => {
137
+ if (e.target !== this._input) e.preventDefault();
138
+ });
139
+
140
+ const search = _el('div', 'twm-category-picker__search');
141
+ const input = _el('input', 'twm-category-picker__input');
142
+ input.type = 'search';
143
+ input.placeholder = this.placeholder;
144
+ input.setAttribute('aria-label', this.placeholder || 'Search');
145
+ input.setAttribute('role', 'combobox');
146
+ input.setAttribute('aria-expanded', 'true');
147
+ input.setAttribute('aria-autocomplete', 'list');
148
+ input.setAttribute('aria-controls', `${this._id}-items`);
149
+ input.autocomplete = 'off';
150
+ input.spellcheck = false;
151
+ input.addEventListener('input', () => this._setQuery(input.value));
152
+ search.appendChild(input);
153
+
154
+ const body = _el('div', 'twm-category-picker__body');
155
+ const cats = _el('div', 'twm-category-picker__categories twm-category-picker__column');
156
+ cats.id = `${this._id}-cats`;
157
+ cats.setAttribute('role', 'listbox');
158
+ cats.setAttribute('aria-label', 'Categories');
159
+ const items = _el('div', 'twm-category-picker__items twm-category-picker__column');
160
+ items.id = `${this._id}-items`;
161
+ items.setAttribute('role', 'listbox');
162
+ items.setAttribute('aria-label', 'Items');
163
+ // No aria-live: the input's aria-activedescendant already announces
164
+ // each move, and a live region would read the pane on every one.
165
+ const detail = _el('div', 'twm-category-picker__detail');
166
+ body.append(cats, items, detail);
167
+
168
+ root.append(search, body);
169
+ document.body.appendChild(root);
170
+
171
+ this.el = root;
172
+ this._input = input;
173
+ this._catsEl = cats;
174
+ this._itemsEl = items;
175
+ this._detailEl = detail;
176
+ this._render();
177
+ }
178
+
179
+ /** The items column's entries: the active category's items, or every hit for the query. */
180
+ _visible() {
181
+ const terms = _terms(this._query);
182
+ if (!terms.length) {
183
+ const cat = this.categories[this._activeCat];
184
+ return (cat?.items || []).map((_, i) => ({ c: this._activeCat, i }));
185
+ }
186
+ const out = [];
187
+ this.categories.forEach((cat, c) => (cat.items || []).forEach((item, i) => {
188
+ const hay = _haystack(item);
189
+ if (terms.every((t) => hay.includes(t))) out.push({ c, i });
190
+ }));
191
+ return out;
192
+ }
193
+
194
+ /** Rebuild both lists and the detail pane. Hover never calls this. */
195
+ _render() {
196
+ if (!this.el) return;
197
+ const searching = _terms(this._query).length > 0;
198
+ const visible = this._visible();
199
+ const hits = new Map();
200
+ for (const v of visible) hits.set(v.c, (hits.get(v.c) || 0) + 1);
201
+
202
+ // Keep the highlight when it is still listed, else the first entry.
203
+ if (!this._hl || !visible.some((v) => v.c === this._hl.c && v.i === this._hl.i)) {
204
+ this._hl = visible[0] ? { ...visible[0] } : null;
205
+ }
206
+ if (searching && this._hl) this._activeCat = this._hl.c;
207
+
208
+ // Categories.
209
+ this._catsEl.replaceChildren();
210
+ this.categories.forEach((cat, c) => {
211
+ const btn = _el('button', 'twm-category-picker__category');
212
+ btn.type = 'button';
213
+ btn.tabIndex = -1;
214
+ btn.id = `${this._id}-cat-${c}`;
215
+ btn.setAttribute('role', 'option');
216
+ btn.dataset.index = String(c);
217
+ if (cat.color) btn.style.setProperty('--twm-category-picker-color', cat.color);
218
+ const count = searching ? (hits.get(c) || 0) : (cat.items || []).length;
219
+ if (searching && !count) btn.classList.add('twm-category-picker__category--empty');
220
+ btn.append(
221
+ _el('span', 'twm-category-picker__dot'),
222
+ _el('span', 'twm-category-picker__category-label', cat.label),
223
+ _el('span', 'twm-category-picker__count', String(count)),
224
+ );
225
+ btn.addEventListener('click', (e) => {
226
+ e.preventDefault();
227
+ this._column = 'categories';
228
+ this._chooseCategory(c);
229
+ this._input.focus();
230
+ });
231
+ this._catsEl.appendChild(btn);
232
+ });
233
+
234
+ // Items (or hits: each category's in a role="group" named by its header).
235
+ this._itemsEl.replaceChildren();
236
+ this._rows = [];
237
+ let lastCat = -1;
238
+ let into = this._itemsEl;
239
+ for (const v of visible) {
240
+ const cat = this.categories[v.c];
241
+ const item = cat.items[v.i];
242
+ if (searching && v.c !== lastCat) {
243
+ const header = _el('div', 'twm-category-picker__group');
244
+ header.id = `${this._id}-group-${v.c}`;
245
+ if (cat.color) header.style.setProperty('--twm-category-picker-color', cat.color);
246
+ header.append(_el('span', 'twm-category-picker__dot'), _el('span', null, cat.label));
247
+ into = _el('div', 'twm-category-picker__hits');
248
+ into.setAttribute('role', 'group');
249
+ into.setAttribute('aria-labelledby', header.id);
250
+ into.appendChild(header);
251
+ this._itemsEl.appendChild(into);
252
+ lastCat = v.c;
253
+ }
254
+ const btn = _el('button', 'twm-category-picker__item');
255
+ btn.type = 'button';
256
+ btn.tabIndex = -1;
257
+ btn.id = `${this._id}-item-${v.c}-${v.i}`;
258
+ btn.setAttribute('role', 'option');
259
+ btn.dataset.category = String(v.c);
260
+ btn.dataset.index = String(v.i);
261
+ if (cat.color) btn.style.setProperty('--twm-category-picker-color', cat.color);
262
+ if (item.icon) {
263
+ btn.appendChild(_el('span', 'material-symbols-outlined twm-category-picker__icon', item.icon));
264
+ } else {
265
+ btn.appendChild(_el('span', 'twm-category-picker__badge', item.badge ?? ''));
266
+ }
267
+ btn.appendChild(_el('span', 'twm-category-picker__item-label', item.label));
268
+ btn.addEventListener('mouseenter', () => this._highlight(v));
269
+ btn.addEventListener('click', (e) => {
270
+ e.preventDefault();
271
+ this._column = 'items';
272
+ this._highlight(v);
273
+ this._input.focus();
274
+ });
275
+ btn.addEventListener('dblclick', (e) => {
276
+ e.preventDefault();
277
+ this._pick(v);
278
+ });
279
+ this._rows.push({ ...v, el: btn });
280
+ into.appendChild(btn);
281
+ }
282
+ if (!visible.length) {
283
+ this._itemsEl.appendChild(_el('div', 'twm-category-picker__empty',
284
+ searching ? `Nothing matches “${this._query.trim()}”.` : 'Nothing here.'));
285
+ }
286
+
287
+ this._paintState();
288
+ }
289
+
290
+ /** Classes and ARIA for the active category, the highlight and the current column; the detail pane. */
291
+ _paintState() {
292
+ const searching = _terms(this._query).length > 0;
293
+ [...this._catsEl.children].forEach((btn, c) => {
294
+ const on = c === this._activeCat;
295
+ btn.classList.toggle('twm-category-picker__category--active', on);
296
+ btn.setAttribute('aria-selected', on ? 'true' : 'false');
297
+ });
298
+ for (const r of this._rows) {
299
+ const on = !!this._hl && r.c === this._hl.c && r.i === this._hl.i;
300
+ r.el.classList.toggle('twm-category-picker__item--highlighted', on);
301
+ r.el.setAttribute('aria-selected', on ? 'true' : 'false');
302
+ }
303
+ const column = searching ? 'items' : this._column;
304
+ this._catsEl.classList.toggle('twm-category-picker__column--current', column === 'categories');
305
+ this._itemsEl.classList.toggle('twm-category-picker__column--current', column === 'items');
306
+
307
+ const hlRow = this._rows.find((r) => this._hl && r.c === this._hl.c && r.i === this._hl.i);
308
+ const activeCatEl = this._catsEl.children[this._activeCat];
309
+ // The input names the option the arrow keys are on: the active
310
+ // category while the categories column is current, else the highlight.
311
+ const current = column === 'categories' ? activeCatEl : hlRow?.el;
312
+ this._input.setAttribute('aria-controls', column === 'categories' ? this._catsEl.id : this._itemsEl.id);
313
+ if (current) this._input.setAttribute('aria-activedescendant', current.id);
314
+ else this._input.removeAttribute('aria-activedescendant');
315
+ hlRow?.el.scrollIntoView?.({ block: 'nearest' });
316
+ activeCatEl?.scrollIntoView?.({ block: 'nearest' });
317
+
318
+ this._renderDetail();
319
+ }
320
+
321
+ _renderDetail() {
322
+ const d = this._detailEl;
323
+ d.replaceChildren();
324
+ const item = this._hl ? this.categories[this._hl.c]?.items?.[this._hl.i] : null;
325
+ if (item) {
326
+ d.appendChild(_el('div', 'twm-category-picker__title', item.label));
327
+ if (item.description) d.appendChild(_el('div', 'twm-category-picker__description', item.description));
328
+ if (item.preview != null && item.preview !== '') {
329
+ if (this.previewLabel) d.appendChild(_el('div', 'twm-category-picker__preview-label', this.previewLabel));
330
+ d.appendChild(_el('pre', 'twm-category-picker__preview', String(item.preview)));
331
+ }
332
+ } else {
333
+ d.appendChild(_el('div', 'twm-category-picker__detail-empty', 'Nothing selected.'));
334
+ }
335
+ const add = _el('button', 'twm-btn twm-btn--primary twm-category-picker__add', this.addLabel);
336
+ add.type = 'button';
337
+ add.disabled = !item;
338
+ add.addEventListener('click', (e) => {
339
+ e.preventDefault();
340
+ if (this._hl) this._pick(this._hl);
341
+ });
342
+ d.appendChild(add);
343
+ }
344
+
345
+ // ── State changes ──────────────────────────────────────────────────────
346
+
347
+ _setQuery(q) {
348
+ this._query = String(q || '');
349
+ if (_terms(this._query).length) {
350
+ // A new query highlights its first hit.
351
+ this._hl = null;
352
+ } else if (this._hl) {
353
+ // Back to browsing: the highlight's category is the one shown.
354
+ this._activeCat = this._hl.c;
355
+ }
356
+ this._render();
357
+ }
358
+
359
+ _highlight(v) {
360
+ if (!v) return;
361
+ this._hl = { c: v.c, i: v.i };
362
+ if (_terms(this._query).length) this._activeCat = v.c;
363
+ this._paintState();
364
+ }
365
+
366
+ /** Make category `c` the active one. Browsing: show its items. Searching: jump to its first hit. */
367
+ _chooseCategory(c) {
368
+ if (c < 0 || c >= this.categories.length) return;
369
+ if (_terms(this._query).length) {
370
+ const first = this._rows.find((r) => r.c === c);
371
+ if (first) this._highlight(first);
372
+ return;
373
+ }
374
+ this._activeCat = c;
375
+ this._hl = (this.categories[c].items || []).length ? { c, i: 0 } : null;
376
+ this._render();
377
+ }
378
+
379
+ _moveHighlight(dir) {
380
+ if (!this._rows.length) return;
381
+ let at = this._rows.findIndex((r) => this._hl && r.c === this._hl.c && r.i === this._hl.i);
382
+ at = at < 0 ? (dir > 0 ? 0 : this._rows.length - 1)
383
+ : (at + dir + this._rows.length) % this._rows.length;
384
+ this._highlight(this._rows[at]);
385
+ }
386
+
387
+ _pick(v) {
388
+ const category = this.categories[v.c];
389
+ const item = category?.items?.[v.i];
390
+ if (!item) return;
391
+ const onSelect = this.onSelect;
392
+ this.close();
393
+ this.trigger?.focus();
394
+ onSelect?.(item, category);
395
+ }
396
+
397
+ // ── Events ─────────────────────────────────────────────────────────────
398
+
399
+ _onTriggerClick(e) {
400
+ e.preventDefault();
401
+ e.stopPropagation();
402
+ this.toggle();
403
+ }
404
+
405
+ _onDocClick(e) {
406
+ if (!this.isOpen) return;
407
+ if (this.el?.contains(e.target) || this.trigger?.contains(e.target)) return;
408
+ this.close();
409
+ }
410
+
411
+ _onKeydown(e) {
412
+ if (!this.isOpen) return;
413
+ // An IME composing a character owns Enter and Escape.
414
+ if (e.isComposing || e.keyCode === 229) return;
415
+ if (e.key === 'Escape') {
416
+ // Capture phase, and stopped: an open picker is the innermost
417
+ // thing on screen, so Escape closes it and not the ManagedWindow
418
+ // around it (ActionDropdown's rule).
419
+ e.preventDefault();
420
+ e.stopPropagation();
421
+ this.close();
422
+ this.trigger?.focus();
423
+ return;
424
+ }
425
+ if (!this.el.contains(e.target)) return;
426
+
427
+ const searching = _terms(this._query).length > 0;
428
+ switch (e.key) {
429
+ case 'ArrowDown':
430
+ case 'ArrowUp': {
431
+ e.preventDefault();
432
+ const dir = e.key === 'ArrowDown' ? 1 : -1;
433
+ if (!searching && this._column === 'categories') {
434
+ const n = this.categories.length;
435
+ if (n) this._chooseCategory((this._activeCat + dir + n) % n);
436
+ } else {
437
+ this._moveHighlight(dir);
438
+ }
439
+ break;
440
+ }
441
+ case 'ArrowRight':
442
+ case 'ArrowLeft':
443
+ if (searching) return; // the query's caret
444
+ e.preventDefault();
445
+ this._column = e.key === 'ArrowRight' ? 'items' : 'categories';
446
+ if (this._column === 'items' && !this._hl) this._moveHighlight(1);
447
+ this._paintState();
448
+ break;
449
+ case 'Enter':
450
+ e.preventDefault();
451
+ if (this._hl) this._pick(this._hl);
452
+ break;
453
+ case 'Tab': {
454
+ // Tab stays inside, like a modal ManagedWindow's focus trap:
455
+ // it cycles the search field and Add (when Add is enabled).
456
+ // Leaving would strand an open popover with a dead keyboard.
457
+ e.preventDefault();
458
+ e.stopPropagation();
459
+ const add = this._detailEl.querySelector('.twm-category-picker__add');
460
+ const stops = [this._input, ...(add && !add.disabled ? [add] : [])];
461
+ const at = stops.indexOf(e.target);
462
+ const next = at < 0 ? 0 : (at + (e.shiftKey ? -1 : 1) + stops.length) % stops.length;
463
+ stops[next].focus();
464
+ break;
465
+ }
466
+ default:
467
+ // A printable key typed while a row or the button has focus
468
+ // goes to the search field.
469
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey
470
+ && e.target !== this._input) {
471
+ this._input.focus();
472
+ }
473
+ }
474
+ }
475
+
476
+ // ── Public API ─────────────────────────────────────────────────────────
477
+
478
+ /** Open it under (or above) the trigger, with an empty query and focus in the search field. */
479
+ open() {
480
+ if (this.isOpen || !this.el) return;
481
+ this.isOpen = true;
482
+ this._input.value = '';
483
+ this._query = '';
484
+ this._column = 'items';
485
+ // Shown and placed first, then drawn: scrolling the highlight into
486
+ // view does nothing in a hidden popover.
487
+ this.el.hidden = false;
488
+ this.position();
489
+ this._render();
490
+
491
+ this.trigger?.classList.add('twm-is-open');
492
+ this.trigger?.setAttribute('aria-expanded', 'true');
493
+ document.addEventListener('click', this._onDocClick, true);
494
+ document.addEventListener('keydown', this._onKeydown, true);
495
+ this._input.focus();
496
+ }
497
+
498
+ close() {
499
+ if (!this.isOpen) return;
500
+ this.isOpen = false;
501
+ if (this.el) this.el.hidden = true;
502
+ this.trigger?.classList.remove('twm-is-open');
503
+ this.trigger?.setAttribute('aria-expanded', 'false');
504
+ document.removeEventListener('click', this._onDocClick, true);
505
+ document.removeEventListener('keydown', this._onKeydown, true);
506
+ }
507
+
508
+ /** @param {boolean} [force] */
509
+ toggle(force) {
510
+ const want = typeof force === 'boolean' ? force : !this.isOpen;
511
+ if (want) this.open(); else this.close();
512
+ }
513
+
514
+ /** Place it against the trigger again (after the trigger moved). */
515
+ position() {
516
+ if (!this.el || !this.trigger) return;
517
+ ActionDropdown.position(this.trigger, this.el, { align: this.align });
518
+ // The columns scroll themselves; the popover only shrinks to fit.
519
+ this.el.style.overflowY = 'hidden';
520
+ }
521
+
522
+ /**
523
+ * Replace the categories. The active category and the highlight are
524
+ * looked up again by `id`, so they are kept when they still exist,
525
+ * wherever they moved to.
526
+ */
527
+ setCategories(categories) {
528
+ const oldCat = this.categories[this._activeCat];
529
+ const oldHlCat = this._hl ? this.categories[this._hl.c] : null;
530
+ const oldHlItem = oldHlCat?.items?.[this._hl.i];
531
+ this.categories = categories || [];
532
+ const catAt = (cat) => (cat ? this.categories.findIndex((c) => c.id === cat.id) : -1);
533
+
534
+ const active = catAt(oldCat);
535
+ this._activeCat = active >= 0 ? active : 0;
536
+ this._hl = null;
537
+ const hc = catAt(oldHlCat);
538
+ if (hc >= 0 && oldHlItem) {
539
+ const hi = (this.categories[hc].items || []).findIndex((it) => it.id === oldHlItem.id);
540
+ if (hi >= 0) this._hl = { c: hc, i: hi };
541
+ }
542
+ if (this.el) this._render();
543
+ }
544
+
545
+ destroy() {
546
+ this.close();
547
+ this.trigger?.removeEventListener('click', this._onTriggerClick);
548
+ this.el?.remove();
549
+ this.el = null;
550
+ this.trigger = null;
551
+ this.categories = [];
552
+ this.onSelect = null;
553
+ }
554
+ }
555
+
556
+ export default CategoryPicker;
@@ -110,6 +110,9 @@ export function showComputingWindow({
110
110
  canResize: false,
111
111
  canDrag: true,
112
112
  modal: false,
113
+ // BUG-0053. A status window opened by the program, not by the user:
114
+ // it must not take focus from what the user is doing.
115
+ focusOnOpen: false,
113
116
  onClose: () => {
114
117
  if (_activeWindow === win) _activeWindow = null;
115
118
  if (finished) return;
@@ -314,6 +314,9 @@ export function showRunProgressDialog(options = {}) {
314
314
  canResize: false,
315
315
  canDrag: false,
316
316
  modal: true,
317
+ // BUG-0053. Focus goes to the window, never to Stop or Cancel: a stray
318
+ // Enter or Space would cancel the run.
319
+ focusOnOpen: 'window',
317
320
  onClose: () => {
318
321
  if (finished) return;
319
322
  finish('cancelled', 'Cancelled.', 'is-cancelled');