flexdesk 0.17.15 → 0.17.16

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 (42) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/css/base.css +224 -4
  3. package/css/flexdesk.css +314 -12
  4. package/dist/charts.js +104 -34
  5. package/dist/charts.js.map +3 -3
  6. package/dist/{chunk-2PM6ZMNS.js → chunk-2Z2ENHR3.js} +2 -2
  7. package/dist/{chunk-AD7FIA3Z.js → chunk-7JI7HHSY.js} +2 -2
  8. package/dist/{chunk-TLIV6QSU.js → chunk-7OL6F2AM.js} +3 -3
  9. package/dist/{chunk-MADMGRX2.js → chunk-PGZ4EHSE.js} +156 -9
  10. package/dist/chunk-PGZ4EHSE.js.map +7 -0
  11. package/dist/{chunk-OEG6NPAN.js → chunk-XJNADRGR.js} +2 -2
  12. package/dist/flexdesk.css +314 -12
  13. package/dist/flow.js +3 -3
  14. package/dist/tiles.js +84 -9
  15. package/dist/tiles.js.map +2 -2
  16. package/dist/widgets.js +448 -5
  17. package/dist/widgets.js.map +4 -4
  18. package/dist/wm.js +102 -14
  19. package/dist/wm.js.map +2 -2
  20. package/docs/README.md +1 -1
  21. package/docs/components/charts.md +70 -3
  22. package/docs/components/forms-and-dialogs.md +61 -0
  23. package/docs/components/menus.md +85 -1
  24. package/docs/components/shell.md +37 -0
  25. package/docs/components/tiles.md +37 -0
  26. package/package.json +1 -1
  27. package/src/charts/plot_config_editor.js +125 -29
  28. package/src/tiles/tile_base.js +48 -2
  29. package/src/tiles/tile_grid.js +42 -1
  30. package/src/tiling/kind_taxonomy.js +45 -0
  31. package/src/tiling/wm.js +86 -11
  32. package/src/ui/components/category_picker.js +556 -0
  33. package/src/ui/components/computing_status_window.js +3 -0
  34. package/src/ui/components/confirm_dialog.js +3 -0
  35. package/src/ui/components/managed_window.js +204 -9
  36. package/src/ui/components/wizard.js +4 -1
  37. package/widgets.js +2 -1
  38. package/dist/chunk-MADMGRX2.js.map +0 -7
  39. /package/dist/{chunk-2PM6ZMNS.js.map → chunk-2Z2ENHR3.js.map} +0 -0
  40. /package/dist/{chunk-AD7FIA3Z.js.map → chunk-7JI7HHSY.js.map} +0 -0
  41. /package/dist/{chunk-TLIV6QSU.js.map → chunk-7OL6F2AM.js.map} +0 -0
  42. /package/dist/{chunk-OEG6NPAN.js.map → chunk-XJNADRGR.js.map} +0 -0
package/docs/README.md CHANGED
@@ -49,7 +49,7 @@ FlexDesk is nine entry points that share their code, so you load only what you u
49
49
  - [Settings page](components/settings-view.md): a settings store's schema drawn as a page.
50
50
  - [Wizard](components/wizard.md): a multi-step flow, inline or in a window.
51
51
  - [Input fields: slider and tags](components/input-fields.md): `SliderField` and `createTagInput`.
52
- - [Menus](components/menus.md): `createMenuBar` and `ActionDropdown`.
52
+ - [Menus](components/menus.md): `createMenuBar`, `ActionDropdown` and `CategoryPicker`.
53
53
 
54
54
  ### Layout, content and feedback
55
55
 
@@ -4,6 +4,70 @@
4
4
 
5
5
  Plotly-backed charts: the wrapper, the chart types, subplot layouts, downsampling, the plot grid, the plot-config editor and the two plot windows. Plotly itself is not in the bundle; call `setPlotlySource()` with the path to your copy. The plot window's `streamEvents` option is described with the [other application-neutral options](../concepts/domain-neutrality.md#no-application-globals-the-gallery-picker-and-plot-window-events-0170).
6
6
 
