flexdesk 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/css/flexdesk.css +83 -5
  3. package/css/tokens.css +7 -0
  4. package/dist/charts.js +78 -12
  5. package/dist/charts.js.map +2 -2
  6. package/dist/{chunk-5W4EEKSK.js → chunk-3BLIVOFD.js} +3 -3
  7. package/dist/{chunk-P74F6I6P.js → chunk-4X4SY3CP.js} +4 -4
  8. package/dist/{chunk-P74F6I6P.js.map → chunk-4X4SY3CP.js.map} +2 -2
  9. package/dist/{chunk-ZL3VKINP.js → chunk-HL3IIHU3.js} +494 -3
  10. package/dist/{chunk-ZL3VKINP.js.map → chunk-HL3IIHU3.js.map} +4 -4
  11. package/dist/{chunk-RKVYIXDC.js → chunk-NFZJ6P42.js} +19 -500
  12. package/dist/chunk-NFZJ6P42.js.map +7 -0
  13. package/dist/{chunk-U5GKJUOC.js → chunk-QRBOIUU3.js} +5 -6
  14. package/dist/chunk-QRBOIUU3.js.map +7 -0
  15. package/dist/flexdesk.css +83 -5
  16. package/dist/flow.js +22 -10
  17. package/dist/flow.js.map +2 -2
  18. package/dist/git.js +3 -3
  19. package/dist/tiles.js +4 -4
  20. package/dist/tokens.css +7 -0
  21. package/dist/widgets.js +226 -58
  22. package/dist/widgets.js.map +2 -2
  23. package/dist/wm.js +2 -2
  24. package/docs/components/charts.md +40 -0
  25. package/docs/components/chat-shell.md +8 -2
  26. package/docs/components/filter-builder.md +52 -6
  27. package/docs/components/flow-editors.md +20 -0
  28. package/docs/components/forms-and-dialogs.md +8 -0
  29. package/docs/components/input-fields.md +24 -0
  30. package/docs/components/menus.md +4 -0
  31. package/docs/components/shell.md +4 -0
  32. package/docs/components/workspace-picker.md +46 -2
  33. package/package.json +1 -1
  34. package/src/charts/plot_config_editor.js +92 -6
  35. package/src/charts/plot_grid.js +6 -1
  36. package/src/flow/kit/settings_panel.js +24 -9
  37. package/src/ui/components/chat_shell.js +7 -1
  38. package/src/ui/components/context_menu.js +8 -2
  39. package/src/ui/components/filter_builder.js +53 -28
  40. package/src/ui/components/filter_model.js +86 -18
  41. package/src/ui/components/inline_renamer.js +10 -5
  42. package/src/ui/components/modal.js +25 -8
  43. package/src/ui/components/workspace_picker.js +125 -27
  44. package/src/ui/utils/tooltip_service.js +81 -14
  45. package/dist/chunk-RKVYIXDC.js.map +0 -7
  46. package/dist/chunk-U5GKJUOC.js.map +0 -7
  47. /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-RKVYIXDC.js";
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-ZL3VKINP.js";
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; one that never settles leaves the next turn waiting until the user stops it.
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()`, `newConversation()` → id, `switchTo(id)`, `close(id)`, `rename(id, title)`, `reset()` | The tabs. `reset` empties the one in front. Refused ones answer false (`newConversation`: null) |
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. `formatValue(value, field)` names each value (a key shown as a name, say). An unknown field is said: `⚠ unknown field “gone”`; the `unknownName(key)` option of `createFilterModel` shapes the key shown. |
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.
@@ -81,6 +81,26 @@ be used is disabled and its reason is shown next to it, not only in a tooltip.
81
81
  `extra` holds the editor's own fields (a port shown as a setting) and notes,
82
82
  placed with `after: '<key>'` or `at: 'start'`.
83
83
 
84
+ ### A flat panel: `variant: 'sheet'` (0.18.2)
85
+
86
+ `createSettingsPanel({ ..., variant: 'sheet' })` draws the panel flat, as one
87
+ section of a page of them: no fill and no icon chip, the title and the
88
+ description in a margin at the left, the id line, findings, slots and fields
89
+ beside them. Two sheets in a row are split by a rule, so a settings page built of
90
+ several panels (one per subject: *Run*, *Solver*, *Metadata*) reads as one form.
91
+ Narrower than 560px the margin goes above the fields. Everything else is the
92
+ card's: `show()`, the repaints, focus, findings and slots behave the same.
93
+ `variant` is `'card'` (the default) or `'sheet'`; any other value is refused when
94
+ the panel is created.
95
+
96
+ ```js
97
+ for (const section of sections) {
98
+ const panel = createSettingsPanel({ widgets, host: page, variant: 'sheet', onChange: section.write });
99
+ panel.show({ title: section.title, description: section.hint, type: { config_schema: section.schema },
100
+ value: section.read() });
101
+ }
102
+ ```
103
+
84
104
  **The widgets.** A schema type with no `x-ui-widget` draws its plain control: an
85
105
  `enum` a select (writing the enum's own value, so a number stays a number), a
86
106
  string a text box, a number a number box, a boolean a checkbox, an object with
@@ -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.
@@ -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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- numberInput(line.value, (v) => { line.value = v; notify(); }, { placeholder: 'Value', label: 'Value' }),
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' }),
@@ -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
  });
@@ -50,13 +50,20 @@ const TITLE = 'data-twm-flow-title';
50
50
  * @param {string} [o.valuesNote] the picker's footer sentence
51
51
  * @param {(step: object) => object[]|null} [o.columns] a step's input columns (a data flow)
52
52
  * @param {HTMLElement} [o.host] appended here when given
53
+ * @param {'card'|'sheet'} [o.variant] 'card' (the default): a raised panel, its header above
54
+ * its fields. 'sheet': flat, one section of a page of them — the title and the description
55
+ * in a margin at the left, the fields beside them, a rule between two sheets (0.18.2)
53
56
  */
54
57
  export function createSettingsPanel({ widgets, references = {}, strings = null, services = null, readOnly = false,
55
58
  onChange = null, onRename = null, onFocusLost = null, values = null,
56
- valuesNote = null, columns = null, host = null } = {}) {
59
+ valuesNote = null, columns = null, host = null, variant = 'card' } = {}) {
57
60
  if (!widgets?.get) throw new Error('createSettingsPanel needs a widget registry (createWidgetRegistry()).');
61
+ if (variant !== 'card' && variant !== 'sheet') {
62
+ throw new Error(`createSettingsPanel's variant is 'card' or 'sheet', not ${JSON.stringify(variant)}.`);
63
+ }
64
+ const sheet = variant === 'sheet';
58
65
  const S = createStrings(strings);
