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