7
+ ## A plot config series is one block, and an embedder's fields share it (0.17.16)
8
+
9
+ [`renderPlotConfigEditor`](#editing-a-plot-config-renderplotconfigeditor-0170)
10
+ drew each series as one wrapped line: the series picker, the legend label, the
11
+ colour swatch with a wide hex box, the width, the dash and the stack group,
12
+ then the embedder's `extraSeriesFields` and the overlay select on label
13
+ columns of their own. Each series is now one block:
14
+
15
+ - **A header line**: the series picker, the legend label and the remove
16
+ button. It wraps when the window is narrow.
17
+ - **Its fields, one per line, on the editor's label column** (the same 96px
18
+ column as the subplot's and the axes' fields): **Colour** (a `ColorField`:
19
+ the swatch and a hex box sized to `#rrggbb`), **Width**, **Dash**,
20
+ **Stack group**, then the embedder's fields, then **Overlay** (with
21
+ `overlaySources`). An overlay's own label, colour, dash, width and mode sit
22
+ under its source, set in by a rule, with their controls still on the
23
+ series' control column.
24
+
25
+ It reads at the usual ~600px config window and down to 360px without
26
+ overflowing. The classes are `.twm-plot-config__series-head` and
27
+ `.twm-plot-config__series-fields`; `.twm-plot-config__series-main` is gone.
28
+
29
+ `extraSeriesFields(el, series, ctx)` can now put its fields on that same
30
+ column, in either of two ways, and the 0.17.0 form keeps working:
31
+
32
+ ```js
33
+ renderPlotConfigEditor(el, plotConfig, {
34
+ // 1. return field specs: the editor lays them out
35
+ extraSeriesFields: (el, series, { onChange }) => [
36
+ { label: 'Scenario', control: scenarioSelect(series, onChange),
37
+ hint: 'The run this series is read from' },
38
+ { label: 'Note', render: (slot) => mountMyWidget(slot, series, onChange) }, // may return { dispose }
39
+ ],
40
+ // 2. or draw rows with the editor's own helper
41
+ // extraSeriesFields: (el, series, { onChange, field }) => {
42
+ // el.appendChild(field('Scenario', scenarioSelect(series, onChange), { hint: '…' }));
43
+ // },
44
+ });
45
+ ```
46
+
47
+ - `ctx.field(label, control, { hint })` returns a field line (a `<label>`
48
+ when the control is a single input, select or textarea). A hint is drawn
49
+ under the control, outside the label: the line is then a `<div>`, its label
50
+ text is the `<label for>` of a single control, and the hint is the control's
51
+ `aria-describedby`, so it describes the control without joining its name.
52
+ - Only an ARRAY return is field specs. A spec is
53
+ `{ label: string, control: HTMLElement, hint? }` or
54
+ `{ label: string, render(slot), hint? }`; `render` draws into an empty slot
55
+ on the field line and may return `{ dispose }`. Specs are laid out after
56
+ anything drawn into `el`. A spec without a string `label`, with a `control`
57
+ that is not an Element, with neither `control` nor `render`, or whose
58
+ `render` throws is skipped with a warning (`logger.warn`, else
59
+ `console.warn`); the rest of the row is drawn. Any other return is a handle
60
+ as in 0.17.0 (`{ dispose }`), even one that has a `fields` property.
61
+ - `el` sits in the field column (`display: contents`), so rows appended to it
62
+ line up with the editor's; content drawn there any other way shows as
63
+ before. A returned `{ dispose }` is still disposed on every redraw.
64
+
65
+ The config the editor reads and writes is unchanged: no key was added, renamed
66
+ or defaulted differently, opening a series writes nothing, and each control
67
+ writes its own key only.
68
+
69
+ `tests/plot_config_editor.test.mjs` §12 pins it.
70
+
7
71
  ## An empty cell can name what is missing: `emptyMessage` as a function (0.17.12)
8
72
 
