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,1253 @@
1
+ /**
2
+ * workspace_picker.js — the field in a top bar that names what the window is
3
+ * working in, and the switcher that changes it.
4
+ *
5
+ * field switcher, opened in the field's place
6
+ * +-----------------------+ +------------------------------------+
7
+ * | (i) North Ctrl+P | | (o) nor| |
8
+ * +-----------------------+ | heading 2 of 9|
9
+ * | North (check)(x)|
10
+ * | Northwest |
11
+ * | Load 20 more · 2 of 9 shown |
12
+ * | + New "nor"… |
13
+ * | card: the current one's links |
14
+ * | status footer verbs |
15
+ * +------------------------------------+
16
+ *
17
+ * Three exports, each usable alone:
18
+ *
19
+ * mountPickerField the field: a name, an icon, a key hint; centred in
20
+ * its host at the width the host can spare, or inline
21
+ * createPickerSwitcher the switcher, on any anchor or on none
22
+ * createWorkspacePicker both, wired together, with an optional chord
23
+ *
24
+ * It knows nothing of what is being picked. Items are the caller's, `describe`
25
+ * turns one into what a row draws, and every string is the caller's or a
26
+ * neutral default the caller can replace through `labels`.
27
+ *
28
+ * ══ ITEMS ARE ASKED FOR ON EVERY OPEN ══════════════════════════════════════
29
+ *
30
+ * `load({ query, offset, limit, signal })` answers an array, or
31
+ * `{ items, total?, status? }`, now or in a promise. With `search: 'local'` it
32
+ * is asked once per open and typing filters what it answered, names ahead of
33
+ * other matches. With `search: 'remote'` typing is debounced and asked again,
34
+ * and a `total` above what is drawn offers Load more. Either way only the
35
+ * newest question's answer paints: every read carries a sequence number and an
36
+ * AbortSignal, and an answer to an older question is dropped however it
37
+ * arrives. The last unsearched answer, per `cacheKey()`, is drawn at once on
38
+ * the next open while it is asked for again.
39
+ *
40
+ * ══ THE KEYBOARD ═══════════════════════════════════════════════════════════
41
+ *
42
+ * Focus is real: what the keyboard is on is `document.activeElement`, so a screen
43
+ * reader says each stop, a button in the card or the footer included, and Tab and
44
+ * the arrows agree about where the reader is.
45
+ *
46
+ * Opening puts the caret in the input. The arrows walk focus over every stop,
47
+ * wrapping: the input, the rows a person can choose (a disabled one is stepped
48
+ * over), Load more, the action rows, the card's buttons, the footer's; after
49
+ * `open({ select })` they start from the row it chose. Tab is shorter: the rows are
50
+ * ONE tab stop (the row last focused, else the current one, else the first), and so
51
+ * are the action rows. An answer or a redraw leaves focus where it is, on a row that
52
+ * moved too; Load more stays focusable while it loads.
53
+ *
54
+ * Typing always types: a character pressed anywhere in the panel goes to the input.
55
+ * In the input, the first row or action row of what is shown wears the highlight,
56
+ * and Enter takes it, or, while the answer to what was typed is on its way, takes
57
+ * the first one when it lands. Enter or Space on a row takes that row. Shift+Delete
58
+ * removes the highlighted row, and focus goes on to the next. Backspace away from
59
+ * the input stops at the panel. Esc closes and gives focus back to where it was, as
60
+ * does a pick or a verb, before it runs, so a dialog it opens opens over nothing. A
61
+ * press outside closes and leaves focus where it was pressed. Tab out of the panel
62
+ * closes it.
63
+ *
64
+ * ══ NOTHING IS REBUILT UNDER A POINTER ═════════════════════════════════════
65
+ *
66
+ * A click fires only when the press and the release land on one node. So every
67
+ * part is reconciled by key: an answer, a card or a `setCurrent` updates nodes
68
+ * in place, and a node is created or removed only for a key that came or went.
69
+ * Which node holds what lives in maps here, never in DOM attributes.
70
+ *
71
+ * Nor is a list moved under one. The list scrolls to the highlight only when the
72
+ * keyboard moved it, `open({ select })` asked for a row, or the row the arrows chose
73
+ * went away (focus then goes to the input); typing and a new query's answer go back
74
+ * to the top.
75
+ * A redraw, Load more by the mouse and a removal leave the scroll where it is.
76
+ *
77
+ * ══ AN ASYNC REMOVE MAY ASK ════════════════════════════════════════════════
78
+ *
79
+ * While the promise `onRemove` returned is unsettled the panel and its backdrop
80
+ * step below managed windows and do not close on a press outside, so a
81
+ * confirmation dialog can be answered and the switcher is still there afterwards.
82
+ */
83
+
84
+ import { ActionDropdown } from './action_dropdown.js';
85
+ import { createRafResizeObserver } from '../utils/raf_resize_observer.js';
86
+
87
+ const DEFAULT_LABELS = Object.freeze({
88
+ loading: 'Loading…',
89
+ empty: () => 'Nothing to show.',
90
+ noMatch: (query) => `Nothing matches “${query}”.`,
91
+ failed: (err) => `The list could not be read. ${err?.message || ''}`.trim(),
92
+ failedMore: (err) => `The next page could not be read. ${err?.message || ''}`.trim(),
93
+ failedRemove: (err) => `Could not remove it. ${err?.message || ''}`.trim(),
94
+ loadMore: ({ next, shown, total }) => `Load ${next} more · ${shown} of ${total} shown`,
95
+ remove: (name) => `Remove ${name}`,
96
+ });
97
+
98
+ const PANEL_EDGE = 8;
99
+ /** How far the panel's input row stands above the field it covers. */
100
+ const OVER_LIFT = 3;
101
+
102
+ let nextUid = 0;
103
+
104
+ function part(tag, className, text = null) {
105
+ const node = document.createElement(tag);
106
+ if (className) node.className = className;
107
+ if (text != null) node.textContent = text;
108
+ return node;
109
+ }
110
+
111
+ function glyph(name, className = '') {
112
+ const node = part('span', `material-symbols-outlined ${className}`.trim(), name);
113
+ node.setAttribute('aria-hidden', 'true');
114
+ return node;
115
+ }
116
+
117
+ /** Write text only when it changed: a write that changes nothing is still a
118
+ * mutation, and a screen reader hears it. */
119
+ function setText(node, text) {
120
+ const value = String(text ?? '');
121
+ if (node.textContent !== value) node.textContent = value;
122
+ }
123
+
124
+ function show(node, shown) {
125
+ node.hidden = !shown;
126
+ }
127
+
128
+ function button(className) {
129
+ const node = part('button', className);
130
+ node.type = 'button';
131
+ return node;
132
+ }
133
+
134
+ /**
135
+ * Keep `container`'s children one per key: `build()` makes `{ node, ... }` for
136
+ * a new key, `update(entry, item)` brings any entry up to date, a key that went
137
+ * loses its node, and a node already in its place is not moved (moving one
138
+ * under a pressed pointer is half of rebuilding it). The node holding focus is
139
+ * never moved, wherever it belongs now: the others go round it, because moving it
140
+ * would drop the keyboard on the body.
141
+ * @returns {(items: object[], keyOf: (item: object) => unknown) => Map<string, object>}
142
+ */
143
+ function keyedChildren(container, build, update) {
144
+ let drawn = new Map();
145
+ return (items, keyOf) => {
146
+ const next = new Map();
147
+ const nodes = items.map((item) => {
148
+ const key = String(keyOf(item));
149
+ const entry = drawn.get(key) ?? build();
150
+ update(entry, item);
151
+ next.set(key, entry);
152
+ return entry.node;
153
+ });
154
+ for (const [key, entry] of drawn) {
155
+ if (!next.has(key)) entry.node.remove();
156
+ }
157
+ drawn = next;
158
+ const focused = document.activeElement;
159
+ nodes.forEach((node, index) => {
160
+ if (node.contains(focused)) {
161
+ while (container.children[index] !== node) container.append(container.children[index]);
162
+ } else if (container.children[index] !== node) {
163
+ container.insertBefore(node, container.children[index] || null);
164
+ }
165
+ });
166
+ return drawn;
167
+ };
168
+ }
169
+
170
+ function parseChord(chord) {
171
+ const parts = String(chord).split('+').map((p) => p.trim().toLowerCase());
172
+ const key = parts.pop();
173
+ const known = new Set(['ctrl', 'cmd', 'meta', 'shift', 'alt']);
174
+ if (!key || parts.some((p) => !known.has(p))) {
175
+ throw new TypeError(`createWorkspacePicker: cannot read the chord "${chord}"`);
176
+ }
177
+ return {
178
+ key,
179
+ ctrl: parts.includes('ctrl') || parts.includes('cmd') || parts.includes('meta'),
180
+ shift: parts.includes('shift'),
181
+ alt: parts.includes('alt'),
182
+ };
183
+ }
184
+
185
+ function chordMatches(parsed, event) {
186
+ return (event.ctrlKey || event.metaKey) === parsed.ctrl
187
+ && event.shiftKey === parsed.shift
188
+ && event.altKey === parsed.alt
189
+ && String(event.key).toLowerCase() === parsed.key;
190
+ }
191
+
192
+ /**
193
+ * Mount the field into `hostEl`.
194
+ *
195
+ * `layout: 'centered'` places it in the middle of `hostEl` (which must be
196
+ * positioned) at the width the host can spare between the elements `avoid`
197
+ * selects: at most `maxWidth`, without the key hint below `tightWidth`, and
198
+ * hidden below `minWidth`. `layout: 'inline'` leaves its width to the host and
199
+ * only drops the key hint.
200
+ *
201
+ * @param {HTMLElement} hostEl
202
+ * @param {object} [options]
203
+ * @param {string} [options.name] - The name it shows
204
+ * @param {string} [options.icon] - A Material Symbols name
205
+ * @param {string} [options.keyHint] - Drawn as a key; none by default
206
+ * @param {boolean} [options.muted] - Draw the name as naming nothing
207
+ * @param {boolean} [options.caret] - A trailing caret, for an inline field
208
+ * @param {string} [options.tooltip]
209
+ * @param {string} [options.ariaLabel]
210
+ * @param {string} [options.className] - Extra classes
211
+ * @param {'centered'|'inline'} [options.layout]
212
+ * @param {string} [options.avoid] - Selector, within `hostEl`, of what it must not cover
213
+ * @param {number} [options.maxWidth]
214
+ * @param {number} [options.minWidth]
215
+ * @param {number} [options.tightWidth]
216
+ * @param {number} [options.gap] - Room kept free in all, shared between the two sides
217
+ * @param {() => void} [options.onActivate] - A click
218
+ * @returns {{ el: HTMLButtonElement, update: (patch: object) => void, setOpen: (open: boolean) => void, fit: () => void, destroy: () => void }}
219
+ */
220
+ export function mountPickerField(hostEl, {
221
+ name = '',
222
+ icon = '',
223
+ keyHint = '',
224
+ muted = false,
225
+ caret = false,
226
+ tooltip = '',
227
+ ariaLabel = '',
228
+ className = '',
229
+ layout = 'inline',
230
+ avoid = '',
231
+ maxWidth = 440,
232
+ minWidth = 120,
233
+ tightWidth = 230,
234
+ gap = 24,
235
+ onActivate = null,
236
+ } = {}) {
237
+ const centered = layout === 'centered';
238
+ const el = button(['twm-wspicker-field', centered ? 'twm-wspicker-field--centered' : '', className]
239
+ .filter(Boolean).join(' '));
240
+ el.setAttribute('aria-haspopup', 'dialog');
241
+ el.setAttribute('aria-expanded', 'false');
242
+ const iconEl = glyph('', 'twm-wspicker-field__icon');
243
+ const nameEl = part('span', 'twm-wspicker-field__name');
244
+ const keyEl = part('kbd', 'twm-wspicker-field__key');
245
+ el.append(iconEl, nameEl, keyEl);
246
+ if (caret) el.append(glyph('expand_more', 'twm-wspicker-field__caret'));
247
+
248
+ const update = (patch) => {
249
+ if ('name' in patch) setText(nameEl, patch.name);
250
+ if ('icon' in patch) {
251
+ setText(iconEl, patch.icon);
252
+ show(iconEl, !!patch.icon);
253
+ }
254
+ if ('keyHint' in patch) {
255
+ setText(keyEl, patch.keyHint);
256
+ show(keyEl, !!patch.keyHint);
257
+ }
258
+ if ('muted' in patch) el.classList.toggle('twm-wspicker-field--muted', !!patch.muted);
259
+ if ('tooltip' in patch) {
260
+ el.classList.toggle('twm-has-tooltip', !!patch.tooltip);
261
+ if (patch.tooltip) {
262
+ el.setAttribute('data-tooltip', patch.tooltip);
263
+ el.setAttribute('data-tooltip-placement', 'bottom');
264
+ } else {
265
+ el.removeAttribute('data-tooltip');
266
+ el.removeAttribute('data-tooltip-placement');
267
+ }
268
+ }
269
+ if ('ariaLabel' in patch) {
270
+ if (patch.ariaLabel) el.setAttribute('aria-label', patch.ariaLabel);
271
+ else el.removeAttribute('aria-label');
272
+ }
273
+ };
274
+ update({ name, icon, keyHint, muted, tooltip, ariaLabel });
275
+
276
+ const fit = () => {
277
+ if (!centered) {
278
+ el.classList.toggle('twm-wspicker-field--tight', el.getBoundingClientRect().width < tightWidth);
279
+ return;
280
+ }
281
+ const box = hostEl.getBoundingClientRect();
282
+ const middle = box.left + box.width / 2;
283
+ let leftEdge = box.left;
284
+ let rightEdge = box.right;
285
+ for (const other of avoid ? hostEl.querySelectorAll(avoid) : []) {
286
+ if (el.contains(other)) continue;
287
+ const r = other.getBoundingClientRect();
288
+ if (r.width <= 0) continue;
289
+ if (r.left + r.width / 2 < middle) leftEdge = Math.max(leftEdge, r.right);
290
+ else rightEdge = Math.min(rightEdge, r.left);
291
+ }
292
+ const width = Math.min(maxWidth, 2 * Math.min(middle - leftEdge, rightEdge - middle) - gap);
293
+ el.classList.toggle('twm-wspicker-field--hidden', width < minWidth);
294
+ el.classList.toggle('twm-wspicker-field--tight', width < tightWidth);
295
+ el.style.width = `${Math.max(minWidth, width)}px`;
296
+ };
297
+
298
+ let destroyed = false;
299
+ const onClick = () => onActivate?.();
300
+ el.addEventListener('click', onClick);
301
+ hostEl.appendChild(el);
302
+ const observer = createRafResizeObserver(() => {
303
+ if (!destroyed) fit();
304
+ });
305
+ observer.observe(centered ? hostEl : el);
306
+ fit();
307
+ document.fonts?.ready?.then(() => {
308
+ if (!destroyed) fit();
309
+ });
310
+
311
+ return {
312
+ el,
313
+ update,
314
+ fit,
315
+ setOpen(open) {
316
+ el.classList.toggle('twm-wspicker-field--open', open);
317
+ el.setAttribute('aria-expanded', String(open));
318
+ },
319
+ destroy() {
320
+ destroyed = true;
321
+ observer.disconnect();
322
+ el.removeEventListener('click', onClick);
323
+ el.remove();
324
+ },
325
+ };
326
+ }
327
+
328
+ /**
329
+ * The switcher. See the file's header for how it asks and how it behaves.
330
+ *
331
+ * @param {object} options
332
+ * @param {HTMLElement} [options.anchor] - What it opens from. With none, or one
333
+ * that takes no room, it opens centred over a dimmed page.
334
+ * @param {'over'|'below'} [options.placement] - `over` lays the input over the
335
+ * anchor; `below` hangs from it, flipped above when there is no room.
336
+ * @param {number} [options.minWidth] - The anchored panel's least width
337
+ * @param {number} [options.visibleRows] - Scroll past this many two-line rows
338
+ * @param {string} [options.className] - Extra classes on the panel
339
+ * @param {string} [options.label] - The panel's accessible name
340
+ * @param {string} [options.listLabel] - The list's accessible name
341
+ * @param {string} [options.placeholder] - The input's placeholder
342
+ * @param {string} [options.glyph] - The icon before the input
343
+ * @param {(request: {query: string, offset: number, limit: number, signal: AbortSignal}) => (object[]|{items: object[], total?: number, status?: string|{text: string, error?: boolean}}|Promise)} options.load
344
+ * @param {'local'|'remote'} [options.search]
345
+ * @param {number} [options.pageSize] - `limit`, in remote search
346
+ * @param {number} [options.debounceMs] - How long typing waits, in remote search
347
+ * @param {() => string} [options.cacheKey] - Whose list the kept answer is
348
+ * @param {(item: object, ctx: {current: boolean}) => {id: string|number, name: string, detail?: string, icon?: string, badge?: string, title?: string, keywords?: string|string[], disabled?: boolean, removable?: boolean}} options.describe
349
+ * @param {(item: object) => void} [options.onPick] - A row other than the current one chosen
350
+ * @param {(item: object) => (boolean|void|Promise<boolean|void>)} [options.onRemove] -
351
+ * Offers a bin on each row whose `removable` is not false. Answering `false`
352
+ * keeps the row; rejecting keeps it and shows the error.
353
+ * @param {(ctx: {query: string}) => Array<{id: string, label: string, detail?: string, icon?: string, always?: boolean, run: (ctx: {query: string}) => void}>} [options.actions] -
354
+ * Rows after the items, narrowed by typing unless `always`.
355
+ * @param {(ctx: {query: string, shown: number, total: number|null}) => ({title?: string, count?: string}|null)} [options.heading]
356
+ * @param {(ctx: {current: object|null, row: object|null}) => ({heading?: string, icon?: string, name: string, detail?: string, open?: {label: string, icon?: string, run: Function}, columns?: number, tiles?: Array<{id: string, label: string, icon?: string, run: Function}>}|null)} [options.card]
357
+ * @param {() => ({start?: object[], end?: object[], keys?: Array<{keys: string[], label: string}>}|null)} [options.footer] -
358
+ * Buttons are `{ id, label, icon?, trailing?, primary?, run }`. `keys` are drawn
359
+ * only when the panel is centred.
360
+ * @param {object} [options.labels] - Replaces any of the default strings
361
+ * @param {(open: boolean) => void} [options.onOpenChange]
362
+ */
363
+ export function createPickerSwitcher({
364
+ anchor = null,
365
+ placement = 'over',
366
+ minWidth = 520,
367
+ visibleRows = 0,
368
+ className = '',
369
+ label = '',
370
+ listLabel = '',
371
+ placeholder = '',
372
+ glyph: inputGlyph = 'search',
373
+ load,
374
+ search = 'local',
375
+ pageSize = 20,
376
+ debounceMs = 200,
377
+ cacheKey = () => '',
378
+ describe,
379
+ onPick = null,
380
+ onRemove = null,
381
+ actions = null,
382
+ heading = null,
383
+ card = null,
384
+ footer = null,
385
+ labels: labelOverrides = {},
386
+ onOpenChange = null,
387
+ }) {
388
+ const labels = { ...DEFAULT_LABELS, ...labelOverrides };
389
+ const remote = search === 'remote';
390
+ const uid = `twm-wspicker-${++nextUid}`;
391
+
392
+ let isOpen = false;
393
+ let destroyed = false;
394
+ let current = null;
395
+ /** What the last answer held, in its order. */
396
+ let items = [];
397
+ let total = null;
398
+ let status = null;
399
+ /** The query the drawn items answer, and the newest one asked. */
400
+ let answered = '';
401
+ let asked = '';
402
+ let seq = 0;
403
+ let abort = null;
404
+ let loading = false;
405
+ let loadingMore = false;
406
+ let failure = null;
407
+ let timer = null;
408
+ /** Enter came before the answer to what was typed. */
409
+ let pickFirst = false;
410
+ /** Which row to highlight when the next answer lands. */
411
+ let selectId = null;
412
+ /** An `onRemove` promise is unsettled. */
413
+ let busy = false;
414
+ /** Where focus was when it opened. */
415
+ let opener = null;
416
+ /** Every place the arrows stop, in order. */
417
+ let stops = [];
418
+ let activeKey = null;
419
+ let activeNode = null;
420
+ /** The arrows chose the highlight; an answer does not move it. */
421
+ let moved = false;
422
+ /** The row last focused, per list of rows. */
423
+ const rovingKeys = {};
424
+ let nextEntryId = 0;
425
+ const kept = new Map();
426
+
427
+ const idOf = (item) => String(describe(item, { current: false }).id);
428
+ const isCurrent = (item) => current != null && idOf(item) === idOf(current);
429
+ const modelOf = (item) => describe(item, { current: isCurrent(item) });
430
+ const typed = () => input.value.trim();
431
+
432
+ // ── the panel, built once ───────────────────────────────────────────
433
+ const backdrop = part('div', 'twm-wspicker-backdrop');
434
+ const panel = part('div', `twm-wspicker ${className}`.trim());
435
+ panel.setAttribute('role', 'dialog');
436
+ if (label) panel.setAttribute('aria-label', label);
437
+
438
+ const searchRow = part('div', 'twm-wspicker__search');
439
+ const input = part('input', 'twm-wspicker__input');
440
+ input.type = 'text';
441
+ input.autocomplete = 'off';
442
+ input.spellcheck = false;
443
+ input.placeholder = placeholder;
444
+ if (placeholder) input.setAttribute('aria-label', placeholder);
445
+ input.setAttribute('role', 'combobox');
446
+ input.setAttribute('aria-expanded', 'true');
447
+ input.setAttribute('aria-autocomplete', 'list');
448
+ input.setAttribute('aria-controls', `${uid}-list`);
449
+ searchRow.append(glyph(inputGlyph, 'twm-wspicker__glyph'), input);
450
+
451
+ const head = part('div', 'twm-wspicker__head');
452
+ const headTitle = part('span', 'twm-wspicker__title');
453
+ const headCount = part('span', 'twm-wspicker__count');
454
+ head.append(headTitle, headCount);
455
+
456
+ const scroller = part('div', 'twm-wspicker__scroll');
457
+ if (visibleRows > 0) {
458
+ scroller.classList.add('twm-wspicker__scroll--capped');
459
+ scroller.style.setProperty('--twm-wspicker-rows', String(visibleRows));
460
+ }
461
+ const list = part('div', 'twm-wspicker__list');
462
+ list.id = `${uid}-list`;
463
+ list.setAttribute('role', 'listbox');
464
+ if (listLabel) list.setAttribute('aria-label', listLabel);
465
+ const note = part('p', 'twm-wspicker__note');
466
+ note.setAttribute('role', 'status');
467
+ const more = button('twm-btn twm-wspicker__more');
468
+ const moreLabel = part('span', 'twm-wspicker__more-label');
469
+ more.append(glyph('expand_more'), moreLabel);
470
+ const actionList = part('div', 'twm-wspicker__actions');
471
+ actionList.setAttribute('role', 'listbox');
472
+ scroller.append(list, note, more, actionList);
473
+
474
+ const cardEl = part('section', 'twm-wspicker__card');
475
+ const cardHeading = part('h3', 'twm-wspicker__card-heading');
476
+ const cardHead = part('div', 'twm-wspicker__card-head');
477
+ const cardIcon = glyph('', 'twm-wspicker__card-icon');
478
+ const cardText = part('span', 'twm-wspicker__card-text');
479
+ const cardName = part('span', 'twm-wspicker__card-name');
480
+ const cardDetail = part('span', 'twm-wspicker__card-detail');
481
+ cardText.append(cardName, cardDetail);
482
+ const cardOpen = button('twm-btn twm-wspicker__card-open');
483
+ const cardOpenIcon = glyph('');
484
+ const cardOpenLabel = part('span', '');
485
+ cardOpen.append(cardOpenIcon, cardOpenLabel);
486
+ cardHead.append(cardIcon, cardText, cardOpen);
487
+ const tiles = part('div', 'twm-wspicker__tiles');
488
+ cardEl.append(cardHeading, cardHead, tiles);
489
+
490
+ const foot = part('div', 'twm-wspicker__foot');
491
+ const footStatus = part('span', 'twm-wspicker__status');
492
+ const footStart = part('div', 'twm-wspicker__foot-group');
493
+ const footEnd = part('div', 'twm-wspicker__foot-group');
494
+ const footKeys = part('span', 'twm-wspicker__keys');
495
+ foot.append(footStart, footStatus, footEnd, footKeys);
496
+
497
+ panel.append(searchRow, head, scroller, cardEl, foot);
498
+
499
+ // ── rows, the items' and the action rows' ───────────────────────────
500
+ function buildRow() {
501
+ const node = part('div', 'twm-wspicker__row');
502
+ node.id = `${uid}-row-${++nextEntryId}`;
503
+ node.setAttribute('role', 'option');
504
+ node.tabIndex = -1;
505
+ const icon = glyph('', 'twm-wspicker__row-icon');
506
+ const text = part('span', 'twm-wspicker__row-text');
507
+ const name = part('span', 'twm-wspicker__row-name');
508
+ const detail = part('span', 'twm-wspicker__row-detail');
509
+ text.append(name, detail);
510
+ const badge = part('span', 'twm-wspicker__row-badge');
511
+ const check = glyph('check', 'twm-wspicker__row-check');
512
+ const bin = button('twm-wspicker__row-remove');
513
+ bin.tabIndex = -1;
514
+ bin.append(glyph('delete'));
515
+ node.append(icon, text, badge, check, bin);
516
+ const entry = { node, icon, name, detail, badge, check, bin, run: null, remove: null };
517
+ node.addEventListener('click', () => entry.run?.());
518
+ bin.addEventListener('click', (event) => {
519
+ event.stopPropagation();
520
+ entry.remove?.();
521
+ });
522
+ return entry;
523
+ }
524
+
525
+ function paintRow(entry, { name, detail, icon, badge, title, disabled = false, isCurrentRow = false }) {
526
+ setText(entry.icon, icon);
527
+ setText(entry.name, name);
528
+ setText(entry.detail, detail);
529
+ show(entry.detail, !!detail);
530
+ setText(entry.badge, badge);
531
+ show(entry.badge, !!badge);
532
+ show(entry.check, isCurrentRow);
533
+ entry.node.classList.toggle('twm-wspicker__row--current', isCurrentRow);
534
+ entry.node.classList.toggle('twm-wspicker__row--disabled', disabled);
535
+ entry.node.setAttribute('aria-selected', String(isCurrentRow));
536
+ if (disabled) entry.node.setAttribute('aria-disabled', 'true');
537
+ else entry.node.removeAttribute('aria-disabled');
538
+ entry.node.title = title ?? (detail ? `${name} — ${detail}` : name);
539
+ }
540
+
541
+ const syncRows = keyedChildren(list, buildRow, (entry, item) => {
542
+ const model = modelOf(item);
543
+ const disabled = !!model.disabled;
544
+ paintRow(entry, { ...model, disabled, isCurrentRow: isCurrent(item) });
545
+ entry.run = disabled ? null : () => pick(item);
546
+ if (disabled) entry.node.tabIndex = -1;
547
+ const removable = !!onRemove && model.removable !== false;
548
+ entry.remove = removable ? () => removeItem(item) : null;
549
+ show(entry.bin, removable);
550
+ if (removable) {
551
+ const words = labels.remove(model.name);
552
+ entry.bin.setAttribute('aria-label', words);
553
+ entry.bin.title = words;
554
+ }
555
+ });
556
+
557
+ const syncActions = keyedChildren(actionList, buildRow, (entry, action) => {
558
+ paintRow(entry, { name: action.label, detail: action.detail, icon: action.icon, title: action.title });
559
+ entry.run = () => runVerb(action.run);
560
+ show(entry.bin, false);
561
+ });
562
+
563
+ /** What typing leaves of the answer: names ahead of other matches. */
564
+ function shownItems() {
565
+ const query = typed().toLowerCase();
566
+ if (remote || !query) return items;
567
+ const names = [];
568
+ const others = [];
569
+ for (const item of items) {
570
+ const { name, detail, keywords } = modelOf(item);
571
+ if (String(name).toLowerCase().includes(query)) names.push(item);
572
+ else if ([detail, keywords].flat().some((text) => String(text ?? '').toLowerCase().includes(query))) others.push(item);
573
+ }
574
+ return [...names, ...others];
575
+ }
576
+
577
+ function shownActions() {
578
+ if (!actions) return [];
579
+ const query = typed();
580
+ const lower = query.toLowerCase();
581
+ return actions({ query }).filter((action) => action.always || !lower
582
+ || String(action.label).toLowerCase().includes(lower));
583
+ }
584
+
585
+ // ── the card and the footer ─────────────────────────────────────────
586
+ function buildVerb(hook) {
587
+ return () => {
588
+ const node = button(`twm-btn ${hook}`);
589
+ const icon = glyph('');
590
+ const text = part('span', '');
591
+ const trailing = glyph('');
592
+ node.append(icon, text, trailing);
593
+ const entry = { node, icon, text, trailing, run: null };
594
+ node.addEventListener('click', () => entry.run?.());
595
+ return entry;
596
+ };
597
+ }
598
+
599
+ function paintVerb(entry, verb) {
600
+ setText(entry.icon, verb.icon);
601
+ setText(entry.text, verb.label);
602
+ setText(entry.trailing, verb.trailing);
603
+ entry.node.classList.toggle('twm-btn--primary', !!verb.primary);
604
+ entry.node.title = verb.label;
605
+ entry.run = () => runVerb(verb.run);
606
+ }
607
+
608
+ const syncTiles = keyedChildren(tiles, buildVerb('twm-wspicker__tile'), paintVerb);
609
+ const syncFootStart = keyedChildren(footStart, buildVerb('twm-wspicker__foot-btn'), paintVerb);
610
+ const syncFootEnd = keyedChildren(footEnd, buildVerb('twm-wspicker__foot-btn'), paintVerb);
611
+ let drawnTiles = new Map();
612
+ let drawnFoot = new Map();
613
+ let cardOpenRun = null;
614
+
615
+ cardOpen.addEventListener('click', () => cardOpenRun?.());
616
+
617
+ function renderCard() {
618
+ const row = current == null ? null : items.find((item) => isCurrent(item)) ?? null;
619
+ const model = card ? card({ current, row }) : null;
620
+ show(cardEl, !!model);
621
+ drawnTiles = syncTiles(model?.tiles || [], (tile) => tile.id);
622
+ cardOpenRun = model?.open ? () => runVerb(model.open.run) : null;
623
+ show(cardOpen, !!model?.open);
624
+ if (!model) return;
625
+ setText(cardHeading, model.heading);
626
+ show(cardHeading, !!model.heading);
627
+ setText(cardIcon, model.icon);
628
+ setText(cardName, model.name);
629
+ setText(cardDetail, model.detail);
630
+ cardName.title = model.name;
631
+ setText(cardOpenIcon, model.open?.icon);
632
+ setText(cardOpenLabel, model.open?.label);
633
+ tiles.style.setProperty('--twm-wspicker-columns', String(model.columns || 2));
634
+ }
635
+
636
+ function renderFooter() {
637
+ const model = footer ? footer() : null;
638
+ const start = syncFootStart(model?.start || [], (verb) => verb.id);
639
+ const end = syncFootEnd(model?.end || [], (verb) => verb.id);
640
+ drawnFoot = new Map([...[...start].map(([key, entry]) => [`start:${key}`, entry]),
641
+ ...[...end].map(([key, entry]) => [`end:${key}`, entry])]);
642
+ footKeys.replaceChildren(...(model?.keys || []).map(({ keys, label: words }) => {
643
+ const hint = part('span', 'twm-wspicker__key-hint');
644
+ hint.append(...keys.map((key) => part('kbd', '', key)), ` ${words}`);
645
+ return hint;
646
+ }));
647
+ const { text = '', error = false } = typeof status === 'string' ? { text: status } : status ?? {};
648
+ setText(footStatus, text);
649
+ show(footStatus, !!text);
650
+ footStatus.classList.toggle('twm-wspicker__status--error', !!error);
651
+ const keysShown = footKeys.childElementCount > 0 && panel.classList.contains('twm-wspicker--centered');
652
+ show(foot, !!text || keysShown || drawnFoot.size > 0);
653
+ }
654
+
655
+ // ── the highlight ───────────────────────────────────────────────────
656
+ function collectStops(rowEntries, actionEntries, shown) {
657
+ const next = [];
658
+ for (const item of shown) {
659
+ const entry = rowEntries.get(idOf(item));
660
+ if (entry?.run) next.push({ key: `row:${idOf(item)}`, node: entry.node, run: entry.run, remove: entry.remove, primary: true });
661
+ }
662
+ if (!more.hidden) next.push({ key: 'more', node: more, run: () => loadMore({ byKey: true }) });
663
+ for (const [key, entry] of actionEntries) next.push({ key: `action:${key}`, node: entry.node, run: entry.run, primary: true });
664
+ if (!cardEl.hidden) {
665
+ if (!cardOpen.hidden) next.push({ key: 'card-open', node: cardOpen, run: () => cardOpenRun?.() });
666
+ for (const [key, entry] of drawnTiles) next.push({ key: `tile:${key}`, node: entry.node, run: entry.run });
667
+ }
668
+ for (const [key, entry] of drawnFoot) next.push({ key: `foot:${key}`, node: entry.node, run: entry.run });
669
+ stops = next;
670
+ syncTabStops();
671
+ }
672
+
673
+ /** One tab stop per list of rows: the row last focused, else the current one, else the first. */
674
+ function syncTabStops() {
675
+ const currentKey = current == null ? null : `row:${idOf(current)}`;
676
+ for (const prefix of ['row:', 'action:']) {
677
+ const rows = stops.filter((s) => s.key.startsWith(prefix));
678
+ const chosen = rows.find((s) => s.key === rovingKeys[prefix])
679
+ ?? rows.find((s) => s.key === currentKey) ?? rows[0];
680
+ for (const stop of rows) stop.node.tabIndex = stop === chosen ? 0 : -1;
681
+ }
682
+ }
683
+
684
+ function paintActive() {
685
+ const stop = stops.find((s) => s.key === activeKey) ?? null;
686
+ if (activeNode && activeNode !== stop?.node) activeNode.classList.remove('twm-wspicker__stop--active');
687
+ activeNode = stop?.node ?? null;
688
+ activeNode?.classList.add('twm-wspicker__stop--active');
689
+ if (activeNode?.id) input.setAttribute('aria-activedescendant', activeNode.id);
690
+ else input.removeAttribute('aria-activedescendant');
691
+ }
692
+
693
+ function revealActive() {
694
+ activeNode?.scrollIntoView?.({ block: 'nearest' });
695
+ }
696
+
697
+ function settleActive() {
698
+ let reveal = false;
699
+ if (selectId != null && stops.some((s) => s.key === `row:${selectId}`)) {
700
+ activeKey = `row:${selectId}`;
701
+ moved = true;
702
+ selectId = null;
703
+ reveal = true;
704
+ }
705
+ if (!moved || !stops.some((s) => s.key === activeKey)) {
706
+ reveal = moved;
707
+ // The stop that held focus went, with its node or by being hidden: the
708
+ // keyboard must not be left on the body, where none of this panel's keys
709
+ // are heard, or on what no longer is a stop.
710
+ const at = document.activeElement;
711
+ if (moved && isOpen && (at === document.body || (panel.contains(at) && at !== input))) input.focus();
712
+ moved = false;
713
+ activeKey = stops.find((s) => s.primary)?.key ?? null;
714
+ }
715
+ paintActive();
716
+ if (reveal) revealActive();
717
+ }
718
+
719
+ /** Focus on `stop`, without the browser's own scroll: `revealActive` is the one that may. */
720
+ function focusStop(stop) {
721
+ activeKey = stop.key;
722
+ moved = true;
723
+ stop.node.focus({ preventScroll: true });
724
+ paintActive();
725
+ }
726
+
727
+ function move(step) {
728
+ const walk = [input, ...stops.map((s) => s.node)];
729
+ // `open({ select })` chose a row and left the caret in the input: the arrows
730
+ // go on from that row.
731
+ const from = document.activeElement === input && moved ? activeNode : document.activeElement;
732
+ const next = walk[(walk.indexOf(from) + step + walk.length) % walk.length];
733
+ if (next === input) {
734
+ input.focus();
735
+ return;
736
+ }
737
+ focusStop(stops.find((s) => s.node === next));
738
+ revealActive();
739
+ }
740
+
741
+ // ── drawing ─────────────────────────────────────────────────────────
742
+ function render() {
743
+ const shown = shownItems();
744
+ const query = remote ? answered : typed();
745
+ const rowEntries = syncRows(shown, idOf);
746
+ const actionEntries = syncActions(shownActions(), (action) => action.id);
747
+
748
+ const words = heading ? heading({ query, shown: shown.length, total }) : null;
749
+ show(head, !!(words?.title || words?.count));
750
+ setText(headTitle, words?.title);
751
+ setText(headCount, words?.count);
752
+
753
+ let said = '';
754
+ if (failure) said = failure;
755
+ else if (!shown.length && (loading || total == null)) said = labels.loading;
756
+ else if (!shown.length) said = query ? labels.noMatch(query) : labels.empty();
757
+ setText(note, said);
758
+ show(note, !!said);
759
+ note.classList.toggle('twm-wspicker__note--error', !!failure);
760
+
761
+ const remaining = remote && total != null ? Math.max(0, total - items.length) : 0;
762
+ show(more, remaining > 0 && !failure);
763
+ if (remaining > 0) {
764
+ setText(moreLabel, labels.loadMore({ next: Math.min(pageSize, remaining), shown: items.length, total }));
765
+ }
766
+ // Not `disabled`: that would drop focus on the body. `loadMore` refuses for itself.
767
+ more.setAttribute('aria-disabled', String(loading || loadingMore));
768
+
769
+ collectStops(rowEntries, actionEntries, shown);
770
+ settleActive();
771
+ }
772
+
773
+ function renderEverything() {
774
+ renderCard();
775
+ renderFooter();
776
+ render();
777
+ }
778
+
779
+ // ── asking ──────────────────────────────────────────────────────────
780
+ function normalize(answer) {
781
+ if (Array.isArray(answer)) return { items: answer, total: answer.length, status: null };
782
+ const list_ = answer?.items || [];
783
+ return {
784
+ items: list_,
785
+ total: Number.isFinite(answer?.total) ? answer.total : list_.length,
786
+ status: answer?.status ?? null,
787
+ };
788
+ }
789
+
790
+ function unique(all) {
791
+ const seen = new Set();
792
+ return all.filter((item) => {
793
+ const id = idOf(item);
794
+ if (seen.has(id)) return false;
795
+ seen.add(id);
796
+ return true;
797
+ });
798
+ }
799
+
800
+ async function ask(query) {
801
+ const mine = ++seq;
802
+ asked = query;
803
+ abort?.abort();
804
+ abort = new AbortController();
805
+ const { signal } = abort;
806
+ loading = true;
807
+ failure = null;
808
+ loadingMore = false;
809
+ render();
810
+ try {
811
+ const answer = normalize(await load({ query, offset: 0, limit: pageSize, signal }));
812
+ if (destroyed || mine !== seq) return;
813
+ items = unique(answer.items);
814
+ total = answer.total;
815
+ status = answer.status;
816
+ if (query !== answered) scroller.scrollTop = 0;
817
+ answered = query;
818
+ loading = false;
819
+ if (!query) kept.set(cacheKey(), { items: [...items], total, status });
820
+ renderEverything();
821
+ if (placementNow() === 'below') place();
822
+ if (pickFirst) {
823
+ pickFirst = false;
824
+ stops.find((s) => s.primary)?.run();
825
+ }
826
+ } catch (err) {
827
+ if (destroyed || mine !== seq || err?.name === 'AbortError') return;
828
+ loading = false;
829
+ pickFirst = false;
830
+ items = [];
831
+ total = null;
832
+ status = null;
833
+ answered = query;
834
+ failure = labels.failed(err);
835
+ renderEverything();
836
+ }
837
+ }
838
+
839
+ async function loadMore({ byKey = false } = {}) {
840
+ if (loading || loadingMore || total == null || items.length >= total) return;
841
+ const mine = seq;
842
+ loadingMore = true;
843
+ render();
844
+ let added = [];
845
+ try {
846
+ const answer = normalize(await load({ query: answered, offset: items.length, limit: pageSize, signal: abort?.signal }));
847
+ if (destroyed || mine !== seq) return;
848
+ const seen = new Set(items.map(idOf));
849
+ added = unique(answer.items).filter((item) => !seen.has(idOf(item)));
850
+ items = items.concat(added);
851
+ // A server that ignores the paging hands back what is drawn: there
852
+ // is no more, whatever it said before.
853
+ total = added.length && Number.isFinite(answer.total) ? answer.total : items.length;
854
+ if (answer.status != null) status = answer.status;
855
+ } catch (err) {
856
+ if (destroyed || mine !== seq || err?.name === 'AbortError') return;
857
+ failure = labels.failedMore(err);
858
+ }
859
+ loadingMore = false;
860
+ // Focus stays on Load more while there is more, else goes to the first
861
+ // row it brought that can be chosen. Only the keyboard scrolls to it.
862
+ const stayed = activeKey === 'more';
863
+ if (stayed && items.length >= total) {
864
+ const first = added.find((item) => !modelOf(item).disabled);
865
+ if (first) {
866
+ activeKey = `row:${idOf(first)}`;
867
+ moved = true;
868
+ }
869
+ }
870
+ renderEverything();
871
+ if (stayed) {
872
+ const stop = stops.find((s) => s.key === activeKey);
873
+ if (stop) focusStop(stop);
874
+ if (byKey) revealActive();
875
+ }
876
+ }
877
+
878
+ more.addEventListener('click', () => loadMore());
879
+
880
+ function setBusy(waiting) {
881
+ busy = waiting;
882
+ panel.classList.toggle('twm-wspicker--waiting', waiting);
883
+ backdrop.classList.toggle('twm-wspicker-backdrop--waiting', waiting);
884
+ }
885
+
886
+ async function removeItem(item) {
887
+ if (busy) return;
888
+ const removedKey = `row:${idOf(item)}`;
889
+ const at = stops.findIndex((s) => s.key === removedKey);
890
+ const following = stops.slice(at + 1).find((s) => s.key.startsWith('row:'))
891
+ ?? stops.slice(0, Math.max(at, 0)).reverse().find((s) => s.key.startsWith('row:'));
892
+ const fromRow = stops[at]?.node === document.activeElement;
893
+ // Before the call: a dialog it opens takes focus before it returns.
894
+ setBusy(true);
895
+ let outcome;
896
+ try {
897
+ outcome = await onRemove(item);
898
+ } catch (err) {
899
+ setBusy(false);
900
+ if (isOpen) {
901
+ failure = labels.failedRemove(err);
902
+ render();
903
+ input.focus();
904
+ }
905
+ return;
906
+ }
907
+ setBusy(false);
908
+ if (!isOpen) return;
909
+ if (outcome !== false) {
910
+ items = items.filter((other) => idOf(other) !== idOf(item));
911
+ if (total != null) total = Math.max(0, total - 1);
912
+ if (activeKey === removedKey && following) {
913
+ activeKey = following.key;
914
+ moved = true;
915
+ }
916
+ ask(remote ? answered : '');
917
+ if (fromRow && following) {
918
+ focusStop(following);
919
+ revealActive();
920
+ return;
921
+ }
922
+ }
923
+ input.focus();
924
+ }
925
+
926
+ // ── choosing ────────────────────────────────────────────────────────
927
+ function pick(item) {
928
+ const same = isCurrent(item);
929
+ close({ restoreFocus: true });
930
+ if (!same) onPick?.(item);
931
+ }
932
+
933
+ function runVerb(run) {
934
+ const query = typed();
935
+ close({ restoreFocus: true });
936
+ run?.({ query });
937
+ }
938
+
939
+ function enter() {
940
+ const query = typed();
941
+ if (remote && query !== asked) {
942
+ clearTimeout(timer);
943
+ timer = null;
944
+ pickFirst = true;
945
+ ask(query);
946
+ return;
947
+ }
948
+ if (loading && !moved) {
949
+ pickFirst = true;
950
+ return;
951
+ }
952
+ stops.find((s) => s.key === activeKey)?.run();
953
+ }
954
+
955
+ // ── placing ─────────────────────────────────────────────────────────
956
+ function placementNow() {
957
+ if (!anchor?.isConnected) return 'centered';
958
+ return anchor.getBoundingClientRect().width > 0 ? placement : 'centered';
959
+ }
960
+
961
+ function place() {
962
+ const mode = placementNow();
963
+ for (const name of ['over', 'below', 'centered']) {
964
+ panel.classList.toggle(`twm-wspicker--${name}`, mode === name);
965
+ }
966
+ show(backdrop, mode === 'centered');
967
+ for (const side of ['top', 'bottom', 'left', 'right', 'width', 'maxHeight', 'overflowY', 'position']) panel.style[side] = '';
968
+ if (mode === 'centered') return;
969
+ const r = anchor.getBoundingClientRect();
970
+ const width = Math.min(Math.max(r.width, minWidth), window.innerWidth - 2 * PANEL_EDGE);
971
+ panel.style.width = `${width}px`;
972
+ if (mode === 'below') {
973
+ ActionDropdown.position(anchor, panel);
974
+ return;
975
+ }
976
+ const left = r.left + r.width / 2 - width / 2;
977
+ panel.style.left = `${Math.max(PANEL_EDGE, Math.min(left, window.innerWidth - width - PANEL_EDGE))}px`;
978
+ // A field in the lower half (a bottom bar) grows upward, its bottom edge
979
+ // where the field's is, because a list hanging below it has no room.
980
+ if (r.top + r.height / 2 > window.innerHeight / 2) {
981
+ panel.style.bottom = `${Math.max(2, window.innerHeight - r.bottom - OVER_LIFT)}px`;
982
+ } else {
983
+ panel.style.top = `${Math.max(2, r.top - OVER_LIFT)}px`;
984
+ }
985
+ }
986
+
987
+ // ── opening and closing ─────────────────────────────────────────────
988
+ function openPanel({ select = null } = {}) {
989
+ if (isOpen || destroyed) return;
990
+ isOpen = true;
991
+ opener = document.activeElement;
992
+ selectId = select == null ? null : String(select);
993
+ input.value = '';
994
+ clearTimeout(timer);
995
+ timer = null;
996
+ pickFirst = false;
997
+ moved = false;
998
+ activeKey = null;
999
+ delete rovingKeys['row:'];
1000
+ delete rovingKeys['action:'];
1001
+ const last = kept.get(cacheKey());
1002
+ items = last ? [...last.items] : [];
1003
+ total = last ? last.total : null;
1004
+ status = last ? last.status : null;
1005
+ answered = '';
1006
+ asked = '';
1007
+ failure = null;
1008
+ document.body.append(backdrop, panel);
1009
+ place();
1010
+ renderEverything();
1011
+ scroller.scrollTop = 0;
1012
+ document.addEventListener('mousedown', onDocumentDown, true);
1013
+ document.addEventListener('keydown', onDocumentKey);
1014
+ window.addEventListener('resize', place);
1015
+ input.focus();
1016
+ onOpenChange?.(true);
1017
+ ask('');
1018
+ }
1019
+
1020
+ function close({ restoreFocus = false } = {}) {
1021
+ if (!isOpen) return;
1022
+ isOpen = false;
1023
+ clearTimeout(timer);
1024
+ timer = null;
1025
+ pickFirst = false;
1026
+ selectId = null;
1027
+ // An answer that lands after the panel closed paints nothing.
1028
+ seq += 1;
1029
+ abort?.abort();
1030
+ loading = false;
1031
+ loadingMore = false;
1032
+ setBusy(false);
1033
+ panel.remove();
1034
+ backdrop.remove();
1035
+ document.removeEventListener('mousedown', onDocumentDown, true);
1036
+ document.removeEventListener('keydown', onDocumentKey);
1037
+ window.removeEventListener('resize', place);
1038
+ onOpenChange?.(false);
1039
+ if (restoreFocus && !destroyed) {
1040
+ const back = opener?.isConnected && opener !== document.body ? opener : anchor;
1041
+ back?.focus?.();
1042
+ }
1043
+ opener = null;
1044
+ }
1045
+
1046
+ /** In the capture phase, so a surface that stops the event still closes it. */
1047
+ function onDocumentDown(event) {
1048
+ if (busy || panel.contains(event.target) || anchor?.contains?.(event.target)) return;
1049
+ close();
1050
+ }
1051
+
1052
+ /** Esc with the focus outside the panel: on the anchor, say. */
1053
+ function onDocumentKey(event) {
1054
+ if (busy || event.key !== 'Escape' || panel.contains(event.target)) return;
1055
+ event.preventDefault();
1056
+ event.stopPropagation();
1057
+ close({ restoreFocus: true });
1058
+ }
1059
+
1060
+ // A press in the panel leaves focus where it is: on a row or a button it is
1061
+ // about to act and close; anywhere else it would drop the keyboard on nothing.
1062
+ panel.addEventListener('mousedown', (event) => {
1063
+ if (event.target !== input) event.preventDefault();
1064
+ });
1065
+
1066
+ // Focus gets here by the arrows, Tab or a script, and the highlight goes with it.
1067
+ panel.addEventListener('focusin', ({ target, relatedTarget }) => {
1068
+ if (target === input) {
1069
+ // Back from a stop, not in from outside: `open({ select })` chose first.
1070
+ if (panel.contains(relatedTarget)) {
1071
+ moved = false;
1072
+ settleActive();
1073
+ }
1074
+ return;
1075
+ }
1076
+ const stop = stops.find((s) => s.node === target);
1077
+ if (!stop) return;
1078
+ activeKey = stop.key;
1079
+ moved = true;
1080
+ // The reader has gone on: an answer still to land does not pull the highlight back.
1081
+ selectId = null;
1082
+ const prefix = ['row:', 'action:'].find((p) => stop.key.startsWith(p));
1083
+ if (prefix) rovingKeys[prefix] = stop.key;
1084
+ syncTabStops();
1085
+ paintActive();
1086
+ });
1087
+
1088
+ input.addEventListener('input', () => {
1089
+ moved = false;
1090
+ scroller.scrollTop = 0;
1091
+ if (remote) {
1092
+ clearTimeout(timer);
1093
+ const query = typed();
1094
+ timer = setTimeout(() => {
1095
+ timer = null;
1096
+ if (query !== asked) ask(query);
1097
+ }, debounceMs);
1098
+ }
1099
+ render();
1100
+ });
1101
+
1102
+ panel.addEventListener('keydown', (event) => {
1103
+ if (event.isComposing || busy) return;
1104
+ const { key, shiftKey, target } = event;
1105
+ const handled = () => {
1106
+ event.preventDefault();
1107
+ event.stopPropagation();
1108
+ };
1109
+ const stop = stops.find((s) => s.node === target);
1110
+ if (key === 'Escape') {
1111
+ handled();
1112
+ close({ restoreFocus: true });
1113
+ } else if (key === 'ArrowDown' || key === 'ArrowUp') {
1114
+ handled();
1115
+ move(key === 'ArrowDown' ? 1 : -1);
1116
+ } else if (key === 'Enter' && target === input) {
1117
+ handled();
1118
+ enter();
1119
+ } else if ((key === 'Enter' || key === ' ') && stop) {
1120
+ // A row is no button, because it holds one; and Load more scrolls for
1121
+ // a key and not for a click.
1122
+ handled();
1123
+ stop.run();
1124
+ } else if (key === 'Delete' && shiftKey) {
1125
+ handled();
1126
+ stops.find((s) => s.key === activeKey)?.remove?.();
1127
+ } else if (key === 'Backspace' && target !== input) {
1128
+ // A window manager's keymap reads Backspace on anything that is not
1129
+ // a field as "up one level"; a row or a button here is not a field.
1130
+ handled();
1131
+ } else if (target !== input && key.length === 1 && key !== ' '
1132
+ && !event.ctrlKey && !event.metaKey && !event.altKey) {
1133
+ // Moved before the press lands, so the character lands in the input;
1134
+ // and no further, because a keymap would read it as a shortcut: the
1135
+ // press began on something that is not a field.
1136
+ event.stopPropagation();
1137
+ input.focus();
1138
+ }
1139
+ });
1140
+
1141
+ panel.addEventListener('focusout', (event) => {
1142
+ const to = event.relatedTarget;
1143
+ if (!busy && to && !panel.contains(to) && !anchor?.contains?.(to)) close();
1144
+ });
1145
+
1146
+ return {
1147
+ panel,
1148
+ input,
1149
+ get isOpen() { return isOpen; },
1150
+ get current() { return current; },
1151
+ open: openPanel,
1152
+ close,
1153
+ toggle() {
1154
+ if (isOpen) close({ restoreFocus: true });
1155
+ else openPanel();
1156
+ },
1157
+ /** Which one the window is in, whether or not an answer holds it. */
1158
+ setCurrent(item) {
1159
+ current = item ?? null;
1160
+ if (isOpen) renderEverything();
1161
+ },
1162
+ /** Ask `actions`, `card` and `footer` again: who may do what changed. */
1163
+ refresh() {
1164
+ if (isOpen) renderEverything();
1165
+ },
1166
+ /** Ask `load` again: the list changed behind the switcher's back. */
1167
+ reload() {
1168
+ if (isOpen) ask(remote ? typed() : '');
1169
+ },
1170
+ destroy() {
1171
+ close();
1172
+ destroyed = true;
1173
+ },
1174
+ };
1175
+ }
1176
+
1177
+ /**
1178
+ * The field and the switcher together: a click on the field, or the chord,
1179
+ * opens the switcher in the field's place; `setCurrent` names the current item
1180
+ * on the field and marks it in the list.
1181
+ *
1182
+ * `chord` is none by default. Given (`'Ctrl+P'`; Ctrl also answers to Cmd), it
1183
+ * is taken on `window` in the capture phase, ahead of an embedded editor and of
1184
+ * the browser's own use of the key, and it is the key hint unless
1185
+ * `field.keyHint` says otherwise. It still works while the field is hidden for
1186
+ * want of room.
1187
+ *
1188
+ * @param {HTMLElement} hostEl - Where the field mounts
1189
+ * @param {object} options - `createPickerSwitcher`'s, and:
1190
+ * @param {object} [options.field] - `mountPickerField`'s. `field.placeholder` is
1191
+ * its name while nothing is current; `field.icon`, when given, is its icon
1192
+ * whatever is current, and otherwise the current item's `icon` is.
1193
+ * @param {string} [options.chord]
1194
+ */
1195
+ export function createWorkspacePicker(hostEl, { field: fieldOptions = {}, chord = null, ...switcherOptions }) {
1196
+ const { placeholder = '', icon: fixedIcon, ...mountOptions } = fieldOptions;
1197
+ const parsed = chord ? parseChord(chord) : null;
1198
+ const field = mountPickerField(hostEl, {
1199
+ ariaLabel: switcherOptions.label,
1200
+ keyHint: chord ?? '',
1201
+ ...mountOptions,
1202
+ onActivate: () => switcher.toggle(),
1203
+ });
1204
+ const switcher = createPickerSwitcher({
1205
+ ...switcherOptions,
1206
+ anchor: field.el,
1207
+ onOpenChange: (open) => {
1208
+ field.setOpen(open);
1209
+ switcherOptions.onOpenChange?.(open);
1210
+ },
1211
+ });
1212
+
1213
+ const paint = () => {
1214
+ const item = switcher.current;
1215
+ const model = item == null ? null : switcherOptions.describe(item, { current: true });
1216
+ field.update({
1217
+ name: model ? model.name : placeholder,
1218
+ icon: fixedIcon ?? model?.icon ?? '',
1219
+ muted: !model,
1220
+ });
1221
+ };
1222
+ paint();
1223
+
1224
+ const onKeydown = (event) => {
1225
+ if (!chordMatches(parsed, event)) return;
1226
+ event.preventDefault();
1227
+ event.stopPropagation();
1228
+ if (!event.repeat) switcher.toggle();
1229
+ };
1230
+ if (parsed) window.addEventListener('keydown', onKeydown, true);
1231
+
1232
+ return {
1233
+ field,
1234
+ switcher,
1235
+ get el() { return field.el; },
1236
+ get isOpen() { return switcher.isOpen; },
1237
+ get current() { return switcher.current; },
1238
+ open: switcher.open,
1239
+ close: switcher.close,
1240
+ toggle: switcher.toggle,
1241
+ refresh: switcher.refresh,
1242
+ reload: switcher.reload,
1243
+ setCurrent(item) {
1244
+ switcher.setCurrent(item);
1245
+ paint();
1246
+ },
1247
+ destroy() {
1248
+ if (parsed) window.removeEventListener('keydown', onKeydown, true);
1249
+ switcher.destroy();
1250
+ field.destroy();
1251
+ },
1252
+ };
1253
+ }