flexdesk 0.17.15 → 0.17.16
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.
- package/CHANGELOG.md +23 -0
- package/css/base.css +224 -4
- package/css/flexdesk.css +314 -12
- package/dist/charts.js +104 -34
- package/dist/charts.js.map +3 -3
- package/dist/{chunk-2PM6ZMNS.js → chunk-2Z2ENHR3.js} +2 -2
- package/dist/{chunk-AD7FIA3Z.js → chunk-7JI7HHSY.js} +2 -2
- package/dist/{chunk-TLIV6QSU.js → chunk-7OL6F2AM.js} +3 -3
- package/dist/{chunk-MADMGRX2.js → chunk-PGZ4EHSE.js} +156 -9
- package/dist/chunk-PGZ4EHSE.js.map +7 -0
- package/dist/{chunk-OEG6NPAN.js → chunk-XJNADRGR.js} +2 -2
- package/dist/flexdesk.css +314 -12
- package/dist/flow.js +3 -3
- package/dist/tiles.js +84 -9
- package/dist/tiles.js.map +2 -2
- package/dist/widgets.js +448 -5
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +102 -14
- package/dist/wm.js.map +2 -2
- package/docs/README.md +1 -1
- package/docs/components/charts.md +70 -3
- package/docs/components/forms-and-dialogs.md +61 -0
- package/docs/components/menus.md +85 -1
- package/docs/components/shell.md +37 -0
- package/docs/components/tiles.md +37 -0
- package/package.json +1 -1
- package/src/charts/plot_config_editor.js +125 -29
- package/src/tiles/tile_base.js +48 -2
- package/src/tiles/tile_grid.js +42 -1
- package/src/tiling/kind_taxonomy.js +45 -0
- package/src/tiling/wm.js +86 -11
- package/src/ui/components/category_picker.js +556 -0
- package/src/ui/components/computing_status_window.js +3 -0
- package/src/ui/components/confirm_dialog.js +3 -0
- package/src/ui/components/managed_window.js +204 -9
- package/src/ui/components/wizard.js +4 -1
- package/widgets.js +2 -1
- package/dist/chunk-MADMGRX2.js.map +0 -7
- /package/dist/{chunk-2PM6ZMNS.js.map → chunk-2Z2ENHR3.js.map} +0 -0
- /package/dist/{chunk-AD7FIA3Z.js.map → chunk-7JI7HHSY.js.map} +0 -0
- /package/dist/{chunk-TLIV6QSU.js.map → chunk-7OL6F2AM.js.map} +0 -0
- /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');
|