flexdesk 0.18.0 → 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 +38 -0
- package/css/flexdesk.css +33 -5
- package/css/tokens.css +7 -0
- package/dist/charts.js +78 -12
- package/dist/charts.js.map +2 -2
- package/dist/{chunk-5W4EEKSK.js → chunk-3BLIVOFD.js} +3 -3
- package/dist/{chunk-P74F6I6P.js → chunk-4X4SY3CP.js} +4 -4
- package/dist/{chunk-P74F6I6P.js.map → chunk-4X4SY3CP.js.map} +2 -2
- package/dist/{chunk-ZL3VKINP.js → chunk-HL3IIHU3.js} +494 -3
- package/dist/{chunk-ZL3VKINP.js.map → chunk-HL3IIHU3.js.map} +4 -4
- package/dist/{chunk-RKVYIXDC.js → chunk-NFZJ6P42.js} +19 -500
- package/dist/chunk-NFZJ6P42.js.map +7 -0
- package/dist/{chunk-U5GKJUOC.js → chunk-QRBOIUU3.js} +5 -6
- package/dist/chunk-QRBOIUU3.js.map +7 -0
- package/dist/flexdesk.css +33 -5
- package/dist/flow.js +1 -1
- package/dist/git.js +3 -3
- package/dist/tiles.js +4 -4
- package/dist/tokens.css +7 -0
- package/dist/widgets.js +226 -58
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +2 -2
- package/docs/components/charts.md +40 -0
- package/docs/components/chat-shell.md +8 -2
- package/docs/components/filter-builder.md +52 -6
- package/docs/components/forms-and-dialogs.md +8 -0
- package/docs/components/input-fields.md +24 -0
- package/docs/components/menus.md +4 -0
- package/docs/components/shell.md +4 -0
- package/docs/components/workspace-picker.md +46 -2
- package/package.json +1 -1
- package/src/charts/plot_config_editor.js +92 -6
- package/src/charts/plot_grid.js +6 -1
- package/src/ui/components/chat_shell.js +7 -1
- package/src/ui/components/context_menu.js +8 -2
- package/src/ui/components/filter_builder.js +53 -28
- package/src/ui/components/filter_model.js +86 -18
- package/src/ui/components/inline_renamer.js +10 -5
- package/src/ui/components/modal.js +25 -8
- package/src/ui/components/workspace_picker.js +125 -27
- package/src/ui/utils/tooltip_service.js +81 -14
- package/dist/chunk-RKVYIXDC.js.map +0 -7
- package/dist/chunk-U5GKJUOC.js.map +0 -7
- /package/dist/{chunk-5W4EEKSK.js.map → chunk-3BLIVOFD.js.map} +0 -0
package/dist/wm.js
CHANGED
|
@@ -9,13 +9,13 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
openForm,
|
|
11
11
|
showContextMenu
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-NFZJ6P42.js";
|
|
13
13
|
import {
|
|
14
14
|
isRowActivation
|
|
15
15
|
} from "./chunk-VLOAI3HS.js";
|
|
16
16
|
import {
|
|
17
17
|
ManagedWindow
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-HL3IIHU3.js";
|
|
19
19
|
import "./chunk-Q7BALEPX.js";
|
|
20
20
|
import "./chunk-HNU6MM2B.js";
|
|
21
21
|
import {
|
|
@@ -414,6 +414,10 @@ config it was given and then calls `onChange(config)`.
|
|
|
414
414
|
is disposed. A `dispose()` that throws is reported to `logger.warn` and does
|
|
415
415
|
not stop the rest of the editor being disposed.
|
|
416
416
|
|
|
417
|
+
- [`hide`](#hiding-parts-of-the-editor-hide-0181) takes parts of the editor away,
|
|
418
|
+
and [`constantsProvider`](#a-reference-line-that-names-a-constant-constantsprovider-resolveconstant-0181)
|
|
419
|
+
lets a reference line name a constant.
|
|
420
|
+
|
|
417
421
|
It returns `{ dispose, refresh, selectSubplot, activeSubplot }`: `dispose()`
|
|
418
422
|
disposes every field it created (every `AutocompleteField` among them) and
|
|
419
423
|
removes the editor; `refresh()` redraws it after the config was changed from
|
|
@@ -431,3 +435,39 @@ const win = await openPlotPopoutWindow({ id, title, plotConfig, getResults, serv
|
|
|
431
435
|
|
|
432
436
|
Its classes are under `.twm-plot-config` in `flexdesk.css`.
|
|
433
437
|
`tests/plot_config_editor.test.mjs` pins all of it.
|
|
438
|
+
|
|
439
|
+
## Hiding parts of the editor: `hide` (0.18.1)
|
|
440
|
+
|
|
441
|
+
An embedder whose plots are always 2D time series has no use for some of the editor:
|
|
442
|
+
|
|
443
|
+
```js
|
|
444
|
+
renderPlotConfigEditor(el, plotConfig, { hide: ['3d', 'stackGroup', 'xAxis'] });
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
| Name | What goes |
|
|
448
|
+
|---|---|
|
|
449
|
+
| `'3d'` | The 3D chart types and the z axis section. A subplot saved as 3D keeps its chart type, marked as one the list does not offer, until someone picks a 2D one. |
|
|
450
|
+
| `'stackGroup'` | Each series' stack group box. A saved stack group stays in the config and is drawn. |
|
|
451
|
+
| `'xAxis'` | The x axis section. The subplot then plots against time: `xAxis.useTime` is set to true when it opens, and `xAxis.variable` is left as it was. |
|
|
452
|
+
|
|
453
|
+
Any other name throws. Without `hide` the editor draws what it always did.
|
|
454
|
+
|
|
455
|
+
## A reference line that names a constant: `constantsProvider`, `resolveConstant` (0.18.1)
|
|
456
|
+
|
|
457
|
+
A reference line's value is a number, so it cannot follow a parameter whose value changes between runs. With `constantsProvider` it may also name one:
|
|
458
|
+
|
|
459
|
+
```js
|
|
460
|
+
renderPlotConfigEditor(el, plotConfig, {
|
|
461
|
+
constantsProvider: () => [{ name: 'bank.rate', value: 0.25, label: 'Bank rate', group: 'bank' }],
|
|
462
|
+
});
|
|
463
|
+
renderPlotGrid(host, plotConfig, results, {
|
|
464
|
+
resolveConstant: (name) => constants.get(name), // a number, or undefined
|
|
465
|
+
});
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
The value box then offers the names, with their values and under their groups, as `attachInputOptions` does. It is asked each time the list is drawn. What is stored is a number when what was typed is one, even when it is part of a name on the list, and the name as a string otherwise; beside a name it knows, the box shows `= 0.25`. An empty box stores `null`, as the number box does. A click shows every name.
|
|
469
|
+
|
|
470
|
+
`renderPlotGrid` asks `resolveConstant(name)` each time it draws a subplot, so a redraw follows a new value, and `grid.update({ results })` is enough. A name it does not know draws no line and no label, and so does any name without `resolveConstant`. The config keeps the name.
|
|
471
|
+
|
|
472
|
+
Without `constantsProvider` the box is the number box it always was.
|
|
473
|
+
|
|
@@ -62,7 +62,7 @@ So a transport that gives each run its own callback, and calls that run's `emit`
|
|
|
62
62
|
|
|
63
63
|
#### A backend with no run id
|
|
64
64
|
|
|
65
|
-
A backend that pushes to a global has one place to send a frame, and the frame does not say which run it is from. The adapter holds the latest `emit`, so the chat cannot tell an old run's chunk from the new reply's. Runs must therefore not overlap, and `cancel` is how: it answers a promise that settles when the cancelled run is over and the backend takes a new request. The next turn shows at once, with its typing dots, and its `send` is called when the promise settles. A promise that rejects is logged and counts as settled
|
|
65
|
+
A backend that pushes to a global has one place to send a frame, and the frame does not say which run it is from. The adapter holds the latest `emit`, so the chat cannot tell an old run's chunk from the new reply's. Runs must therefore not overlap, and `cancel` is how: it answers a promise that settles when the cancelled run is over and the backend takes a new request. The next turn shows at once, with its typing dots, and its `send` is called when the promise settles. A promise that rejects is logged and counts as settled. One that never settles, or a run-over that is lost, holds the next turn only until the user stops it: [Stop releases a turn that is waiting](#stop-releases-a-turn-that-is-waiting-0181).
|
|
66
66
|
|
|
67
67
|
```js
|
|
68
68
|
let emit = null;
|
|
@@ -99,6 +99,12 @@ const transport = {
|
|
|
99
99
|
|
|
100
100
|
While the run winds down, `emit` is still the cancelled turn's, so its late chunks and its report that it was cancelled are dropped, with no check of yours. The promise must not settle before the backend would accept a request: if the backend sends its last frame and only then frees itself, have it send the end of the run after that, and settle on that instead. A `cancel` that answers no promise says there is nothing to wait for, and `send` is called at once.
|
|
101
101
|
|
|
102
|
+
#### Stop releases a turn that is waiting (0.18.1)
|
|
103
|
+
|
|
104
|
+
A send made after a Stop waits for `cancel`'s promise. If that promise never settles, because the backend stalled or its run-over was lost, the turn shows its typing dots with nothing behind them. Stop on that turn releases the wait: it was never sent, so `transport.cancel` is not called for it, and the next send goes out at once.
|
|
105
|
+
|
|
106
|
+
The person has then said twice that they will not wait, and the chat does what they ask. So a backend whose old run may still be alive can see a request while it runs. One that takes one run at a time should refuse the overlapping request: the refusal shows as an error with Retry. One that accepts it will mix the old run's frames into the new reply, because they arrive through the one `emit` the adapter holds.
|
|
107
|
+
|
|
102
108
|
### A turn
|
|
103
109
|
|
|
104
110
|
The user's message goes in, then a streamed assistant message that shows the typing dots while it is empty. While the turn runs, Send is Cancel and Esc cancels, as in the conversation view, and the New and Clear buttons are disabled. A turn ends in one of three ways:
|
|
@@ -125,7 +131,7 @@ A reply still empty is not in a snapshot, and a tool that was `running` when a s
|
|
|
125
131
|
|---|---|
|
|
126
132
|
| `send(text)`, `cancel()` → bool | As the user would; false when refused |
|
|
127
133
|
| `isBusy()`, `messages()`, `snapshot()` | A turn is running; the messages in front; everything to keep |
|
|
128
|
-
| `activeConversation()
|
|
134
|
+
| `activeConversation()` → id, `newConversation()` → id, `switchTo(id)`, `close(id)`, `rename(id, title)`, `reset()` | The tabs. `activeConversation` answers the id of the one in front, not the conversation; `snapshot()` holds its messages. `reset` empties the one in front. Refused ones answer false (`newConversation`: null) |
|
|
129
135
|
| `setConversationId(tabId, id)` | Your backend's id for a conversation |
|
|
130
136
|
| `setMessages(tabId, messages)` | Replaces a conversation's messages, when your backend knows better. The one a turn is writing to refuses |
|
|
131
137
|
| `updateMeta(id, patch)`, `refreshControls(id)`, `controlsSlot(id)` | A message's controls, below |
|
|
@@ -92,14 +92,24 @@ A field naming an operator the catalogue lacks throws when the model is created.
|
|
|
92
92
|
|---|---|
|
|
93
93
|
| `matcherFor(expr)` | `(row) => boolean`, compiled once and cached by the expression's JSON. An unknown field or operator matches nothing and warns. |
|
|
94
94
|
| `evaluateFilter(expr, row)` | One row. |
|
|
95
|
-
| `describeFilter(expr, { formatValue })` | One line of words.
|
|
95
|
+
| `describeFilter(expr, { formatValue, groupBrackets })` | One line of words. [What it says](#what-a-sentence-says-0181). |
|
|
96
96
|
| `validateFilter(expr)` | `{ ok, errors }`, listing every problem, not the first. |
|
|
97
97
|
| `unknownFields(expr)` | The keys it names that the catalogue lacks, for a marker wherever a saved filter is listed. |
|
|
98
|
-
| `pruneFilter(expr)` | A copy without empty groups and conditions with nothing to compare against, or null when nothing is left. A condition the catalogue cannot read is kept. |
|
|
98
|
+
| `pruneFilter(expr)` | A copy without empty groups and conditions with nothing to compare against, or null when nothing is left. A condition the catalogue cannot read is kept, and so is a range with one end filled. |
|
|
99
|
+
| `partialRanges(expr)` | What is said of each range with one end filled: `Count between: needs two values`. [The dialog refuses one](#a-range-with-one-end-filled-is-refused-0181). |
|
|
100
|
+
| `moveClause(clause, key)` | The condition on another field. [What it keeps](#what-a-condition-keeps-when-its-field-changes-0181). Null when the catalogue has no such field. |
|
|
99
101
|
| `operatorsFor(key)`, `operatorById(id)`, `newClause(key?)`, `defaultValue(field, op)`, `fieldByKey(key)` | What the builder uses. |
|
|
100
102
|
|
|
101
103
|
`coerceFilterValue(field, text)` is the one place a control's string becomes a value. A `number` field writes a number when the string is a plain decimal a double holds exactly. It leaves the string alone when converting would change the filter: an empty box, a half-typed `-`, or a decimal wider than a double.
|
|
102
104
|
|
|
105
|
+
### What a sentence says (0.18.1)
|
|
106
|
+
|
|
107
|
+
`formatValue(value, field)` names each value (a key shown as a name, say); without it a value is shown as itself.
|
|
108
|
+
|
|
109
|
+
- **Every value of a list**, under any operator: `Tags contains a, b`, not the first alone. A saved condition or one from elsewhere may hold several under an operator that takes one.
|
|
110
|
+
- **A field the catalogue does not know is said, with what the condition asks:** `⚠ unknown field “gone” is x`. The `unknownName(key)` option of `createFilterModel` shapes the key shown. `formatValue` is not asked about it, because there is no field to hand it. An operator the catalogue does not know is said as its id, with its value.
|
|
111
|
+
- **Brackets** go where the sentence would misread without them: a nested group of two or more, and whatever a NOT covers: `A AND (B OR C)`, `NOT (A)`. With `groupBrackets: true` every group of two or more has them, the root too, and a NOT of one thing has none: `(A AND (B OR C))`, `NOT A`. That is for a surface that reads a group by its brackets.
|
|
112
|
+
|
|
103
113
|
## The condition tree (0.18.0)
|
|
104
114
|
|
|
105
115
|
A server that compiles conditions usually wants a different spelling. `toConditionTree(expr)` and `fromConditionTree(tree)` convert to and from:
|
|
@@ -179,6 +189,15 @@ Each group has an AND/OR toggle, NOT, `+ Condition` and `+ Group`; each conditio
|
|
|
179
189
|
|
|
180
190
|
**It rewrites nothing it cannot read.** A condition on a field the catalogue lacks, an operator it lacks, an operator this field is not offered, an `enum` value no longer offered and a carried node all stay exactly as they are until someone changes or removes them: the selects show them as their own entry (`⚠ unknown field “gone”`, `made_up (unknown)`), and a value it cannot interpret is shown read-only. Opening and closing a builder leaves the expression byte-identical, with one exception: an `enum` condition with no value takes the first option, because a select cannot show nothing.
|
|
181
191
|
|
|
192
|
+
### What a condition keeps when its field changes (0.18.1)
|
|
193
|
+
|
|
194
|
+
Choosing another field for a condition goes through `model.moveClause(clause, key)`:
|
|
195
|
+
|
|
196
|
+
- The **operator** stays when the new field offers it. Otherwise the condition starts on the field's first operator, as `newClause(key)` does.
|
|
197
|
+
- The **value** stays when it still fits: it passes `validateFilter` on the new field and, where the editor offers a list (an `enum` field, a `multi` operator on a field with `options`), it is on that list. Otherwise it starts as `defaultValue(field, op)`. `validateFilter` checks every value of a `multi` operator as it does a single one, so `one_of ['abc']` on a number field is refused and does not move there. A value that does fit is kept as it stands: `is '5'` moved to a number field is still the string `'5'`.
|
|
198
|
+
|
|
199
|
+
A condition whose field or operator cannot be drawn shows the value it holds read-only: an unknown field, an unknown operator, or a known operator its field does not offer. A box drawn for that operator would look empty over a value the result still carries. A custom editor is not asked about one. An unknown field's whole warning is the field select's tooltip, and the select takes its width from it where the row can spare that.
|
|
200
|
+
|
|
182
201
|
### A custom value editor (0.18.0)
|
|
183
202
|
|
|
184
203
|
`field.editor` replaces the value control. It is a factory, called each time the value cell is drawn:
|
|
@@ -226,21 +245,48 @@ const result = await openFilterDialog({
|
|
|
226
245
|
| Option | |
|
|
227
246
|
|---|---|
|
|
228
247
|
| `model`, `value` | As the builder's. `value` is copied, so dismissing loses nothing. |
|
|
229
|
-
| `seedField` | A field key: adds a new condition on it, for "filter by this". |
|
|
248
|
+
| `seedField` | A field key: adds a new condition on it, for "filter by this". [Beside a negated root](#a-seed-beside-a-negated-root-0181), not inside it. |
|
|
230
249
|
| `title`, `icon`, `width`, `height` | The window. |
|
|
231
250
|
| `head` | An element drawn above the conditions. |
|
|
232
|
-
| `name` | `{ value, label, placeholder, required }` adds a name box. `required` defaults to true and holds the submit button off until it is filled. The result's `name` is trimmed. |
|
|
251
|
+
| `name` | `{ value, label, placeholder, required }` adds a name box, which opens with focus and [its text selected](#the-name-box-opens-selected-0181). `required` defaults to true and holds the submit button off until it is filled. The result's `name` is trimmed. |
|
|
233
252
|
| `iconChoice` | `{ value, options }` adds a searchable grid over those Material Symbols names. |
|
|
234
253
|
| `preview` | `(host, { expr, sentence, signal })`: yours to draw into. Called on open and, debounced, after each change, while the expression can be submitted; otherwise the slot is emptied and hidden and the errors are shown. `signal` aborts when this drawing is superseded or the dialog closes, for a preview that asks a server. The dialog shows `sentence` above the slot. |
|
|
235
|
-
| `requireValid` | Default true: the submit button is off, and the errors are listed, while `validateFilter` objects. False submits regardless. |
|
|
254
|
+
| `requireValid` | Default true: the submit button is off, and the errors are listed, while `validateFilter` objects. False submits regardless, except [a range with one end filled](#a-range-with-one-end-filled-is-refused-0181). |
|
|
236
255
|
| `prune` | Validate, preview and submit `model.pruneFilter(...)`, so a half-built row is dropped rather than refused. `expr` is null when nothing is left. |
|
|
237
256
|
| `confirmDiscard` | Ask before Cancel, Esc or the X throws away a change. |
|
|
257
|
+
| `dismissOnBackdrop` | [A click on the dimmed page beside the dialog dismisses it](#a-click-beside-the-dialog-dismissonbackdrop-0181), as Esc does. |
|
|
238
258
|
| `submitLabel`, `onSubmit` | `onSubmit(result)` may be async: until it settles every button is off and Esc and the X do nothing, so the answer always says what happened. If it throws, the message is shown and the dialog stays. A promise that never settles holds the dialog open, so give a call that can hang its own timeout. |
|
|
239
259
|
| `clearLabel` | Adds a button answering `{ action: 'clear', expr: null }`, for a caller to whom "no filter" and "leave it alone" are different answers. |
|
|
240
|
-
| `actions` | More buttons, before Cancel: `{ label, value, danger, icon, confirm, run }`. `confirm` is `openConfirm`'s options, asked first, and `run` behaves as `onSubmit` does. |
|
|
260
|
+
| `actions` | More buttons, before Cancel: `{ label, value, danger, icon, confirm, run }`. `confirm` is `openConfirm`'s options, asked first, [with a `message` that may be computed](#a-question-that-quotes-what-was-typed-0181), and `run` behaves as `onSubmit` does. |
|
|
241
261
|
|
|
242
262
|
`confirmDiscard` asks through [`openModal`'s `beforeClose`](forms-and-dialogs.md#a-modal-that-can-refuse-to-close-beforeclose-0180).
|
|
243
263
|
|
|
264
|
+
### A range with one end filled is refused (0.18.1)
|
|
265
|
+
|
|
266
|
+
`between` with `[5, null]` is not blank, so `prune` keeps it, and it matches nothing: with `requireValid: false` it used to leave the dialog, and a filter that a server compiles to `BETWEEN 5 AND NULL` emptied the view with no word why. The submit button is now off and the dialog says `Count between: needs two values`, whatever `requireValid` and `prune` say. A range with both ends blank is still a half-built row, which `prune` drops.
|
|
267
|
+
|
|
268
|
+
`model.pruneFilter` keeps such a range for a caller without the dialog, and `model.partialRanges(expr)` names them.
|
|
269
|
+
|
|
270
|
+
### A seed beside a negated root (0.18.1)
|
|
271
|
+
|
|
272
|
+
A new condition narrows what the filter already says. Pushed into a negated root it would become `NOT (title is x AND qty is 7)`, and rows the filter excluded would come back. So `seedField` on a negated root wraps it: `(NOT (title is x)) AND qty is 7`.
|
|
273
|
+
|
|
274
|
+
### The name box opens selected (0.18.1)
|
|
275
|
+
|
|
276
|
+
The name box takes focus when the dialog opens, with its text selected: editing a name usually means replacing it, and `Mine (copy)` always does. The rest of the library's dialogs keep the caret at the end, and [`openModal` leaves focus where `onMount` put it](forms-and-dialogs.md#focus-placed-by-onmount-stays-0181).
|
|
277
|
+
|
|
278
|
+
### A click beside the dialog: `dismissOnBackdrop` (0.18.1)
|
|
279
|
+
|
|
280
|
+
`openFilterDialog({ dismissOnBackdrop: true })` dismisses on a click on the dimmed page beside it, through [`openModal`'s option of that name](forms-and-dialogs.md#a-click-beside-a-modal-dismissonbackdrop-0181). `confirmDiscard` still asks, and an action in flight still holds it open.
|
|
281
|
+
|
|
282
|
+
### A question that quotes what was typed (0.18.1)
|
|
283
|
+
|
|
284
|
+
`confirm.message` may be `(result) => string`, called when the question is asked with what the dialog would answer (`{ action, expr, name?, icon? }`):
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
confirm: { title: 'Delete this filter?', message: ({ name }) => `Delete “${name}”?`, confirmLabel: 'Delete', danger: true },
|
|
288
|
+
```
|
|
289
|
+
|
|
244
290
|
## Styling
|
|
245
291
|
|
|
246
292
|
Classes are `twm-filter-builder*` and `twm-filter-dialog*`. Every rule reads tokens; retheme through those.
|
|
@@ -332,6 +332,14 @@ answering false keeps it open. An action button does not ask it. A modal it lets
|
|
|
332
332
|
close resolves `null`, as any dismissal does. `openFilterDialog`'s `confirmDiscard`
|
|
333
333
|
is built on it.
|
|
334
334
|
|
|
335
|
+
## A click beside a modal: `dismissOnBackdrop` (0.18.1)
|
|
336
|
+
|
|
337
|
+
`openModal({ dismissOnBackdrop: true })` dismisses on a click on the dimmed page beside the modal, as Esc does: `beforeClose` is asked, and the modal resolves `null`. A drag that starts in the modal and ends on the page is no click on the backdrop. The press takes no focus from the modal, so when `beforeClose` refuses, the caret is where it was. Default `false`: the backdrop only blocks, which is what it has always done, whatever older comments in the source said.
|
|
338
|
+
|
|
339
|
+
## Focus placed by `onMount` stays (0.18.1)
|
|
340
|
+
|
|
341
|
+
`openModal` focuses the first field, or the primary button, a frame after `onMount` ran. It now leaves focus alone when `onMount` already put it inside the modal, with its selection. Before, the first field took it and the caret went to the end.
|
|
342
|
+
|
|
335
343
|
## Windows: closing, focus and Esc (0.17.16)
|
|
336
344
|
|
|
337
345
|
Every dialog above is a `ManagedWindow`, and so is any window you build
|
|
@@ -235,3 +235,27 @@ fires a bubbling `change` on the input, for a form around it.
|
|
|
235
235
|
also means "this one", such as the tag input's comma.
|
|
236
236
|
|
|
237
237
|
`tests/select_combobox_popover.test.mjs` covers these and the tag input's options in jsdom.
|
|
238
|
+
|
|
239
|
+
## A list under a name being edited: `attachInlineRenamer` (0.18.1)
|
|
240
|
+
|
|
241
|
+
`attachInlineRenamer`'s `onStart` now runs before the renamer adds its own key listeners, and is handed the input as `{ value, triggerEvent, input }`. So what it attaches hears each key first, which is what [`attachInputOptions`](#a-text-box-that-also-offers-a-list-createcombobox-0180) asks for:
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
const renamer = attachInlineRenamer({
|
|
245
|
+
label,
|
|
246
|
+
onStart: ({ input }) => {
|
|
247
|
+
list = attachInputOptions(input, {
|
|
248
|
+
options: names,
|
|
249
|
+
onPick: (option) => {
|
|
250
|
+
input.value = option.value;
|
|
251
|
+
renamer.commit('pick');
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
onCommit: (name) => save(name),
|
|
256
|
+
onFinish: () => list.destroy(),
|
|
257
|
+
onCancel: () => list.destroy(),
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Enter on an arrow-highlighted row commits the row; with no row highlighted it commits what was typed. Esc with the list open closes only the list, and the next one cancels. Taking the field off the page ends the edit first, so the blur a browser fires for it commits nothing after a commit or an Esc.
|
package/docs/components/menus.md
CHANGED
|
@@ -33,6 +33,10 @@ items they hold:
|
|
|
33
33
|
|
|
34
34
|
Nothing changes in how you call them.
|
|
35
35
|
|
|
36
|
+
## A press inside no longer disarms a context menu (0.18.1)
|
|
37
|
+
|
|
38
|
+
`showContextMenu` kept its outside-press listener for one press. A press inside the menu (a separator, the padding, a disabled row) used it up, and from then on a press outside was ignored until Escape. It now listens until the menu closes. A consumer's own outside-press listener for this can go.
|
|
39
|
+
|
|
36
40
|
## A menu bar: `createMenuBar` (0.17.0)
|
|
37
41
|
|
|
38
42
|
File / Edit / View menus from one declarative model, instead of hand-built
|
package/docs/components/shell.md
CHANGED
|
@@ -37,6 +37,10 @@ await createShell({
|
|
|
37
37
|
});
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
## Keyboard focus shows a tooltip (0.18.1)
|
|
41
|
+
|
|
42
|
+
`TooltipService` shows a `twm-has-tooltip` element's tooltip (or a `has-latex-tooltip` element's) when it takes keyboard focus, as hovering does, and hides it when focus leaves the element or on Esc. Focus moving inside the element keeps it. A pointer does not take it down: over another target the pointer's tooltip shows for as long as the pointer stays, then focus's comes back. A scroll does not hide it, the one the focus itself caused included: it goes with its target, and waits while a wheel has the target out of sight. Keyboard focus means the last press was a key, not a Ctrl, Cmd or Alt chord: the focus a click leaves shows nothing, nor does a field its handler then focuses. The browser's `:focus-visible` cannot tell this, being true for any text field. A consumer that wires `focusin` to `TooltipService.show` itself can stop.
|
|
43
|
+
|
|
40
44
|
## The pages as a vertical rail: `chrome.rail` (0.18.0)
|
|
41
45
|
|
|
42
46
|
`chrome.topNav` paints the pages as a strip in the top bar. `chrome.rail` paints
|
|
@@ -107,10 +107,54 @@ A centred field's `hostEl` must be positioned. It measures again whenever `hostE
|
|
|
107
107
|
|
|
108
108
|
`refresh()` asks `actions`, `card` and `footer` again. `reload()` asks `load` again. `open({ select })` highlights the row with that `id` when the answer lands. `setCurrent(item)` takes the item whether or not any page holds it.
|
|
109
109
|
|
|
110
|
-
**The keyboard.** The caret stays in the input. The arrows walk a highlight over the rows, Load more, the action rows, the card's buttons and the footer's, wrapping. Until they move, the highlight is the first row or action row, and Enter takes it, or, while the answer to what was typed is on its way, the first one when it lands. Esc closes and gives focus back to where it was. A press outside closes and leaves focus where it was pressed. Tab goes to the panel's buttons, and out of the panel closes it.
|
|
111
|
-
|
|
112
110
|
**Nothing is rebuilt under a pointer.** Every part is reconciled by key, so an answer landing while a row is being pressed leaves that row's node in place and the click still fires.
|
|
113
111
|
|
|
112
|
+
### The keyboard (0.18.1)
|
|
113
|
+
|
|
114
|
+
Focus is real: what the keyboard is on is `document.activeElement`, so a screen reader says each stop, the card's and the footer's buttons included, and Tab and the arrows agree about where the reader is.
|
|
115
|
+
|
|
116
|
+
| Key | |
|
|
117
|
+
|---|---|
|
|
118
|
+
| The arrows | Walk focus over every stop, wrapping: the input, the rows that can be chosen (a disabled one is stepped over), Load more, the action rows, the card's buttons, the footer's. After `open({ select })` they start from the row it chose. An answer or a redraw leaves focus where it is, on a row that moved too. |
|
|
119
|
+
| Tab | The rows are one tab stop: the row last focused, else the current one, else the first. So are the action rows. Out of the panel closes it. |
|
|
120
|
+
| A character | Goes to the input from anywhere in the panel, and to nothing else: a keymap does not read it as a shortcut. |
|
|
121
|
+
| Enter in the input | Takes what wears the highlight: the first row or action row of what is shown, or, while the answer to what was typed is on its way, the first one when it lands. |
|
|
122
|
+
| Enter or Space elsewhere | Takes the row or runs the button with focus. |
|
|
123
|
+
| Shift+Delete | Removes the row with focus, or the highlighted one from the input. Focus goes on to the next row. |
|
|
124
|
+
| Backspace | The input's in the input. Elsewhere it stops at the panel, where a window manager's keymap would read it as "up one level". |
|
|
125
|
+
| Esc | Closes, and gives focus back to where it was. |
|
|
126
|
+
|
|
127
|
+
A press outside closes and leaves focus where it was pressed. A row that held focus and went away, because an answer no longer holds it, gives focus to the input. Load more keeps focus while something loads: it is `aria-disabled` then, not `disabled`, and refuses the press itself.
|
|
128
|
+
|
|
129
|
+
### A trigger of your own (0.18.1)
|
|
130
|
+
|
|
131
|
+
The field is optional. `createPickerSwitcher` opens on any anchor, so a control of yours can be the trigger:
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
const switcher = createPickerSwitcher({
|
|
135
|
+
anchor: trigger,
|
|
136
|
+
placement: 'below',
|
|
137
|
+
visibleRows: 5,
|
|
138
|
+
load, describe, onPick, card, footer,
|
|
139
|
+
onOpenChange: (open) => trigger.setAttribute('aria-expanded', String(open)),
|
|
140
|
+
});
|
|
141
|
+
trigger.addEventListener('click', () => switcher.toggle());
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
A press on the anchor does not count as a press outside, and Esc, a pick or a verb give focus back to it when it had focus. `setCurrent(item)` on the switcher marks the row; naming it on your trigger is yours.
|
|
145
|
+
|
|
146
|
+
## The field's look: tokens (0.18.1)
|
|
147
|
+
|
|
148
|
+
Set these over FlexDesk's defaults (`css/tokens.css`) instead of overriding the field's rules:
|
|
149
|
+
|
|
150
|
+
| Token | Default | |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `--twm-wspicker-field-icon` | `var(--accent-bright)` | Its icon. |
|
|
153
|
+
| `--twm-wspicker-field-focus-border` | `var(--accent-bright)` | Its border, focused by keyboard and while open. |
|
|
154
|
+
| `--twm-wspicker-field-muted-icon` | `var(--text-muted)` | Its icon while it names nothing (`muted`). |
|
|
155
|
+
| `--twm-wspicker-field-muted-style` | `italic` | Its name's `font-style` while muted. |
|
|
156
|
+
| `--twm-wspicker-field-muted-opacity` | `0.55` | Its name's opacity while muted. |
|
|
157
|
+
|
|
114
158
|
## Recently opened: `createRecentItems` (0.18.0)
|
|
115
159
|
|
|
116
160
|
A most-recent-first list kept through the [host's](../concepts/host-port.md) `state` capability.
|
package/package.json
CHANGED
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
* added. A 2D chart type removes the z axis (`zAxis: null`, as in
|
|
29
29
|
* makeDefaultSubplot).
|
|
30
30
|
*
|
|
31
|
+
* `hide` takes parts of the editor away, for an embedder whose plots never use
|
|
32
|
+
* them: '3d' (the 3D chart types and the z axis), 'stackGroup' (a series' stack
|
|
33
|
+
* group), 'xAxis' (the x axis section; the subplot then plots against time).
|
|
34
|
+
*
|
|
35
|
+
* A reference line's value is a number. With `constantsProvider` it may also name
|
|
36
|
+
* a constant: the box offers the names, and stores a name as a string, which
|
|
37
|
+
* renderPlotGrid's `resolveConstant` turns into a value at each draw.
|
|
38
|
+
*
|
|
31
39
|
* Series names come from `seriesProvider`, through an AutocompleteField per
|
|
32
40
|
* picker: it is asked `{ value, role, subplot }` (`role` is 'series', 'x' or
|
|
33
41
|
* 'z') and answers AutocompleteField items (`{ label, insertText?, type? }`)
|
|
@@ -43,6 +51,7 @@
|
|
|
43
51
|
*/
|
|
44
52
|
|
|
45
53
|
import { AutocompleteField } from '../ui/components/autocomplete_field.js';
|
|
54
|
+
import { attachInputOptions } from '../ui/components/combobox.js';
|
|
46
55
|
import { ColorField } from '../ui/components/color_field.js';
|
|
47
56
|
import { ToggleField } from '../ui/components/toggle_field.js';
|
|
48
57
|
import { selectBox } from '../ui/components/choice_controls.js';
|
|
@@ -55,6 +64,7 @@ const CHART_TYPE_OPTIONS = Object.freeze([
|
|
|
55
64
|
{ group: '2D', options: CHART_TYPES_2D },
|
|
56
65
|
{ group: '3D', options: CHART_TYPES_3D },
|
|
57
66
|
]);
|
|
67
|
+
const HIDEABLE = Object.freeze(['3d', 'stackGroup', 'xAxis']);
|
|
58
68
|
const SCALES = Object.freeze([{ value: 'linear', label: 'Linear' }, { value: 'log', label: 'Log' }]);
|
|
59
69
|
const POSITIONS = Object.freeze([{ value: 'left', label: 'Left' }, { value: 'right', label: 'Right' }]);
|
|
60
70
|
const DASHES = Object.freeze([
|
|
@@ -200,6 +210,10 @@ let editorCount = 0;
|
|
|
200
210
|
* or return an ARRAY of field specs `{ label: string, control: HTMLElement,
|
|
201
211
|
* hint? }` / `{ label: string, render(slot) => { dispose? } | void, hint? }`,
|
|
202
212
|
* laid out after anything drawn into `el`. A bad spec is skipped with a warning.
|
|
213
|
+
* @param {Array<'3d'|'stackGroup'|'xAxis'>} [options.hide] - Parts of the editor not to
|
|
214
|
+
* draw. See the file's header. Any other name throws.
|
|
215
|
+
* @param {Function} [options.constantsProvider] - `() => [{ name, value, label?, group? }]`:
|
|
216
|
+
* the names a reference line's value may take, asked each time the list is drawn.
|
|
203
217
|
* @param {Object} [options.logger] - Given to every AutocompleteField (provider errors);
|
|
204
218
|
* its `warn` also hears a part (an `extraSeriesFields` handle, say) that throws on dispose.
|
|
205
219
|
* @returns {{ dispose: Function, refresh: Function, selectSubplot: Function,
|
|
@@ -212,9 +226,15 @@ function renderPlotConfigEditor(container, config, options = {}) {
|
|
|
212
226
|
overlaySources: null,
|
|
213
227
|
seriesTransforms: null,
|
|
214
228
|
extraSeriesFields: null,
|
|
229
|
+
hide: [],
|
|
230
|
+
constantsProvider: null,
|
|
215
231
|
logger: null,
|
|
216
232
|
...options,
|
|
217
233
|
};
|
|
234
|
+
for (const part of opts.hide) {
|
|
235
|
+
if (!HIDEABLE.includes(part)) throw new TypeError(`[plot config editor] cannot hide "${part}"`);
|
|
236
|
+
}
|
|
237
|
+
const hidden = new Set(opts.hide);
|
|
218
238
|
const editorId = `twm-plot-config-${++editorCount}`;
|
|
219
239
|
let fieldCount = 0;
|
|
220
240
|
let active = 0;
|
|
@@ -380,6 +400,7 @@ function renderPlotConfigEditor(container, config, options = {}) {
|
|
|
380
400
|
|
|
381
401
|
function normalise(sp) {
|
|
382
402
|
if (!sp.xAxis) sp.xAxis = { useTime: true, variable: '' };
|
|
403
|
+
if (hidden.has('xAxis')) sp.xAxis.useTime = true;
|
|
383
404
|
if (!Array.isArray(sp.yAxes) || !sp.yAxes.length) sp.yAxes = [makeDefaultYAxis(0)];
|
|
384
405
|
for (const axis of sp.yAxes) if (!Array.isArray(axis.series)) axis.series = [];
|
|
385
406
|
if (is3DChart(sp.chartType) && !sp.zAxis) sp.zAxis = makeZAxis();
|
|
@@ -403,9 +424,9 @@ function renderPlotConfigEditor(container, config, options = {}) {
|
|
|
403
424
|
section('Subplot', field('Title', textInput(sp.displayName, (v) => { sp.displayName = v; notify(); },
|
|
404
425
|
{ placeholder: 'Untitled' }))),
|
|
405
426
|
section('Display', ...displayFields(sp)),
|
|
406
|
-
section('X axis', ...xAxisFields(sp)),
|
|
427
|
+
hidden.has('xAxis') ? null : section('X axis', ...xAxisFields(sp)),
|
|
407
428
|
section('Y axes', yAxesEditor(sp, is3D, overlaySources)),
|
|
408
|
-
is3D ? section('Z axis', ...zAxisFields(sp)) : null,
|
|
429
|
+
is3D && !hidden.has('3d') ? section('Z axis', ...zAxisFields(sp)) : null,
|
|
409
430
|
section('Reference lines', referenceLinesEditor(sp)),
|
|
410
431
|
section('Shaded bands', shadedBandsEditor(sp)),
|
|
411
432
|
transformsSection(sp),
|
|
@@ -414,7 +435,7 @@ function renderPlotConfigEditor(container, config, options = {}) {
|
|
|
414
435
|
|
|
415
436
|
function displayFields(sp) {
|
|
416
437
|
return [
|
|
417
|
-
field('Chart type', select(CHART_TYPE_OPTIONS, sp.chartType ?? 'line', (v) => {
|
|
438
|
+
field('Chart type', select(hidden.has('3d') ? CHART_TYPES_2D : CHART_TYPE_OPTIONS, sp.chartType ?? 'line', (v) => {
|
|
418
439
|
const was3D = is3DChart(sp.chartType);
|
|
419
440
|
sp.chartType = v;
|
|
420
441
|
const now3D = is3DChart(v);
|
|
@@ -543,9 +564,11 @@ function renderPlotConfigEditor(container, config, options = {}) {
|
|
|
543
564
|
field('Width', numberInput(s.lineWidth ?? 2, (v) => { s.lineWidth = v ?? 2; notify(); },
|
|
544
565
|
{ placeholder: '2', label: 'Line width', min: 1, max: 10, step: 1 })),
|
|
545
566
|
field('Dash', select(DASHES, s.lineStyle ?? 'solid', (v) => { s.lineStyle = v; notify(); }, { label: 'Dash' })),
|
|
546
|
-
field('Stack group', textInput(s.stackGroup, (v) => { s.stackGroup = v || null; notify(); },
|
|
547
|
-
{ placeholder: 'None', label: 'Stack group' })),
|
|
548
567
|
);
|
|
568
|
+
if (!hidden.has('stackGroup')) {
|
|
569
|
+
fields.appendChild(field('Stack group', textInput(s.stackGroup, (v) => { s.stackGroup = v || null; notify(); },
|
|
570
|
+
{ placeholder: 'None', label: 'Stack group' })));
|
|
571
|
+
}
|
|
549
572
|
if (opts.extraSeriesFields) fields.appendChild(extraFields(sp, axis, s));
|
|
550
573
|
if (opts.overlaySources) fields.appendChild(overlayEditor(s, overlaySources));
|
|
551
574
|
row.append(head, fields);
|
|
@@ -655,6 +678,67 @@ function renderPlotConfigEditor(container, config, options = {}) {
|
|
|
655
678
|
return wrap;
|
|
656
679
|
}
|
|
657
680
|
|
|
681
|
+
/**
|
|
682
|
+
* A reference line's value as a number or a constant's name. What was typed
|
|
683
|
+
* settles on change, on Enter or Tab, or on a pick; a typed number stays a number
|
|
684
|
+
* even when it is part of a constant's name, so Enter does not take that row.
|
|
685
|
+
*/
|
|
686
|
+
function constantValueField(line) {
|
|
687
|
+
const wrap = el('div', 'twm-plot-config__constant');
|
|
688
|
+
const input = el('input', 'twm-plot-config__input');
|
|
689
|
+
input.type = 'text';
|
|
690
|
+
input.spellcheck = false;
|
|
691
|
+
input.placeholder = 'Number or name';
|
|
692
|
+
input.setAttribute('aria-label', 'Value');
|
|
693
|
+
input.value = line.value ?? '';
|
|
694
|
+
const now = el('span', 'twm-plot-config__constant-value');
|
|
695
|
+
wrap.append(input, now);
|
|
696
|
+
|
|
697
|
+
const isNumber = (text) => text !== '' && Number.isFinite(Number(text));
|
|
698
|
+
const sayValue = () => {
|
|
699
|
+
const named = opts.constantsProvider().find((c) => c.name === input.value.trim());
|
|
700
|
+
now.textContent = named ? `= ${named.value}` : '';
|
|
701
|
+
};
|
|
702
|
+
const settle = () => {
|
|
703
|
+
const text = input.value.trim();
|
|
704
|
+
const next = isNumber(text) ? Number(text) : text || null;
|
|
705
|
+
sayValue();
|
|
706
|
+
if (next === (line.value ?? null)) return;
|
|
707
|
+
line.value = next;
|
|
708
|
+
notify();
|
|
709
|
+
};
|
|
710
|
+
const offered = (query) => {
|
|
711
|
+
const words = (query ?? '').trim().toLowerCase();
|
|
712
|
+
const groups = new Map();
|
|
713
|
+
for (const c of opts.constantsProvider()) {
|
|
714
|
+
const text = c.label ?? c.name;
|
|
715
|
+
if (words && !`${c.name} ${text}`.toLowerCase().includes(words)) continue;
|
|
716
|
+
if (!groups.has(c.group)) groups.set(c.group, []);
|
|
717
|
+
groups.get(c.group).push({ value: c.name, label: text, hint: `= ${c.value}` });
|
|
718
|
+
}
|
|
719
|
+
return [...groups].flatMap(([group, rows]) => (group ? [{ group, options: rows }] : rows));
|
|
720
|
+
};
|
|
721
|
+
const list = attachInputOptions(input, {
|
|
722
|
+
options: offered,
|
|
723
|
+
debounce: 0,
|
|
724
|
+
autoHighlight: true,
|
|
725
|
+
isCandidate: (typed) => isNumber(typed.trim()),
|
|
726
|
+
openOnFocus: true,
|
|
727
|
+
isCurrent: (option) => option.value === input.value.trim(),
|
|
728
|
+
onPick: (option) => {
|
|
729
|
+
input.value = option.value;
|
|
730
|
+
settle();
|
|
731
|
+
},
|
|
732
|
+
onSettle: settle,
|
|
733
|
+
});
|
|
734
|
+
input.addEventListener('change', settle);
|
|
735
|
+
// After the list's own keys, which take Enter while a row is active.
|
|
736
|
+
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') settle(); });
|
|
737
|
+
track(() => list.destroy());
|
|
738
|
+
sayValue();
|
|
739
|
+
return wrap;
|
|
740
|
+
}
|
|
741
|
+
|
|
658
742
|
function referenceLinesEditor(sp) {
|
|
659
743
|
const axisOptions = sp.yAxes.map((a, i) => ({ value: a.id, label: a.label || `Y axis ${i + 1}` }));
|
|
660
744
|
return listEditor(sp, 'referenceLines', {
|
|
@@ -663,7 +747,9 @@ function renderPlotConfigEditor(container, config, options = {}) {
|
|
|
663
747
|
fields: (line) => [
|
|
664
748
|
select(axisOptions, line.yAxisId ?? sp.yAxes[0].id, (v) => { line.yAxisId = v; notify(); },
|
|
665
749
|
{ label: 'Axis' }),
|
|
666
|
-
|
|
750
|
+
opts.constantsProvider
|
|
751
|
+
? constantValueField(line)
|
|
752
|
+
: numberInput(line.value, (v) => { line.value = v; notify(); }, { placeholder: 'Value', label: 'Value' }),
|
|
667
753
|
textInput(line.label, (v) => { line.label = v; notify(); }, { placeholder: 'Label', label: 'Label' }),
|
|
668
754
|
colorField(line.color, (v) => { if (v) line.color = v; else delete line.color; }, 'Colour'),
|
|
669
755
|
select(DASHES, line.lineStyle ?? 'dashed', (v) => { line.lineStyle = v; notify(); }, { label: 'Dash' }),
|
package/src/charts/plot_grid.js
CHANGED
|
@@ -91,6 +91,9 @@ function emptyMessageFor(message, subplot, results, index) {
|
|
|
91
91
|
* @param {Object} [options]
|
|
92
92
|
* @param {Function} [options.resolveSeries] - `(series, results, subplot) => { y, x? } | null`;
|
|
93
93
|
* default resolveSeriesData. Also resolves the x variable, as `{ variable }`.
|
|
94
|
+
* @param {Function} [options.resolveConstant] - `(name) => number | undefined`: the value of a
|
|
95
|
+
* reference line that stores a name (renderPlotConfigEditor's `constantsProvider`),
|
|
96
|
+
* asked at each draw. A name it does not know draws no line; so does any name without it.
|
|
94
97
|
* @param {Object} [options.overlays] - `{ [series.id]: { years, values } }`, drawn for
|
|
95
98
|
* every series with an `overlay` description.
|
|
96
99
|
* @param {Object} [options.seriesTransforms] - `{ [id]: transform }` (series_transforms.js).
|
|
@@ -326,7 +329,9 @@ function renderPlotGrid(container, plotConfig, results, options = {}) {
|
|
|
326
329
|
? 'top' : (sp.legendPosition ?? 'hidden');
|
|
327
330
|
const xRange = !useX && time.length >= 2 ? [time[0], time[time.length - 1]] : undefined;
|
|
328
331
|
const { shapes, annotations } = buildOverlayShapes({
|
|
329
|
-
referenceLines: sp.referenceLines
|
|
332
|
+
referenceLines: (sp.referenceLines ?? []).map((line) => (typeof line.value === 'string'
|
|
333
|
+
? { ...line, value: opts.resolveConstant?.(line.value) }
|
|
334
|
+
: line)),
|
|
330
335
|
shadedBands: sp.shadedBands,
|
|
331
336
|
yRefByAxisId,
|
|
332
337
|
});
|
|
@@ -703,13 +703,18 @@ export function mountChatShell(host, {
|
|
|
703
703
|
changed();
|
|
704
704
|
}
|
|
705
705
|
|
|
706
|
-
/**
|
|
706
|
+
/**
|
|
707
|
+
* Stops the reply: the chat can send again at once. That send waits for
|
|
708
|
+
* `runEnding`, unless this turn was itself still waiting for it: the person
|
|
709
|
+
* has now said twice that they will not wait, so the next send goes out.
|
|
710
|
+
*/
|
|
707
711
|
function cancel() {
|
|
708
712
|
if (!turn) return false;
|
|
709
713
|
const { request, sent } = turn;
|
|
710
714
|
closeReply('stopped');
|
|
711
715
|
machine.cancel();
|
|
712
716
|
if (sent) callCancel(request);
|
|
717
|
+
else runEnding = null;
|
|
713
718
|
syncWelcome();
|
|
714
719
|
changed();
|
|
715
720
|
return true;
|
|
@@ -789,6 +794,7 @@ export function mountChatShell(host, {
|
|
|
789
794
|
const modelBtn = el('button', 'twm-chat-shell__model-btn');
|
|
790
795
|
modelBtn.type = 'button';
|
|
791
796
|
modelBtn.setAttribute('aria-haspopup', 'menu');
|
|
797
|
+
modelBtn.setAttribute('aria-expanded', 'false');
|
|
792
798
|
const modelDot = el('span', 'twm-chat-shell__status');
|
|
793
799
|
const modelLabel = el('span', 'twm-chat-shell__model-label');
|
|
794
800
|
modelBtn.append(modelDot, modelLabel, icon('expand_more', 'twm-chat-shell__model-chevron'));
|
|
@@ -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
|