9
73
  [`renderPlotGrid`](#a-plot-config-in-any-container-renderplotgrid-0170),
@@ -303,7 +367,7 @@ const editor = renderPlotConfigEditor(el, plotConfig, {
303
367
  seriesProvider: ({ value, role, subplot }) => names(value), // items, or a promise of them
304
368
  seriesTransforms, // their toggles, per subplot
305
369
  overlaySources: () => mySources(), // optional
306
- extraSeriesFields: (el, series, { subplot, axis, onChange }) => { /* your fields */ },
370
+ extraSeriesFields: (el, series, { subplot, axis, onChange, field }) => { /* your fields */ },
307
371
  });
308
372
  editor.dispose();
309
373
  ```
@@ -341,8 +405,11 @@ config it was given and then calls `onChange(config)`.
341
405
  dash, width and mode. `renderPlotGrid` draws it from the data you pass under
342
406
  the series' id in `overlays`. **None** removes `series.overlay`. Without
343
407
  `overlaySources` there are no overlay controls.
344
- - `extraSeriesFields(el, series, { subplot, axis, onChange })` puts fields of
345
- your own in each series row; call `onChange()` after you edit the series. What
408
+ - `extraSeriesFields(el, series, { subplot, axis, onChange, field })` puts fields of
409
+ your own in each series row; call `onChange()` after you edit the series.
410
+ Since 0.17.16 they can share the series' label column (`field`, or returned
411
+ field specs; see
412
+ [above](#a-plot-config-series-is-one-block-and-an-embedders-fields-share-it-01716)). What
346
413
  it returns is disposed (`dispose()`) when the row is redrawn or the editor
347
414
  is disposed. A `dispose()` that throws is reported to `logger.warn` and does
348
415
  not stop the rest of the editor being disposed.
@@ -309,3 +309,64 @@ the caller's own DOM.
309
309
 
310
310
  `tests/confirm_escaping.test.mjs` opens each of them in jsdom with hostile
311
311
  names and labels.
312
+
313
+ ## Windows: closing, focus and Esc (0.17.16)
314
+
315
+ Every dialog above is a `ManagedWindow`, and so is any window you build
316
+ yourself with `new ManagedWindow({ id, title, content, ... })`. Three things
317
+ changed for all of them (BUG-0053):
318
+
319
+ - **A closed window is gone.** `close()` (once `beforeClose` allows it) runs
320
+ `onClose`, removes the window from the registry and releases its content,
321
+ its DOM and every callback it was given. `ManagedWindow.get(id)` then answers
322
+ `null`, `ManagedWindow.all()` no longer lists it and `win.isClosed` is
323
+ `true`. A closed window cannot be shown again: `show()` on it warns and does
324
+ nothing. To reopen, build a new `ManagedWindow` with the same id; it restores
325
+ the geometry the last one saved under that id. Until 0.17.15 a closed window
326
+ stayed registered, holding its content and whatever its `onClose` captured,
327
+ until the same id opened again.
328
+ - **Saved geometry is bounded.** Position and size are still saved per id
329
+ under `flexdesk.managedWindows.v1` (or your `storageKey`), but only for the
330
+ 50 most recently saved ids; older entries are dropped.
331
+ `ManagedWindow.configure({ maxSavedWindows })` changes the cap
332
+ (`DEFAULT_MAX_SAVED_WINDOWS` is 50). Entries saved by an earlier version
333
+ carry no use stamp: those under the window manager's minted ids
334
+ (`twm-mw-…`, which never open again) are purged when the entry is read, and
335
+ the others are stamped in the order they were stored, as older than anything
336
+ saved since, so your stable ids keep their geometry. A window whose id is
337
+ minted per opening passes `persistGeometry: false` and neither saves nor
338
+ restores geometry; the window manager's own windows do. Modal windows never
339
+ saved geometry and still do not.
340
+ - **Focus moves in and back.** With `focusOnOpen` (default `true`), opening a
341
+ window focuses the first element of its content that takes focus (a link,
342
+ button, input, select, textarea, `contenteditable` or `tabindex` element
343
+ that is not disabled or hidden), or the window itself when there is none.
344
+ `focusOnOpen: 'window'` always focuses the window itself, for a dialog whose
345
+ first button must not catch a stray Enter (`showRunProgressDialog` uses it,
346
+ so Stop is never focused). Closing gives focus back to the element that had
347
+ it before the window opened, if that element is still in the document and
348
+ focus is still in the window or nowhere (on `body`); a window closed while
349
+ the user works elsewhere does not pull focus back. Restoring a minimised
350
+ window is not an open and moves nothing. A dialog that focuses a field of
351
+ its own a frame later, as `openForm` and `openConfirm` do, still wins, and
352
+ `Wizard.openInWindow` renders its first step before it shows the window, so
353
+ focus lands on the step's first field. Pass `focusOnOpen: false` for a
354
+ window that must not take focus when it appears; `showComputingWindow`
355
+ does.
356
+ - **The window itself holds focus.** Every window element has
357
+ `tabindex="-1"`: never a Tab stop, but a press on its title bar or on empty
358
+ content moves focus into the window (when it was not there already), so the
359
+ window's Esc still works after the user clicked it.
360
+ - **Esc closes a non-modal window only from inside it.** Which window an Esc
361
+ closes:
362
+ 1. a modal window on top, wherever focus is (as before);
363
+ 2. otherwise the window holding focus, modal or not — so a modal under a
364
+ non-modal window still closes on an Esc from inside it;
365
+ 3. otherwise (focus in no window) the topmost modal.
366
+ A non-modal window that does not hold focus never closes on Esc, so an Esc
367
+ meant for an editor beside it is left alone. An Esc something inside the
368
+ window already handled with `preventDefault()` (an autocomplete closing its
369
+ list) or one ending an IME composition (`isComposing`) closes nothing. One
370
+ Esc closes one window, even when the close hands focus to another window.
371
+
372
+ `tests/managed_window_lifecycle.test.mjs` holds all of these.
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Package:** `@flexdesk/widgets` · [All docs](../README.md)
4
4
 
5
- `createMenuBar` draws File / Edit / View menus from one declarative model, with pointer and keyboard behaviour and the WAI-ARIA menubar roles, and plugs into the shell. `ActionDropdown` draws a dropdown of action buttons from a trigger.
5
+ `createMenuBar` draws File / Edit / View menus from one declarative model, with pointer and keyboard behaviour and the WAI-ARIA menubar roles, and plugs into the shell. `ActionDropdown` draws a dropdown of action buttons from a trigger. `CategoryPicker` draws a categorised picker (search, categories, items, a detail pane) from a trigger.
6
6
 
7
7
  ## Context menus stay inside the window (0.17.9)
8
8
 
@@ -188,3 +188,87 @@ its right edge. Either flips to the other edge when the menu would leave the
188
188
  window. A tile's ⋯ menu uses `'end'`
189
189
  ([Dashboard tiles](tiles.md#the--menu-is-drawn-over-the-page-0179)).
190
190
 
191
+ ## A categorised picker: `CategoryPicker` (0.17.16)
192
+
193
+ An `ActionDropdown` is a flat list. When the choices fall into categories and
194
+ each needs a sentence (and perhaps a sample) before it is chosen, use
195
+ `CategoryPicker`: a search field over three columns — the categories (a
196
+ colour dot, the label, the item count), the active category's items (an icon
197
+ and the label), and a detail pane for the highlighted item (its title,
198
+ description, an optional monospace preview and an Add button).
199
+
200
+ ```js
201
+ import { CategoryPicker } from '@flexdesk/widgets';
202
+
203
+ const picker = new CategoryPicker({
204
+ trigger: addCellButton,
205
+ categories: [
206
+ { id: 'text', label: 'Text', color: 'var(--accent-light)', items: [
207
+ { id: 'heading', label: 'Heading', badge: 'H',
208
+ description: 'A section heading; it shapes the outline.',
209
+ preview: '## Debt dynamics' },
210
+ { id: 'doc', label: 'Documentation', icon: 'description',
211
+ description: 'Markdown with LaTeX math.', keywords: ['markdown', 'latex'] },
212
+ ] },
213
+ { id: 'functions', label: 'Functions', color: 'var(--color-warning)', items: [
214
+ { id: 'lag', label: 'Delay / Lag', badge: 'Δt',
215
+ description: 'The value of an expression some time ago.',
216
+ preview: 'expected = lag(inflation, 2)', keywords: 'delay history' },
217
+ ] },
218
+ ],
219
+ onSelect: (item, category) => insertCell(category.id, item.id),
220
+ placeholder: 'Search cells…', // the search field's placeholder (default 'Search…')
221
+ addLabel: 'Add cell', // the detail pane's button (default 'Add')
222
+ previewLabel: 'Writes', // a caption over the preview (none by default)
223
+ align: 'start', // or 'end', as ActionDropdown.position takes it
224
+ label: 'Add a cell', // the popover's accessible name (default 'Pick')
225
+ });
226
+ // The trigger's click toggles it; or drive it:
227
+ picker.open(); picker.close(); picker.toggle(); picker.position();
228
+ picker.setCategories(next); // finds the active category and the highlight again by id
229
+ picker.destroy();
230
+ ```
231
+
232
+ An item has `id` and `label`, and optionally `icon` (a Material Symbols
233
+ ligature) or `badge` (a short text drawn as a monospace badge in the
234
+ category's colour), `description`, `preview` (text, shown monospace and
235
+ pre-wrapped) and `keywords` (an array or a string; searched, never shown). A
236
+ category's `color` is any CSS colour; it reaches the rows as
237
+ `--twm-category-picker-color`, and without one the dot and badge are muted.
238
+ `onSelect(item, category)` gets your own objects back.
239
+
240
+ - **Anchored like ActionDropdown.** It is appended to `body`, placed by
241
+ `ActionDropdown.position` (`position: fixed`, under the trigger when it fits,
242
+ flipped above it when it does not, kept 8px inside the window, capped to the
243
+ room there), and drawn on `--z-over-windows`, over the managed window it
244
+ opened from. Its columns scroll inside; the popover itself does not. The
245
+ trigger gets `aria-haspopup="dialog"`, `aria-expanded` and `twm-is-open`.
246
+ - **Search.** Focus is in the search field on open (the query starts empty).
247
+ Typing filters every category's items by label, description and keywords:
248
+ each word must appear, case-insensitively. While a query is set the items
249
+ column is one hit list grouped under the category labels, the counts are hit
250
+ counts (a category with none is dimmed), and the first hit is highlighted.
251
+ Clicking a category then jumps to its first hit.
252
+ - **Keyboard.** With no query, Up/Down move within the current column (the
253
+ categories, or the items) and wrap; Left/Right move between the two. With a
254
+ query, Up/Down walk the hit list across categories and Left/Right stay the
255
+ caret's. Enter picks the highlighted item. A printable key typed while a row
256
+ has focus goes to the search field. Tab and Shift+Tab stay inside: they
257
+ cycle the search field and Add (when it is enabled), as a modal
258
+ ManagedWindow's focus trap does. Keys an IME is composing with are left to
259
+ it. Escape closes the picker and nothing
260
+ else (it is caught first and stopped, so a ManagedWindow around it stays
261
+ open). Escape and a pick give focus back to the trigger.
262
+ - **Pointer.** Hovering or clicking an item shows it in the detail pane;
263
+ double-clicking it, or Add, picks it. A click outside closes the picker.
264
+ Pressing anywhere inside but the search field keeps focus where it is, so
265
+ the keyboard keeps working (the preview's text cannot be drag-selected).
266
+ - **Accessibility.** The popover is a named `role="dialog"`. The search field
267
+ is a combobox whose `aria-activedescendant` is the active category while the
268
+ categories column is current, else the highlighted item. While a query is
269
+ set, each category's hits are a `role="group"` named by its header.
270
+
271
+ The Add button is FlexDesk's `twm-btn twm-btn--primary`; everything else is
272
+ styled from the tokens (`tests/category_picker.test.mjs` checks there is no
273
+ colour literal in its rules, and that base.css and flexdesk.css carry the same
274
+ block).
@@ -170,6 +170,43 @@ a top-level page showed its name twice, in the title bar and in a one-segment
170
170
  breadcrumb under it (STORY-0124). `renderBreadcrumb`, which draws segments a
171
171
  content routes itself, is unchanged.
172
172
 
173
+ ## A kind that lives in a window: `windowOnly` and `window` (0.17.16)
174
+
175
+ A kind can be a tool rather than a page: it opens in a managed window, never in
176
+ a tile, and it can say how big that window is.
177
+
178
+ ```js
179
+ const taxonomy = createTaxonomy({
180
+ root: 'start',
181
+ kinds: {
182
+ start: { label: 'Start', icon: 'apps' },
183
+ estimation: { label: 'Parameter Estimation', icon: 'tune',
184
+ single: true, windowOnly: true, window: { width: 1100, height: 700 } },
185
+ },
186
+ });
187
+ wm.navigate('estimation'); // a window, whatever `dest` says
188
+ ```
189
+
190
+ - **`window: { width?, height? }`** is the initial size, in px, of a window the
191
+ kind opens in, by `navigate(…, { dest: 'window' })` or, for a window-only
192
+ kind, always. ManagedWindow clamps it to the screen and to its minimum
193
+ (400 × 300), so a smaller size opens at the minimum. Without it a window
194
+ opens at ManagedWindow's default (600 × 400).
195
+ - **`windowOnly: true`** sends every open path to a window: `navigate` with
196
+ any `dest`, `openInPrimary`, `openInLeaf`, the tab verbs and
197
+ `splitLeafWith`, `navigateActiveTab` (crumbs, Backspace) and
198
+ `openInWindow`. With `single`, opening it again reveals the window that is
199
+ open. Its window never goes into a tile: it has no *Back to tile*, its
200
+ maximise button maximises it, and it does not snap onto a tile when dragged.
201
+ A restored layout that still holds a tab of the kind in a tile (or archived
202
+ under a page) drops it and warns once, naming the kinds; the rest of the
203
+ layout stays, and the next open makes a window. An entry of it in a tab's
204
+ back history (a layout saved before the kind was window-only) opens its
205
+ window when Backspace reaches it. Neither the root kind nor a top-nav
206
+ page can be window-only (`createTaxonomy` refuses).
207
+ - `taxonomy.isWindowOnly(kind)` and `taxonomy.windowSize(kind)` answer both
208
+ (STORY-0127).
209
+
173
210
  ## Buttons that change with the pane: `ctx.setChromeActions` (0.17.0)
174
211
 
175
212
  A factory may return `chromeActions` — `[{icon, title, onClick}]`, drawn in the
@@ -233,3 +233,40 @@ Until 0.17.8 the menu was `position: absolute` inside the tile, so in a tile
233
233
  narrower than about 220px its left part and its labels were cut off
234
234
  (BUG-0043). `tests/tile_menu_unclipped.test.mjs` holds it.
235
235
 
236
+
237
+ ## A double-click on the title strip configures the tile (0.17.16)
238
+
239
+ A double-click on a tile's header (`.tile-header`: its title, its drag grip or
240
+ the strip between them) does what the cogwheel (`.tile-config-btn`) does: it
241
+ calls the same `_onConfigClick`, so the tile is selected and
242
+ `tile:config-request` goes out on the event bus and as a DOM event exactly as
243
+ for a click on the cogwheel. A double-click on one of the strip's buttons
244
+ (cogwheel, ⋯, info, expand, remove) or in an editable field you put in the
245
+ strip (`input`, `textarea`, `select`, `contenteditable`) does not. A read-only
246
+ tile has no cogwheel and opens nothing.
247
+
248
+ `new TileGrid({ configOnDoubleClick: false })` turns it off for every tile of
249
+ the grid; a tile takes the same option (`tile.configOnDoubleClick`, read at the
250
+ double-click). `tests/tile_title_dblclick_config.test.mjs` holds it.
251
+
252
+ ## Several tiles marked as being configured: `grid.setConfiguring` (0.17.16)
253
+
254
+ A config request (cogwheel or double-click) highlights the requesting tile with
255
+ `tile--selected` and takes that highlight off every other tile of the grid:
256
+ one selection per grid, which suits one config panel. An embedder that keeps a
257
+ config window open per tile marks each of those tiles on the grid:
258
+
259
+ ```js
260
+ grid.setConfiguring(tileId, true); // when its config window opens
261
+ grid.setConfiguring(tileId, false); // when its config window closes
262
+ grid.isConfiguring(tileId); // true while marked
263
+ ```
264
+
265
+ The mark is the class `tile--configuring`, drawn exactly like `tile--selected`
266
+ (same rules in `flexdesk.css`). It is the embedder's alone: no config request,
267
+ on that tile or another, clears it. The grid holds it **by tile id**, so a tile
268
+ that `setLayout` disposes and rebuilds (or an id added later) is marked again,
269
+ and clearing it after a rebuild reaches the live tile. `tile.setConfiguring(on)`
270
+ and `tile.isConfiguring` remain as conveniences: on a tile in a grid they go
271
+ through the grid. Do not add or remove `tile--selected` from outside; it
272
+ belongs to the config request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.17.15",
3
+ "version": "0.17.16",
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": {
@@ -11,8 +11,9 @@
11
11
  * It draws a layout picker, a tab per subplot slot (when the layout has more
12
12
  * than one) and, for the subplot in front: its title; its chart type, legend,
13
13
  * interpolation, NaN handling and data points; its x axis (time, or a series);
14
- * its y axes (label, scale, position, min, max, step) and their series (a
15
- * picker, label, colour, width, dash, stack group, the embedder's own fields
14
+ * its y axes (label, scale, position, min, max, step) and their series (each a
15
+ * block: a header line of picker, label and remove over one label column of
16
+ * colour, width, dash, stack group, the embedder's own fields
16
17
  * and, with `overlaySources`, an overlay source); its z axis while the chart
17
18
  * type is 3D; its reference lines and shaded bands; and the toggles of any
18
19
  * injected `seriesTransforms` (series_transforms.js).
@@ -101,15 +102,37 @@ function removeButton(title, onClick) {
101
102
  return b;
102
103
  }
103
104
 
105
+ let hintCount = 0;
106
+
104
107
  /**
105
- * A labelled control. A single input or select is wrapped in a `<label>`; a
106
- * compound one (a picker, a colour field) is not, so a click on its text does
107
- * not land on whichever of its inputs comes first.
108
+ * A labelled control on the editor's label column. A single input or select
109
+ * is wrapped in a `<label>`; a compound one (a picker, a colour field) is not,
110
+ * so a click on its text does not land on whichever of its inputs comes
111
+ * first. A `hint` is drawn under the control, in the control's column, and
112
+ * outside any `<label>`: the row is then a `<div>` whose label text is the
113
+ * `<label for>` of a single control, and the hint describes the control
114
+ * (`aria-describedby`) without being part of its name.
108
115
  */
109
- function field(label, control) {
116
+ function field(label, control, { hint = null } = {}) {
110
117
  const single = control.matches('input, select, textarea');
111
- const row = el(single ? 'label' : 'div', 'twm-plot-config__field');
112
- row.append(el('span', 'twm-plot-config__label', label), control);
118
+ const hinted = hint != null && hint !== '';
119
+ if (!hinted) {
120
+ const row = el(single ? 'label' : 'div', 'twm-plot-config__field');
121
+ row.append(el('span', 'twm-plot-config__label', label), control);
122
+ return row;
123
+ }
124
+ const row = el('div', 'twm-plot-config__field twm-plot-config__field--hinted');
125
+ const text = el(single ? 'label' : 'span', 'twm-plot-config__label', label);
126
+ const note = el('span', 'twm-plot-config__hint', hint);
127
+ note.id = `twm-plot-config-hint-${++hintCount}`;
128
+ if (single) {
129
+ if (!control.id) control.id = `${note.id}-control`;
130
+ text.htmlFor = control.id;
131
+ }
132
+ const target = single ? control : (control.querySelector('input, select, textarea') ?? control);
133
+ const describedBy = target.getAttribute('aria-describedby');
134
+ target.setAttribute('aria-describedby', describedBy ? `${describedBy} ${note.id}` : note.id);
135
+ row.append(text, control, note);
113
136
  return row;
114
137
  }
115
138
 
@@ -170,8 +193,13 @@ let editorCount = 0;
170
193
  * source, written to `series.overlay.source`. Absent: no overlay controls.
171
194
  * @param {Object} [options.seriesTransforms] - `{ [id]: transform }` (series_transforms.js);
172
195
  * their toggles are drawn per subplot.
173
- * @param {Function} [options.extraSeriesFields] - `(el, series, { subplot, axis, onChange })
174
- * => { dispose? } | void`: fields of the embedder's own in each series row.
196
+ * @param {Function} [options.extraSeriesFields] - `(el, series, { subplot, axis, onChange, field })
197
+ * => { dispose? } | specs[] | void`: fields of the embedder's own in each series
198
+ * block, on the series' label column. It may draw into `el` (with
199
+ * `field(label, control, { hint })` for rows that line up) and return a handle,
200
+ * or return an ARRAY of field specs `{ label: string, control: HTMLElement,
201
+ * hint? }` / `{ label: string, render(slot) => { dispose? } | void, hint? }`,
202
+ * laid out after anything drawn into `el`. A bad spec is skipped with a warning.
175
203
  * @param {Object} [options.logger] - Given to every AutocompleteField (provider errors);
176
204
  * its `warn` also hears a part (an `extraSeriesFields` handle, say) that throws on dispose.
177
205
  * @returns {{ dispose: Function, refresh: Function, selectSubplot: Function,
@@ -309,13 +337,14 @@ function renderPlotConfigEditor(container, config, options = {}) {
309
337
  return node;
310
338
  }
311
339
 
312
- function colorField(value, onSet, label) {
340
+ /** `keep` takes its teardown: the body's (track), or a narrower redraw's. */
341
+ function colorField(value, onSet, label, keep = track) {
313
342
  const f = new ColorField({
314
343
  value: value ?? '',
315
344
  label,
316
345
  onChange: (v) => { if (v !== null) { onSet(v || null); notify(); } },
317
346
  });
318
- track(() => f.dispose());
347
+ keep(() => f.dispose());
319
348
  return f.render();
320
349
  }
321
350
 
@@ -489,18 +518,17 @@ function renderPlotConfigEditor(container, config, options = {}) {
489
518
  return group;
490
519
  }
491
520
 
521
+ /**
522
+ * One series as a block: a header line (series picker, legend label,
523
+ * remove) over its fields on the editor's label column (colour, width,
524
+ * dash, stack group, the embedder's fields, overlay).
525
+ */
492
526
  function seriesRow(sp, axis, s, overlaySources) {
493
527
  const row = el('div', 'twm-plot-config__series');
494
- const main = el('div', 'twm-plot-config__series-main');
495
- main.append(
528
+ const head = el('div', 'twm-plot-config__series-head');
529
+ head.append(
496
530
  seriesPicker(s, 'variable', 'series', sp, 'Series'),
497
531
  textInput(s.label, (v) => { s.label = v; notify(); }, { placeholder: 'Legend label', label: 'Legend label' }),
498
- colorField(s.color, (v) => { if (v) s.color = v; else delete s.color; }, 'Colour'),
499
- numberInput(s.lineWidth ?? 2, (v) => { s.lineWidth = v ?? 2; notify(); },
500
- { placeholder: '2', label: 'Line width', min: 1, max: 10, step: 1 }),
501
- select(DASHES, s.lineStyle ?? 'solid', (v) => { s.lineStyle = v; notify(); }, { label: 'Dash' }),
502
- textInput(s.stackGroup, (v) => { s.stackGroup = v || null; notify(); },
503
- { placeholder: 'Stack group', label: 'Stack group' }),
504
532
  removeButton('Remove series', () => {
505
533
  const i = axis.series.indexOf(s);
506
534
  if (i >= 0) axis.series.splice(i, 1);
@@ -508,32 +536,100 @@ function renderPlotConfigEditor(container, config, options = {}) {
508
536
  notify();
509
537
  }),
510
538
  );
511
- row.appendChild(main);
512
539
 
513
- if (opts.extraSeriesFields) {
514
- const extra = el('div', 'twm-plot-config__series-extra');
515
- row.appendChild(extra);
516
- const handle = opts.extraSeriesFields(extra, s, { subplot: sp, axis, onChange: notify });
517
- if (typeof handle?.dispose === 'function') track(() => handle.dispose());
518
- }
519
- if (opts.overlaySources) row.appendChild(overlayEditor(s, overlaySources));
540
+ const fields = el('div', 'twm-plot-config__series-fields');
541
+ fields.append(
542
+ field('Colour', colorField(s.color, (v) => { if (v) s.color = v; else delete s.color; }, 'Colour')),
543
+ field('Width', numberInput(s.lineWidth ?? 2, (v) => { s.lineWidth = v ?? 2; notify(); },
544
+ { placeholder: '2', label: 'Line width', min: 1, max: 10, step: 1 })),
545
+ 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
+ );
549
+ if (opts.extraSeriesFields) fields.appendChild(extraFields(sp, axis, s));
550
+ if (opts.overlaySources) fields.appendChild(overlayEditor(s, overlaySources));
551
+ row.append(head, fields);
520
552
  return row;
521
553
  }
522
554
 
555
+ const warn = (...args) => (opts.logger?.warn ? opts.logger.warn(...args) : console.warn(...args));
556
+
557
+ /**
558
+ * The embedder's fields of a series: whatever `extraSeriesFields` draws
559
+ * into the box (which sits in the series' field column), then, when it
560
+ * returns an ARRAY, those field specs laid out by `field()`. Anything
561
+ * else it returns is a handle, disposed when the row goes (`{ dispose }`).
562
+ * A spec that is not `{ label, control: Element }` or
563
+ * `{ label, render(slot) }` is skipped with a warning, as is a `render`
564
+ * that throws: one bad field never leaves half a body.
565
+ */
566
+ function extraFields(sp, axis, s) {
567
+ const extra = el('div', 'twm-plot-config__series-extra');
568
+ const result = opts.extraSeriesFields(extra, s, { subplot: sp, axis, onChange: notify, field });
569
+ if (!Array.isArray(result)) {
570
+ if (typeof result?.dispose === 'function') track(() => result.dispose());
571
+ return extra;
572
+ }
573
+ for (const spec of result) {
574
+ const label = spec?.label;
575
+ let control = null;
576
+ if (typeof label !== 'string') {
577
+ warn('[plot config editor] an extraSeriesFields spec has no label; skipped', spec);
578
+ continue;
579
+ }
580
+ if (spec.control != null) {
581
+ if (!(spec.control instanceof Element)) {
582
+ warn('[plot config editor] an extraSeriesFields spec control is not an Element; skipped', spec);
583
+ continue;
584
+ }
585
+ control = spec.control;
586
+ } else if (typeof spec.render === 'function') {
587
+ control = el('div', 'twm-plot-config__slot');
588
+ try {
589
+ const handle = spec.render(control);
590
+ if (typeof handle?.dispose === 'function') track(() => handle.dispose());
591
+ } catch (error) {
592
+ warn('[plot config editor] an extraSeriesFields spec failed to render; skipped', error);
593
+ continue;
594
+ }
595
+ } else {
596
+ warn('[plot config editor] an extraSeriesFields spec has neither control nor render; skipped', spec);
597
+ continue;
598
+ }
599
+ extra.appendChild(field(label, control, { hint: spec.hint }));
600
+ }
601
+ return extra;
602
+ }
603
+
523
604
  function overlayEditor(s, overlaySources) {
524
605
  const wrap = el('div', 'twm-plot-config__overlay');
525
606
  const head = el('div', 'twm-plot-config__overlay-source', 'Loading overlay sources…');
526
607
  const details = el('div', 'twm-plot-config__overlay-fields');
527
608
  wrap.append(head, details);
528
609
 
610
+ // The details' colour field is torn down on each redraw of the
611
+ // details, not left for the body's next redraw.
612
+ let detailDisposers = [];
613
+ const disposeDetails = () => {
614
+ const fns = detailDisposers;
615
+ detailDisposers = [];
616
+ for (const fn of fns) {
617
+ try { fn(); } catch (error) {
618
+ opts.logger?.warn?.('[plot config editor] a field failed to dispose', error);
619
+ }
620
+ }
621
+ };
622
+ track(disposeDetails);
623
+ const keep = (fn) => { detailDisposers.push(fn); };
529
624
  const drawDetails = () => {
625
+ disposeDetails();
530
626
  details.replaceChildren();
531
627
  const ov = s.overlay;
532
628
  if (!ov) return;
533
629
  details.append(
534
630
  field('Label', textInput(ov.label, (v) => { ov.label = v; notify(); },
535
631
  { placeholder: `${s.label || s.variable || 'Series'} (actual)` })),
536
- field('Colour', colorField(ov.color, (v) => { if (v) ov.color = v; else delete ov.color; }, 'Overlay colour')),
632
+ field('Colour', colorField(ov.color, (v) => { if (v) ov.color = v; else delete ov.color; }, 'Overlay colour', keep)),
537
633
  field('Dash', select(DASHES, ov.lineStyle ?? 'dashed', (v) => { ov.lineStyle = v; notify(); })),
538
634
  field('Width', numberInput(ov.lineWidth ?? 2, (v) => { ov.lineWidth = v ?? 2; notify(); },
539
635
  { placeholder: '2', min: 1, max: 10, step: 1 })),