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.
- package/CHANGELOG.md +91 -0
- package/README.md +1 -0
- package/css/flexdesk.css +7547 -5891
- package/css/reset.css +1 -1
- package/css/tokens.css +30 -1
- package/dist/charts.js +83 -13
- package/dist/charts.js.map +2 -2
- package/dist/chunk-3BLIVOFD.js +778 -0
- package/dist/chunk-3BLIVOFD.js.map +7 -0
- package/dist/{chunk-7OL6F2AM.js → chunk-4X4SY3CP.js} +7 -5
- package/dist/chunk-4X4SY3CP.js.map +7 -0
- package/dist/chunk-DNJ3N4T4.js +82 -0
- package/dist/chunk-DNJ3N4T4.js.map +7 -0
- package/dist/{chunk-PGZ4EHSE.js → chunk-HL3IIHU3.js} +501 -359
- package/dist/chunk-HL3IIHU3.js.map +7 -0
- package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
- package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
- package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
- package/dist/{chunk-XJNADRGR.js → chunk-NFZJ6P42.js} +188 -96
- package/dist/chunk-NFZJ6P42.js.map +7 -0
- package/dist/chunk-PAI2ZUWV.js +260 -0
- package/dist/chunk-PAI2ZUWV.js.map +7 -0
- package/dist/chunk-Q7BALEPX.js +527 -0
- package/dist/chunk-Q7BALEPX.js.map +7 -0
- package/dist/{chunk-2Z2ENHR3.js → chunk-QRBOIUU3.js} +7 -8
- package/dist/chunk-QRBOIUU3.js.map +7 -0
- package/dist/chunk-QXAOTGCC.js +40 -0
- package/dist/chunk-QXAOTGCC.js.map +7 -0
- package/dist/chunk-UKP22RUG.js +219 -0
- package/dist/chunk-UKP22RUG.js.map +7 -0
- package/dist/chunk-VLOAI3HS.js +43 -0
- package/dist/chunk-VLOAI3HS.js.map +7 -0
- package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
- package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
- package/dist/editor.js +3 -2
- package/dist/editor.js.map +1 -1
- package/dist/flexdesk.css +7547 -5891
- package/dist/flow.js +29 -97
- package/dist/flow.js.map +3 -3
- package/dist/git.js +1356 -0
- package/dist/git.js.map +7 -0
- package/dist/reset.css +1 -1
- package/dist/tiles.js +20 -15
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +30 -1
- package/dist/widgets.js +4146 -1199
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +120 -32
- package/dist/wm.js.map +2 -2
- package/docs/README.md +9 -4
- package/docs/best-practices.md +2 -2
- package/docs/components/charts.md +40 -0
- package/docs/components/chat-shell.md +204 -0
- package/docs/components/conversation.md +10 -0
- package/docs/components/filter-builder.md +292 -0
- package/docs/components/forms-and-dialogs.md +30 -0
- package/docs/components/git.md +355 -0
- package/docs/components/input-fields.md +191 -2
- package/docs/components/keymap.md +8 -0
- package/docs/components/menus.md +86 -2
- package/docs/components/panels-and-desktops.md +16 -5
- package/docs/components/shell.md +108 -1
- package/docs/components/workspace-picker.md +193 -0
- package/docs/concepts/building-and-packaging.md +2 -2
- package/docs/concepts/packages-and-entry-points.md +14 -13
- package/docs/concepts/state-guard.md +3 -3
- package/docs/quickstart.md +1 -1
- package/git.js +13 -0
- package/package.json +6 -1
- package/src/charts/plot_config_editor.js +92 -6
- package/src/charts/plot_grid.js +6 -1
- package/src/flow/kit/dom.js +28 -119
- package/src/flow/kit/step_picker.js +1 -1
- package/src/flow/kit/value_picker.js +1 -1
- package/src/flow/outline/menu.js +1 -1
- package/src/git/diff_viewer.js +212 -0
- package/src/git/diff_window.js +51 -0
- package/src/git/git_graph.js +401 -0
- package/src/git/git_ops.js +61 -0
- package/src/git/git_panel.js +755 -0
- package/src/git/index.js +9 -0
- package/src/git/line_diff.js +202 -0
- package/src/tiles/tile_base.js +5 -4
- package/src/tiling/desktops.js +10 -8
- package/src/tiling/keymap.js +6 -5
- package/src/tiling/kind_taxonomy.js +15 -2
- package/src/tiling/shell.js +124 -17
- package/src/tiling/tile_renderer.js +2 -3
- package/src/ui/components/action_dropdown.js +4 -78
- package/src/ui/components/anchored_layer.js +255 -0
- package/src/ui/components/chat_shell.js +926 -0
- package/src/ui/components/chat_state_machine.js +223 -0
- package/src/ui/components/choice_controls.js +45 -2
- package/src/ui/components/color_field.js +3 -2
- package/src/ui/components/color_list_field.js +3 -2
- package/src/ui/components/combobox.js +422 -0
- package/src/ui/components/context_menu.js +8 -2
- package/src/ui/components/conversation_view.js +17 -2
- package/src/ui/components/data_table.js +2 -2
- package/src/ui/components/filter_builder.js +791 -0
- package/src/ui/components/filter_model.js +682 -0
- package/src/ui/components/inline_renamer.js +10 -5
- package/src/ui/components/managed_window.js +24 -47
- package/src/ui/components/modal.js +104 -117
- package/src/ui/components/option_list.js +160 -0
- package/src/ui/components/popover.js +94 -0
- package/src/ui/components/recent_items.js +169 -0
- package/src/ui/components/select.js +322 -0
- package/src/ui/components/tag_input.js +77 -8
- package/src/ui/components/workspace_picker.js +1253 -0
- package/src/ui/utils/fire_event.js +17 -0
- package/src/ui/utils/focusable.js +25 -0
- package/src/ui/utils/modal_host.js +27 -0
- package/src/ui/utils/tooltip_service.js +81 -14
- package/widgets.js +12 -2
- package/css/base.css +0 -10547
- package/css/overrides.css +0 -2596
- package/dist/chunk-2Z2ENHR3.js.map +0 -7
- package/dist/chunk-7JI7HHSY.js +0 -107
- package/dist/chunk-7JI7HHSY.js.map +0 -7
- package/dist/chunk-7OL6F2AM.js.map +0 -7
- package/dist/chunk-MIW7D6WC.js +0 -526
- package/dist/chunk-MIW7D6WC.js.map +0 -7
- package/dist/chunk-PGZ4EHSE.js.map +0 -7
- package/dist/chunk-XJNADRGR.js.map +0 -7
- /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
|
+
}
|