59
- const root = el('section', 'twm-flow-panel');
66
+ const root = el('section', sheet ? 'twm-flow-panel twm-flow-panel--sheet' : 'twm-flow-panel');
60
67
  root.setAttribute('role', 'region');
61
68
  root.setAttribute('aria-label', say(S, 'settings'));
62
69
  if (host) host.appendChild(root);
@@ -167,15 +174,23 @@ export function createSettingsPanel({ widgets, references = {}, strings = null,
167
174
  root.replaceChildren();
168
175
  if (!spec) return;
169
176
  if (isReadOnly() && readOnlyReason()) root.appendChild(el('p', 'twm-flow-panel__readonly', readOnlyReason()));
170
- root.appendChild(paintHeader());
171
- if (spec.description) root.appendChild(el('p', 'twm-flow-panel__description', spec.description));
172
- if (spec.idLine) root.appendChild(el('div', 'twm-flow-panel__id', spec.idLine));
177
+ // A sheet sets the header and the description in its margin, the rest beside them
178
+ const margin = sheet ? el('div', 'twm-flow-panel__margin') : root;
179
+ const body = sheet ? el('div', 'twm-flow-panel__body') : root;
180
+ if (sheet) {
181
+ const grid = el('div', 'twm-flow-panel__sheet');
182
+ grid.append(margin, body);
183
+ root.appendChild(grid);
184
+ }
185
+ margin.appendChild(paintHeader());
186
+ if (spec.description) margin.appendChild(el('p', 'twm-flow-panel__description', spec.description));
187
+ if (spec.idLine) body.appendChild(el('div', 'twm-flow-panel__id', spec.idLine));
173
188
  findingsBox = el('ul', 'twm-flow-panel__findings');
174
189
  findingsBox.hidden = true;
175
- root.appendChild(findingsBox);
190
+ body.appendChild(findingsBox);
176
191
  slotBefore = el('div', 'twm-flow-panel__slot');
177
192
  slotBefore.dataset.slot = 'before';
178
- root.appendChild(slotBefore);
193
+ body.appendChild(slotBefore);
179
194
 
180
195
  const list = el('div', 'twm-flow-panel__fields');
181
196
  allFields = Array.isArray(spec.fields) ? spec.fields : fieldsFromSchema(spec.type?.config_schema);
@@ -213,10 +228,10 @@ export function createSettingsPanel({ widgets, references = {}, strings = null,
213
228
  const anchor = placed(e);
214
229
  if (anchor === null || !keys.has(anchor)) drawExtra(e);
215
230
  }
216
- root.appendChild(list);
231
+ body.appendChild(list);
217
232
  slotAfter = el('div', 'twm-flow-panel__slot');
218
233
  slotAfter.dataset.slot = 'after';
219
- root.appendChild(slotAfter);
234
+ body.appendChild(slotAfter);
220
235
  paintSlots();
221
236
  applyFindings();
222
237
  }