flexdesk 0.16.0 → 0.17.0
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 +93 -0
- package/LICENSE +0 -0
- package/README.md +2261 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3373 -238
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +709 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
- package/dist/chunk-MCIXFNVF.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
- package/dist/chunk-Q36EPT2R.js.map +7 -0
- package/dist/chunk-QWQJUDPD.js +610 -0
- package/dist/chunk-QWQJUDPD.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/chunk-UWO43BZU.js +111 -0
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3373 -238
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +1131 -253
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3254 -2112
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -345
- package/dist/wm.js.map +4 -4
- package/package.json +3 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +422 -0
- package/src/tiles/scope_picker.js +96 -0
- package/src/tiles/tile_base.js +12 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +38 -1
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +211 -0
- package/src/ui/components/cell_grid.js +648 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +601 -62
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +26 -4
- package/src/ui/components/staged_edits.js +343 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-CWPMLX67.js +0 -475
- package/dist/chunk-CWPMLX67.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- package/src/ui/controllers/panel_state_machine.js +0 -634
package/README.md
CHANGED
|
@@ -43,9 +43,9 @@ manager, and neither of them should drag in a plotting engine or a code editor.
|
|
|
43
43
|
| `@flexdesk/core` | 30 kB | Event bus, settings store, logging, state machine, state guard |
|
|
44
44
|
| `@flexdesk/host` | 5 kB | The host port (below) |
|
|
45
45
|
| `@flexdesk/wm` | 166 kB | Tiling window manager: tabs, splits, desktops, command palette, taxonomy, `createShell()` |
|
|
46
|
-
| `@flexdesk/widgets` | 192 kB | DataTable, modals, managed windows, toasts, trees, panels, context menus |
|
|
47
|
-
| `@flexdesk/charts` | 26 kB | Plotly wrapper, chart types, subplot layouts, downsampling, plot windows |
|
|
48
|
-
| `@flexdesk/tiles` | 67 kB | Draggable/resizable widget grid, tile base class, widget registry, layout persistence, chart and KPI tiles |
|
|
46
|
+
| `@flexdesk/widgets` | 192 kB | DataTable, modals, managed windows, toasts, trees, panels, context menus, menu bars, a document outline, progress, a conversation view |
|
|
47
|
+
| `@flexdesk/charts` | 26 kB | Plotly wrapper, chart types, subplot layouts, downsampling, a plot grid, a plot-config editor, plot windows, injected series transforms |
|
|
48
|
+
| `@flexdesk/tiles` | 67 kB | Draggable/resizable widget grid, tile base class, widget registry, layout persistence, figure, chart and KPI tiles |
|
|
49
49
|
| `@flexdesk/editor` | 62 kB | Monaco loader + factory, multi-file tab bar, search, split panes, undo manager |
|
|
50
50
|
| `@flexdesk/canvas` | 14 kB | The canvas kernel: a pannable, zoomable surface, the edge router, a generic graph layer ([Flow editors](#flow-editors)) |
|
|
51
51
|
| `@flexdesk/flow` | 381 kB | The flow-editor kit and the canvas, outline and lane editors ([Flow editors](#flow-editors)) |
|
|
@@ -130,6 +130,113 @@ await createShell({
|
|
|
130
130
|
});
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
+
### A menu bar: `createMenuBar` (0.17.0)
|
|
134
|
+
|
|
135
|
+
File / Edit / View menus from one declarative model, instead of hand-built
|
|
136
|
+
buttons and `showContextMenu` calls. A menu is `{ id, label, items }`; an item
|
|
137
|
+
is `{ id, label, shortcut?, disabled?, checked?, radio?, submenu? }` or
|
|
138
|
+
`{ separator: true }`:
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
import { createMenuBar, MenuBar } from '@flexdesk/widgets';
|
|
142
|
+
|
|
143
|
+
const bar = createMenuBar(document.getElementById('menus'), {
|
|
144
|
+
menus: [
|
|
145
|
+
{ id: 'file', label: 'File', items: [
|
|
146
|
+
{ id: 'open', label: 'Open…', shortcut: 'Ctrl+O' },
|
|
147
|
+
{ id: 'recent', label: 'Open recent', submenu: () => recentItems() }, // rebuilt on every open
|
|
148
|
+
{ separator: true },
|
|
149
|
+
{ id: 'save', label: 'Save', shortcut: 'Ctrl+S', disabled: true },
|
|
150
|
+
] },
|
|
151
|
+
{ id: 'view', label: 'View', items: [
|
|
152
|
+
{ id: 'grid', label: 'Show grid', checked: true }, // a check item
|
|
153
|
+
{ id: 'light', label: 'Light', radio: 'theme', checked: true }, // a radio group
|
|
154
|
+
{ id: 'dark', label: 'Dark', radio: 'theme', checked: false },
|
|
155
|
+
] },
|
|
156
|
+
],
|
|
157
|
+
onAction: (id, { checked }) => run(id, checked),
|
|
158
|
+
});
|
|
159
|
+
bar.setEnabled('save', true); // patches an open menu in place
|
|
160
|
+
bar.setChecked('dark', true); // …and unchecks the rest of the radio group
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`createMenuBar(el, opts)` returns a `MenuBar` (also exported, for
|
|
164
|
+
`instanceof`). Its methods: `open(menuId, { focusItem })`, `close()`,
|
|
165
|
+
`focus()`, `isOpen()`, `isEnabled(id)`, `isChecked(id)`, `setEnabled(id, on)`,
|
|
166
|
+
`setChecked(id, on)`, `commands()` and `dispose()`.
|
|
167
|
+
|
|
168
|
+
- **Pointer.** Clicking a title opens its menu; while one is open, hovering
|
|
169
|
+
another title switches to it. Hovering an item with a submenu opens it.
|
|
170
|
+
- **Keyboard.** Alt (on its own) or F10 focuses the bar. Left/Right move across
|
|
171
|
+
menus, Up/Down/Home/End within one, Right opens a submenu and Left closes it,
|
|
172
|
+
Enter/Space activate. Esc closes one level; the last Esc gives focus back to
|
|
173
|
+
what had it. Disabled items and separators are skipped, and never reach
|
|
174
|
+
`onAction`.
|
|
175
|
+
- **Roles.** The WAI-ARIA menubar pattern: `menubar`, `menu`, `menuitem`,
|
|
176
|
+
`menuitemcheckbox`, `menuitemradio`, with `aria-checked`, `aria-disabled`
|
|
177
|
+
and `aria-expanded`.
|
|
178
|
+
- `shortcut` is a label: the bar binds no key for it.
|
|
179
|
+
- `bar.commands()` returns the model as palette rows (`{ id, label, shortcut,
|
|
180
|
+
context: 'File › Open recent', action }`), so the palette and the bar list the
|
|
181
|
+
same verbs.
|
|
182
|
+
|
|
183
|
+
In a shell, pass the model as `menuBar` and the element as `chrome.menuBar`
|
|
184
|
+
(each needs the other; either alone throws at boot);
|
|
185
|
+
`shell.chrome.menuBar` is that `MenuBar`, and its enabled items follow the palette's
|
|
186
|
+
own `commands` unless `menuBar.palette` is `false`:
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
const shell = await createShell({
|
|
190
|
+
// …
|
|
191
|
+
menuBar: { menus, onAction },
|
|
192
|
+
chrome: { menuBar: document.getElementById('menus') },
|
|
193
|
+
});
|
|
194
|
+
shell.chrome.menuBar.setEnabled('save', dirty);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The bar is `.twm-menubar` (not the older `.twm-menu-bar` icon strip).
|
|
198
|
+
`tests/menu_bar.test.mjs` drives it in jsdom; `tests/menu_bar_css.test.mjs`
|
|
199
|
+
holds its stylesheet.
|
|
200
|
+
|
|
201
|
+
### A kind that exists once: `single` (0.17.0)
|
|
202
|
+
|
|
203
|
+
Some content holds heavy app-wide state and must not be mounted twice. Mark its
|
|
204
|
+
kind `single` in the taxonomy:
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
createTaxonomy({
|
|
208
|
+
root: 'cosmos',
|
|
209
|
+
kinds: {
|
|
210
|
+
cosmos: { label: 'Cosmos', isTopNav: true },
|
|
211
|
+
observatory: { label: 'Observatory', isTopNav: true, single: true }, // one, ever
|
|
212
|
+
planet: { label: 'Planet', single: 'id' }, // one per props.id
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- **Every open path reveals the instance that exists** — `navigate`, the top
|
|
218
|
+
nav, `openInPrimary`, `openInTab*`, `splitLeafWith`, Back. It switches to the
|
|
219
|
+
desktop it is on, activates its tab (bringing back a top-nav page the tile
|
|
220
|
+
had switched away from) and focuses its tile, or raises its window. Nothing
|
|
221
|
+
is mounted twice, and a reveal is one `onChange` / `wm:changed`
|
|
222
|
+
(`'single-reveal'`), however many steps it took.
|
|
223
|
+
- **A page restore never brings back a copy.** A tile's archived top-nav page
|
|
224
|
+
can still list an instance that has since moved to another tile or a window;
|
|
225
|
+
switching back to that page (top nav, `openInPrimary`, Back) leaves it out.
|
|
226
|
+
- **The copy verbs refuse.** Alt+T, Alt+N, Alt+Shift+H/V do nothing; the tile
|
|
227
|
+
menu's *Open in new tab* and *Open a copy in a window* are greyed with the
|
|
228
|
+
reason as their tooltip (`taxonomy.singleReason(kind)`); the palette draws its
|
|
229
|
+
split and window hints greyed, and Ctrl/Shift+Enter on such a row does nothing.
|
|
230
|
+
- **The move verbs still work**: float, dock, drag a tab, move to a desktop.
|
|
231
|
+
- **A restored layout keeps the first** of any duplicates (desktop order, then
|
|
232
|
+
tile order): the other leaf shows its next tab, or closes if it held nothing
|
|
233
|
+
else.
|
|
234
|
+
|
|
235
|
+
The root kind cannot be `single` — every new pane, tab and desktop is seeded
|
|
236
|
+
with it. The taxonomy answers `isSingle(kind)`, `instanceKey(kind, props)` and
|
|
237
|
+
`singleReason(kind)`; the window manager answers `isSingleKind(kind)`,
|
|
238
|
+
`findInstance(kind, props)` and `revealInstance(kind, props)`.
|
|
239
|
+
|
|
133
240
|
### A pane that names what it shows: `ctx.setTitle` (0.12.1)
|
|
134
241
|
|
|
135
242
|
A content factory receives `(hostEl, props, ctx)`, and `ctx` carries, per pane,
|
|
@@ -152,6 +259,125 @@ content: {
|
|
|
152
259
|
chrome shows only — the tree, the tab labels and the saved layout are left
|
|
153
260
|
alone — and a call from content whose pane has since been rebuilt does nothing.
|
|
154
261
|
|
|
262
|
+
### Panels a page does not offer (0.17.0)
|
|
263
|
+
|
|
264
|
+
A kind says which panels it offers while it is the primary tile. Each side is
|
|
265
|
+
`true`, `false` or `{ reason }`; a side it does not name is offered, and a kind
|
|
266
|
+
that does not name a side takes it from its top-nav kind, so one declaration on
|
|
267
|
+
a page covers every record opened under it:
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
kinds: {
|
|
271
|
+
paper: { label: 'Paper', isTopNav: true, panels: { bottom: { reason: 'No console here' } } },
|
|
272
|
+
sheet: { label: 'Sheet', topNav: 'paper' }, // withholds it too
|
|
273
|
+
slide: { label: 'Slide', topNav: 'paper', panels: { bottom: true } }, // offers it
|
|
274
|
+
},
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
While a panel is withheld it is not in the layout, `wm.togglePanel(side)`
|
|
278
|
+
answers `false` and changes nothing (nor what is saved), `wm.isPanelOpen(side)`
|
|
279
|
+
is `false`, and the shell's toggle button and the palette chip carry
|
|
280
|
+
`aria-disabled="true"` with the reason as their tooltip (`data-tooltip` on a
|
|
281
|
+
`twm-has-tooltip` button, `title` otherwise; the button's own comes back after).
|
|
282
|
+
The desktop still remembers the panel was open, and it comes back open when the
|
|
283
|
+
user returns to a page that offers it. With no declarations every panel is
|
|
284
|
+
offered, exactly as before.
|
|
285
|
+
|
|
286
|
+
Content overrides its kind at runtime, until it clears the override with `null`:
|
|
287
|
+
|
|
288
|
+
```js
|
|
289
|
+
ctx.wm.setPanelAvailability('bottom', { reason: 'Run the model first' });
|
|
290
|
+
ctx.wm.setPanelAvailability('bottom', null); // e.g. in destroy()
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`wm.isPanelAvailable(side)` and `wm.getPanelAvailability(side)` (`{ available,
|
|
294
|
+
reason }`) read it, and a change is announced once — whatever caused it and
|
|
295
|
+
however many sides it moved — to `wm.onPanelAvailabilityChange(fn)` (which
|
|
296
|
+
returns the unsubscribe) and on the bus as `wm:panels:availability`, both with
|
|
297
|
+
`{ panels, changed }`. `togglePanel` now answers `true` when it flipped a panel.
|
|
298
|
+
|
|
299
|
+
`PanelStateMachine`, `PANEL_STATES` and `PANEL_TRANSITIONS` are gone from
|
|
300
|
+
`@flexdesk/widgets`: nothing in the library used them, and which panels a page
|
|
301
|
+
offers is now said once, in the taxonomy.
|
|
302
|
+
|
|
303
|
+
`tests/panel_availability.test.mjs` drives all of it against a real shell.
|
|
304
|
+
|
|
305
|
+
### Panels keep the size you dragged them to (0.17.0)
|
|
306
|
+
|
|
307
|
+
Each desktop remembers every panel's share of the window-manager area along
|
|
308
|
+
its axis (width for left and right, height for bottom), taken when a splitter
|
|
309
|
+
drag ends, and saves it with the layout as `panelShares`. A reload, a switch to
|
|
310
|
+
another desktop and back, and toggling one panel all rebuild the panels at
|
|
311
|
+
those shares, and a closed panel comes back at the size it had. Only a panel
|
|
312
|
+
that has never been sized on that desktop opens at the default 1:4. No split is
|
|
313
|
+
built tighter than a drag allows (`TileRenderer.MIN_SLOT_FRACTION` of it per
|
|
314
|
+
slot). A `TileRenderer` built by hand takes `onSplitResized(splitId)`, called
|
|
315
|
+
once when a drag that changed a split's sizes ends.
|
|
316
|
+
|
|
317
|
+
`tests/panel_sizes_kept.test.mjs` covers each of these.
|
|
318
|
+
|
|
319
|
+
### A navigation panel you drive yourself: `mountNavPanel` (0.17.0)
|
|
320
|
+
|
|
321
|
+
`mountNavPanel(hostEl, { modes })` (from `@flexdesk/wm`) builds a mode strip, a
|
|
322
|
+
shared filter box and one lazily-mounted pane per mode. A strip click is no
|
|
323
|
+
longer the only way to switch: the handle's `select(modeId)` takes the same
|
|
324
|
+
path — it mounts the mode on first use, runs its `activate()`, restores that
|
|
325
|
+
mode's filter text — and its promise resolves once `activate()` has run. An
|
|
326
|
+
unknown id is ignored. `active()` names the mode shown.
|
|
327
|
+
|
|
328
|
+
```js
|
|
329
|
+
const nav = mountNavPanel(hostEl, {
|
|
330
|
+
modes: [
|
|
331
|
+
{ id: 'files', icon: 'folder', label: 'Files', mount: mountFiles },
|
|
332
|
+
{ id: 'runs', icon: 'history', label: 'Runs', mount: mountRuns,
|
|
333
|
+
filter: false }, // a pane with controls of its own
|
|
334
|
+
],
|
|
335
|
+
showModeStrip: false, // no strip: the app switches with select()
|
|
336
|
+
initialMode: 'runs', // the mode shown at mount (default: the first)
|
|
337
|
+
});
|
|
338
|
+
await nav.select('files'); // mounted and activated
|
|
339
|
+
nav.active(); // 'files'
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
A mode with `filter: false` hides the filter box while it is active, and its
|
|
343
|
+
pane is never handed a query; the next mode with a filter shows its own saved
|
|
344
|
+
text again. `showFilter: false` renders no filter box at all. All three options
|
|
345
|
+
default to the old behaviour, so a panel built without them renders exactly as
|
|
346
|
+
before.
|
|
347
|
+
|
|
348
|
+
### Buttons that change with the pane: `ctx.setChromeActions` (0.17.0)
|
|
349
|
+
|
|
350
|
+
A factory may return `chromeActions` — `[{icon, title, onClick}]`, drawn in the
|
|
351
|
+
pane's chrome left of the split/float/close buttons — but those are read once.
|
|
352
|
+
`ctx.setChromeActions(specs)` replaces them at any time, the way `setTitle`
|
|
353
|
+
replaces the title: called during the mount it wins over the returned list, the
|
|
354
|
+
later call wins, the list survives repaints, `null` or `[]` clears it, and a
|
|
355
|
+
call from content whose pane has since been rebuilt does nothing. The content is
|
|
356
|
+
never re-mounted.
|
|
357
|
+
|
|
358
|
+
```js
|
|
359
|
+
content: {
|
|
360
|
+
page: (hostEl, props, ctx) => {
|
|
361
|
+
let wide = false;
|
|
362
|
+
const paint = () => ctx.setChromeActions([
|
|
363
|
+
{ icon: wide ? 'width_normal' : 'width_wide', title: 'Page width',
|
|
364
|
+
pressed: wide, onClick: () => { wide = !wide; paint(); } },
|
|
365
|
+
{ separator: true },
|
|
366
|
+
{ icon: 'add', label: 'New', title: 'Add a page', onClick: addPage },
|
|
367
|
+
{ icon: 'publish', title: 'Nothing to publish yet', disabled: true },
|
|
368
|
+
]);
|
|
369
|
+
paint();
|
|
370
|
+
return { destroy() {} };
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
A spec may add **`label`** (text beside the glyph, or instead of it),
|
|
376
|
+
**`pressed`** (`aria-pressed`, plus a pressed style; leave it out for a button
|
|
377
|
+
that is not a toggle), **`disabled`** (greyed and inert, with its tooltip kept —
|
|
378
|
+
it is `aria-disabled`, not the `disabled` attribute) and **`{separator: true}`**
|
|
379
|
+
(a divider). A spec with none of them renders exactly as before.
|
|
380
|
+
|
|
155
381
|
### Your desktops beside the app's (0.10.0)
|
|
156
382
|
|
|
157
383
|
Pass `chrome.desktops` and the shell paints a desktop bar: a chip per desktop
|
|
@@ -215,6 +441,81 @@ at most half the query (`cstmr` finds *customer*; `cour` does not find *Scroll u
|
|
|
215
441
|
to*). A drill's `search` is debounced, and a stale answer is aborted and
|
|
216
442
|
never drawn. `palette.invalidate()` drops the cache.
|
|
217
443
|
|
|
444
|
+
**The built-in *Keyboard shortcuts* command** opens the help topic
|
|
445
|
+
`keyboard-shortcuts`. If your `HelpProvider` defines that id, your topic is
|
|
446
|
+
shown. If it does not, or you register no help at all, the modal shows the window
|
|
447
|
+
manager's own bindings, `KEYMAP_SHORTCUTS` from `@flexdesk/wm`, as a table
|
|
448
|
+
(`keyboardShortcutsTopic()`). Before this the modal showed *Failed to load help
|
|
449
|
+
topic.* The fallback comes from `HelpModal.open(id, { fallback })`
|
|
450
|
+
(`@flexdesk/widgets`), and you can pass one yourself. A topic id that nobody
|
|
451
|
+
provides now shows *Help topic not found.* where it used to raise a load error.
|
|
452
|
+
|
|
453
|
+
### Your keys, not the window manager's: `keymap` (0.17.0)
|
|
454
|
+
|
|
455
|
+
The window manager binds F1..F12, Backspace, Ctrl+K, Ctrl+Tab, Ctrl/Shift+Arrows,
|
|
456
|
+
the Alt chords, `?` and Esc, and swallows any other bare Alt+letter. Each of
|
|
457
|
+
those chords can be taken back or rebound:
|
|
458
|
+
|
|
459
|
+
```js
|
|
460
|
+
await createShell({
|
|
461
|
+
// …everything above…
|
|
462
|
+
keymap: {
|
|
463
|
+
bindings: {
|
|
464
|
+
'F1': false, // F1 is the page's: not prevented, nothing clicked
|
|
465
|
+
'F2': 'nav:0', // F2 opens the first top-nav kind
|
|
466
|
+
'Backspace': false, // Backspace never calls navigateBack
|
|
467
|
+
'Alt+W': (e, { wm }) => { /* yours; return false to leave e unprevented */ },
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
A value is `false`, an action name, or a function. The actions are `palette`,
|
|
474
|
+
`palette:close`, `help`, `back`, `nav:N`, `desktop:N`, `toDesktop:N` (all
|
|
475
|
+
counted from 0), `desktop:next|prev`, `tab:next|prev`, `panelTab:next|prev`,
|
|
476
|
+
`split:h|v`, `splitWith:h|v`, `openInTab`, `openInWindow`, `close`, `float`,
|
|
477
|
+
`focus:left|right|up|down`, `move:left|right|up|down` and `suppress`. The chord
|
|
478
|
+
ids are the defaults in `src/tiling/keymap.js`: `Ctrl+K`, `Meta+K`, `?`,
|
|
479
|
+
`Escape`, `F1`..`F12`, `Backspace`, `Alt+H`, `Alt+Shift+H`, `Alt+ArrowLeft`,
|
|
480
|
+
`Alt+Shift+1`, `Ctrl+Tab`, `Shift+ArrowRight` and so on, with `Alt+Letter` for
|
|
481
|
+
the Alt+letter suppression. Modifiers may come in any order and case. A chord id
|
|
482
|
+
or action the keymap does not know is warned about once, through `logger` (else
|
|
483
|
+
`console`), when the shell is built, and is then ignored. Only the window
|
|
484
|
+
manager's own chords can be rebound. To add a new key, put your own listener on
|
|
485
|
+
the page. `keymap` itself must be an object whose only key is `bindings`:
|
|
486
|
+
anything else (a string, a typo such as `{ binding: … }`) makes `createShell`
|
|
487
|
+
throw a `TypeError`, so a binding you meant to change never stays in force
|
|
488
|
+
unnoticed.
|
|
489
|
+
|
|
490
|
+
**A key press is read as loosely as before.** With no bindings every key does
|
|
491
|
+
what it did in 0.16, and a binding takes over every key press its chord was
|
|
492
|
+
read as. Backspace ignores Shift and Alt, so `{ Backspace: false }` also hands
|
|
493
|
+
Shift+Backspace and Alt+Backspace to the page. Alt+T/N/W/F ignore Shift. With
|
|
494
|
+
Alt held, Ctrl and Meta are ignored, so Alt+Ctrl+H is `Alt+H`. Ctrl+K and
|
|
495
|
+
Ctrl+Tab also match with Meta held, and Ctrl wins when both are held:
|
|
496
|
+
Ctrl+Meta+K is `Ctrl+K`, and Meta+K is `Meta+K` only without Ctrl. Esc and `?`
|
|
497
|
+
ignore every modifier. F1..F12, Shift+Left/Right and Ctrl+Left/Right match only
|
|
498
|
+
with exactly those modifiers.
|
|
499
|
+
|
|
500
|
+
**A key a component handled is the component's.** The keymap skips every
|
|
501
|
+
keydown that is already `defaultPrevented`. A list that deletes a row on
|
|
502
|
+
Backspace calls `preventDefault()`, and the window manager does not navigate
|
|
503
|
+
back as well.
|
|
504
|
+
|
|
505
|
+
**`?` is installed by the shell.** Importing `HelpModal` no longer adds a
|
|
506
|
+
`document` listener as a side effect. A shell binds `?` as one of its chords,
|
|
507
|
+
and `shell.dispose()` removes it with the rest. A page that uses the help modal
|
|
508
|
+
without a shell calls `installHelpShortcut()` from `@flexdesk/widgets`, which
|
|
509
|
+
returns its disposer. `HelpModal.toggle()` is what `?` does.
|
|
510
|
+
|
|
511
|
+
**The *Keyboard shortcuts* command shows the bindings in effect.** It lists
|
|
512
|
+
`keymapShortcuts(bindings)` from `@flexdesk/wm`. That is `KEYMAP_SHORTCUTS`
|
|
513
|
+
with each changed row replaced by one line per chord still bound, and no line
|
|
514
|
+
for a chord you disabled. `createCommandPalette({ shortcuts })` takes that table.
|
|
515
|
+
The command's hint names the keys that open help in it: `?` by default, `F9`
|
|
516
|
+
after `{ '?': false, F9: 'help' }`, and none when no key opens help (for
|
|
517
|
+
example after `{ '?': 'palette' }`).
|
|
518
|
+
|
|
218
519
|
**`chrome.zoom`** paints a − / track / + / readout control into the element you
|
|
219
520
|
pass, restores the saved zoom through the host's `state` capability, and scales
|
|
220
521
|
the content of tiles and floating windows. It deliberately scales *content only*
|
|
@@ -903,12 +1204,46 @@ list.setColumnVisible(3, true);
|
|
|
903
1204
|
| `columnChooser: true` | A right-click on the header opens a menu that lists every column, with a check mark on those shown. Choosing one shows or hides it. The last column shown is greyed out and says why. Without the option the header keeps the browser's menu. |
|
|
904
1205
|
| `setColumnVisible(colIdx, on)` | Shows or hides a column from code. Returns whether anything changed. The widths of the other columns, dragged or fitted, stay with their columns; a column shown again is measured. |
|
|
905
1206
|
| `getHiddenColumns()` | The hidden columns' indices, ascending. |
|
|
906
|
-
| `onColumnsChange(hidden)` | Called after every change of the set: the chooser, `setColumnVisible` and `setData({ hiddenColumns })`. |
|
|
1207
|
+
| `onColumnsChange(hidden, { hidden, order })` | Called after every change of the set: the chooser, `setColumnVisible` and `setData({ hiddenColumns })`, and after every change of the column order (see below). `order` is `getColumnOrder()`. |
|
|
907
1208
|
| With `persistKey` | The set is saved with the sort and the filters and restored on mount. A saved set wins over `hiddenColumns`, so a column somebody chose stays chosen. A table without these options saves exactly what 0.9.1 saves. |
|
|
908
1209
|
|
|
909
1210
|
A column sorted on and then hidden keeps the order; its arrow comes back with
|
|
910
1211
|
the column.
|
|
911
1212
|
|
|
1213
|
+
### DataTable: columns in the order somebody puts them (0.17.0)
|
|
1214
|
+
|
|
1215
|
+
Columns can be dragged into a new order by their headers, or ordered from
|
|
1216
|
+
code, and the order is remembered. The order is kept by data index, so
|
|
1217
|
+
`hiddenColumns`, the sort, the filters, `renderCell`'s `colIdx` and cell
|
|
1218
|
+
mode's `col` stay on their columns. A table that sets none of these options
|
|
1219
|
+
draws its columns in their natural order and saves what 0.16 saves.
|
|
1220
|
+
|
|
1221
|
+
```js
|
|
1222
|
+
const list = DataTable.mount(host, {
|
|
1223
|
+
headers: ['Id', 'Summary', 'Status', 'Owner'],
|
|
1224
|
+
rows,
|
|
1225
|
+
reorderable: true,
|
|
1226
|
+
columnOrder: [0, 3, 1, 2], // optional starting order
|
|
1227
|
+
stateStore, persistKey: 'bugs:queue',
|
|
1228
|
+
onColumnsChange: (hidden, { order }) => console.log('order now', order),
|
|
1229
|
+
});
|
|
1230
|
+
list.moveColumn(3, 1); // positions in getColumnOrder()
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
| Option | What it does |
|
|
1234
|
+
|---|---|
|
|
1235
|
+
| `reorderable: true` | Every data column's header can be dragged onto another; dropped, it takes that column's place (to its left when moved left, to its right when moved right). A press that starts on a column's resize grip resizes it and never starts a drag. The row-number and select columns stay first: they cannot be dragged, and nothing can be dropped on or in front of them. The header's right-click menu offers Move left and Move right, after any `headerMenu` items and before the `columnChooser` items; they pass over hidden columns. |
|
|
1236
|
+
| `columnOrder: [colIdx, ...]` | The order to draw the columns in: every data index once, hidden ones included. A list that is not a permutation of the columns is ignored with a console warning, and the natural order is drawn; a table mounted with no headers yet checks it, and warns once, when `setData` brings the first ones. Works without `reorderable`, in which case only code moves columns. `setData({ columnOrder })` applies a new order; `null` goes back to the natural one. |
|
|
1237
|
+
| `moveColumn(from, to)` | Moves the column at position `from` of `getColumnOrder()` to position `to`, the others closing up: from the natural order, `moveColumn(3, 1)` draws 0, 3, 1, 2. Returns whether the order changed. Dragged widths stay with their columns. |
|
|
1238
|
+
| `getColumnOrder()` | Every data index, hidden ones included, in the order drawn (a copy). |
|
|
1239
|
+
| With `persistKey` | The order is saved with the sort, the filters and the widths, and restored on mount. A saved order wins over `columnOrder`. A saved order for a different number of columns than the table has today is ignored: `columnOrder` is drawn, or the natural order when there is none. That holds whether the headers are known when the blob is read or come later with `setData`; likewise, a dragged order that new headers no longer fit gives way to `columnOrder`, or the natural order. A blob saved before 0.17 (no order) loads as it did. |
|
|
1240
|
+
| Copies and the CSV | Ctrl+C, the copy menu and `downloadCSV()` write the columns in the order shown. |
|
|
1241
|
+
|
|
1242
|
+
The drag is HTML5 drag-and-drop on the header cells. The cell being dragged is
|
|
1243
|
+
`twm-dt-col--dragging`, the one under the pointer `twm-dt-col--drop` (plus
|
|
1244
|
+
`twm-dt-col--drop-after` when the column would land to its right). The logic
|
|
1245
|
+
lives in `column_order.js`, which is internal to the table.
|
|
1246
|
+
|
|
912
1247
|
### DataTable: the CSV button on every table (0.14.1)
|
|
913
1248
|
|
|
914
1249
|
`showExportButton: true` draws "Download visible rows as CSV" in the pagination
|
|
@@ -956,6 +1291,235 @@ The select column is not a data column: it is not in a copy, a CSV,
|
|
|
956
1291
|
table's whole life, so remembered widths stay on their columns when the mode
|
|
957
1292
|
flips, and widths saved by a table without it land on the same columns.
|
|
958
1293
|
|
|
1294
|
+
### DataTable: a grid of cells you can edit (0.17.0)
|
|
1295
|
+
|
|
1296
|
+
`selectionMode: 'cells'` turns a table into a grid: a click selects a cell, the
|
|
1297
|
+
keyboard moves a cursor over cells, and with `editable` a cell opens an editor
|
|
1298
|
+
in place. A table that sets none of these options selects rows and behaves as
|
|
1299
|
+
in 0.16.
|
|
1300
|
+
|
|
1301
|
+
```js
|
|
1302
|
+
const grid = DataTable.mount(host, {
|
|
1303
|
+
headers: ['Item', 'Qty', 'Note'],
|
|
1304
|
+
rows,
|
|
1305
|
+
pagination: false,
|
|
1306
|
+
virtualize: true,
|
|
1307
|
+
selectionMode: 'cells',
|
|
1308
|
+
editable: [1, 2], // or true, or (colIdx, row, rowIdx) => boolean
|
|
1309
|
+
editors: { num: myNumberEditor }, // by column index or by type
|
|
1310
|
+
onCellCommit: async ({ rowIdx, colIdx, value, previous }) => {
|
|
1311
|
+
const r = await api.save(rowIdx, colIdx, value);
|
|
1312
|
+
return r.ok ? { ok: true } : { ok: false, error: r.message };
|
|
1313
|
+
},
|
|
1314
|
+
headerMenu: (colIdx, ctx) => [{ label: `Sort by ${ctx.header}`, action: 'sort', icon: 'sort' }],
|
|
1315
|
+
onHeaderMenuAction: (action, colIdx) => { if (action === 'sort') grid.sortBy(colIdx); },
|
|
1316
|
+
columnChooser: true,
|
|
1317
|
+
});
|
|
1318
|
+
grid.getCellSelection(); // { anchor, focus, range }
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
| Option | What it does |
|
|
1322
|
+
|---|---|
|
|
1323
|
+
| `selectionMode: 'cells'` | Cell mode. A click selects one cell and puts the cursor on it; Shift+click selects the range from the anchor to it. Every cell of the range is drawn `twm-dt-cell--selected`, the cursor `twm-dt-cell--cursor`; rows are not lit. Combined with `selectToggle`, `selectMode` or `keyboardNav` those are ignored, with a console warning. |
|
|
1324
|
+
| The keys (focused table) | Arrows move the cursor (Shift extends the range). Ctrl+arrow jumps to the edge of the block of filled cells, as a spreadsheet does. Home/End go to the row's first and last cell (Ctrl: the page's first and last cell). Tab moves right and wraps to the next row (Shift+Tab back); on the last cell (Shift+Tab: the first) the table does not take the key, so focus moves on out of the table. ArrowDown on the last row stays put. Ctrl+A selects every cell of the page; Escape collapses the range onto the cursor. The cursor stays on the page drawn and is scrolled into view; under `virtualize` its row is drawn. |
|
|
1325
|
+
| `getCellSelection()` | `{anchor, focus, range}`. A cell is `{row, col, rowIdx}`: `row` is the position in the order shown (as `getDisplayedRows()` lists them, after sort and filter), `col` the data column, `rowIdx` the original row index. `range` is `{top, bottom, left, right, rows, columns}`: positions, the data columns at its edges, and the original row indices and data columns it covers (a hidden column inside it is not one of them). Null outside cell mode. |
|
|
1326
|
+
| `setCellSelection(focus, anchor?)` | Puts the cursor on `{row, col}`, or selects from `anchor` to it; `null` clears. The row is brought into view. |
|
|
1327
|
+
| `getSelection()`, `setSelection()`, Ctrl+C | Still about rows: `getSelection()` returns the rows the range touches, `onSelectionChange` hears them, and Ctrl+C and the copy menu copy them. `setSelection(indices)` selects the cells of those rows, first to last as shown. |
|
|
1328
|
+
| `onCellSelectionChange(sel)` | Called with `getCellSelection()` after every change. |
|
|
1329
|
+
| `editable` | Which cells open an editor: `true`, an array of data column indices, or `(colIdx, row, rowIdx) => boolean`. Needs cell mode. F2, Enter, a double-click or a printable key opens it on the cursor cell (a printable key replaces the value with that character). In the editor Enter commits and moves down, Tab commits and moves right (Shift: up, left), Escape restores the cell and calls nothing; the focus leaving it commits without moving. `editCell(row, col)` opens one from code; `isEditing()` says whether one is open. |
|
|
1330
|
+
| `editors: {[colIdx \| type]: factory}` | The editor for a column, looked up by column index and then by type (`columnTypes`, else the detected `num`/`text`). A factory gets `{value, row, rowIdx, colIdx, column: {index, header, type}, initialText, commit(value), cancel()}` and returns an element or `{el, getValue?, focus?, destroy?}`. An input, select or textarea commits its `value` on Enter and Tab; any other editor calls `commit(value)` itself. The default is a text box (`createTextEditor`). |
|
|
1331
|
+
| `onCellCommit({rowIdx, colIdx, value, previous})` | Called exactly once per commit; `previous` is the value the row holds, and a value left as the cell showed it is no commit. Return nothing or `{ok: true}` (or a Promise of it) and the value is written into the row the table holds for `rowIdx` when it settles (`rows[rowIdx][colIdx]`, so a row put in by `updateRow` meanwhile keeps its other cells) and drawn. `{ok: false, error}`, a rejection or a throw leaves the typed value in the cell, drawn `twm-dt-cell--error` with the message as its `title` and `data-error`, until a later commit of that cell succeeds, the value the row holds is committed back (no call: nothing to save), or new rows arrive. While the Promise is out the cell is `twm-dt-cell--pending`. The editor always opens with the value the cell shows, a refused or pending one included. |
|
|
1332
|
+
| While an editor is open | `render()`, and so `setData`, a sort or a page, waits and runs when the editor closes, so what is being typed is never thrown away. A commit that settles while another editor is open in its row marks its cell at once and draws the row when that editor closes. `updateRow` on the row the editor is in stores the data at once (`config.rows`, the window's row list) and returns `true`, and the row is drawn with it when the editor closes; the editor's cell is left as it is. A row that scrolls out of the virtual window commits its editor first. An editor with no `getValue` (a `{el}` around an element that is not an input, select or textarea) has no value the table can read: Enter, Tab and the focus leaving do nothing, and its row scrolling out cancels it, so what it holds is lost unless it has called `commit(value)` itself. |
|
|
1333
|
+
| `headerMenu(colIdx, ctx) => items` | Items for the header's right-click menu, asked for the data column right-clicked (`ctx` is `{colIdx, header, hiddenColumns, table}`). Items are `{label, action, icon?, disabled?, danger?, title?}` or `{separator: true}`. Choosing one calls `onHeaderMenuAction(action, colIdx)`. With `columnChooser` the chooser's items follow behind a separator. `[]` or `null` shows only what would show without the hook: the chooser, or the page's own menu. Works in either selection mode. |
|
|
1334
|
+
|
|
1335
|
+
The two modules behind cell mode are exported for a consumer that builds its
|
|
1336
|
+
own grid: `CellSelection` (the anchor, the cursor and every key's move, with no
|
|
1337
|
+
DOM), `CellEditorHost` (one cell's editor, opened and closed exactly once) and
|
|
1338
|
+
`createTextEditor` (the default text box). The table's own half of cell mode
|
|
1339
|
+
is internal.
|
|
1340
|
+
|
|
1341
|
+
#### Staged edits, undo and redo, one commit (0.17.0)
|
|
1342
|
+
|
|
1343
|
+
With `staged: true` beside `editable`, a commit from an editor is STAGED
|
|
1344
|
+
rather than sent to `onCellCommit`: it stays in the table, drawn
|
|
1345
|
+
`twm-dt-cell--staged`, until `commit()` hands every staged cell to the
|
|
1346
|
+
consumer in one call. Ctrl+Z and Ctrl+Y walk the staged edits.
|
|
1347
|
+
|
|
1348
|
+
```js
|
|
1349
|
+
const grid = DataTable.mount(host, {
|
|
1350
|
+
headers, rows,
|
|
1351
|
+
selectionMode: 'cells',
|
|
1352
|
+
editable: true,
|
|
1353
|
+
staged: true,
|
|
1354
|
+
historyLimit: 100, // the default
|
|
1355
|
+
onCommit: async ({ edits }) => { // [{rowIdx, colIdx, value, previous}, …]
|
|
1356
|
+
const r = await api.saveBatch(edits);
|
|
1357
|
+
return { errors: r.failed }; // [{rowIdx, colIdx, error}] — empty or absent: success
|
|
1358
|
+
},
|
|
1359
|
+
onStagedChange: ({ count, canUndo, canRedo, committing }) => paintSaveButton(count),
|
|
1360
|
+
});
|
|
1361
|
+
saveButton.onclick = () => grid.commit();
|
|
1362
|
+
discardButton.onclick = () => grid.discard();
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
| Option or method | What it does |
|
|
1366
|
+
|---|---|
|
|
1367
|
+
| `staged: true` | Needs `editable` (ignored with a warning without it). A commit stages the value: the cell shows it, drawn `twm-dt-cell--staged`, and nothing is called. Staging the value the row holds is no change. The editor opens with the staged value. |
|
|
1368
|
+
| Ctrl+Z, Ctrl+Y (focused table) | Ctrl+Z takes the last staged edit back; Ctrl+Y or Ctrl+Shift+Z puts it back. A cell staged as `null` undoes to NO CHANGE, not to a staged null. In an open editor the keys are the editor's. `undo()` and `redo()` do the same from code. |
|
|
1369
|
+
| `historyLimit` | The ring keeps the last this-many entries (default 100); the limit counts entries, not cells. The oldest fall off, and their cells stay staged. An entry is one cell's before/after change or one fill (a group of cell changes, undone together), never a copy of the staged set. |
|
|
1370
|
+
| `commit()` | Calls `onCommit({edits})` ONCE with every staged cell, `{rowIdx, colIdx, value, previous}` by row then column (`previous` is what the row holds); an open editor's value is staged first. Resolves `{ok: true, committed}` on success: the values are written into the rows the table holds, and the staged cells and the ring are cleared (a Ctrl+Z after a commit does nothing). If `onCommit` resolves `{errors: [{rowIdx, colIdx, error}]}` (non-empty), each of those cells is drawn `twm-dt-cell--error` with the message as its `title` and `data-error`, every staged value stays, and `commit()` resolves `{ok: false, errors}`; `{ok: false, error}`, a rejection or a throw marks every staged cell with the message. Staging a marked cell again clears its mark. While the commit is out no editor opens and undo, redo and `discard()` do nothing. |
|
|
1371
|
+
| `discard()` | Drops every staged cell, its marks and the ring. |
|
|
1372
|
+
| `getStagedEdits()` | The staged cells, as `onCommit` would get them. |
|
|
1373
|
+
| `onStagedChange({count, canUndo, canRedo, committing})` | After every change of the staged cells. |
|
|
1374
|
+
| The freeze | While any cell is staged, sorting, filtering and paging are held: the sortable headers, the filter row's controls, the pager's buttons and the `paging` strip are disabled with the reason on them (`data-refusal`, `title`), and `sortBy`, `setFilter`, `clearFilters`, `goToPage`, `goToRow` and the paging calls refuse with a console warning. The export button is not held. `stagedRefusal` (a string, or `(count) => string`) gives the reason in the app's words. |
|
|
1375
|
+
| New rows | `setData({rows})` drops the staged cells and the ring: the row indices they named are other rows now. |
|
|
1376
|
+
|
|
1377
|
+
`StagedHistory` (the ring, with no DOM: `record`, `beginGroup`/`endGroup`,
|
|
1378
|
+
`undo`, `redo`, `reset`, writing through an injected `apply(entry, direction)`)
|
|
1379
|
+
is exported with `ACTIONS` and `DEFAULT_HISTORY_LIMIT` for a consumer that
|
|
1380
|
+
builds its own grid.
|
|
1381
|
+
|
|
1382
|
+
#### Fill handle (0.17.0)
|
|
1383
|
+
|
|
1384
|
+
With `fillHandle: true` beside `staged`, the bottom-right cell of the range
|
|
1385
|
+
carries a small square (`.twm-dt-fill-handle`). Dragging it up, down, left or
|
|
1386
|
+
right over the cells beyond the range fills them as a spreadsheet does, and
|
|
1387
|
+
stages the values: nothing is sent until `commit()`.
|
|
1388
|
+
|
|
1389
|
+
```js
|
|
1390
|
+
const grid = DataTable.mount(host, {
|
|
1391
|
+
headers, rows,
|
|
1392
|
+
selectionMode: 'cells',
|
|
1393
|
+
editable: (colIdx, row) => !row.locked, // a refused cell is skipped by a fill
|
|
1394
|
+
staged: true,
|
|
1395
|
+
fillHandle: true,
|
|
1396
|
+
onCommit: ({ edits }) => api.saveBatch(edits),
|
|
1397
|
+
});
|
|
1398
|
+
grid.fill({ row: 20, col: 3 }); // what dragging the handle to (20, 3) does
|
|
1399
|
+
```
|
|
1400
|
+
|
|
1401
|
+
| Option or method | What it does |
|
|
1402
|
+
|---|---|
|
|
1403
|
+
| `fillHandle: true` | Needs `staged` (ignored with a warning without it). The handle sits in the range's corner cell (`twm-dt-cell--fill-corner`); it is not drawn while an editor is open or a commit is out. During a drag the cells it covers are drawn `twm-dt-cell--fill-preview`; the direction is the one the pointer is furthest out in (up and down win a tie), up to the page's edge. Escape, or a cancelled pointer, ends the drag without filling. A drag is never a click on the table: the click the browser makes of the press and release (on the corner cell, the row or the body) is swallowed, filled or not, so neither the range nor `onRowClick` / `onRowActivate` sees it. On release the cells are staged as ONE undo entry — one Ctrl+Z takes the whole fill back — and the range grows over them. |
|
|
1404
|
+
| What is written | Each column of the range (each row, filling left or right) is a seed, continued by `projectSeries`: one cell repeats its value; numbers a constant step apart continue (`1, 2` filled three further gives `3, 4, 5`); `YYYY-MM-DD` dates or `Date`s a constant number of days apart continue that many days apart; texts like `Item 1, Item 2` continue their number (`INV-001` keeps its zeros); any other block repeats in a cycle. Filling up or left runs the series away from the block (`3, 4` filled up gives `2, 1`). Holding Ctrl (Cmd) at release repeats the block instead of continuing it. A hidden column is stepped over. |
|
|
1405
|
+
| A cell `editable` refuses | Is skipped: nothing is staged in it and the row is untouched. It is drawn `twm-dt-cell--fill-skipped` until the selection moves, the next fill or new rows. |
|
|
1406
|
+
| `fill(to, {forceCopy})` | The fill a drag to cell `to` (`{row, col}`: position shown, data column) makes, from code; `forceCopy` is Ctrl. Returns `{direction, count, staged, skipped}` (`staged` the NUMBER of cells that changed, `skipped` the refused cells as `{row, col, rowIdx}`), or null when nothing was filled. |
|
|
1407
|
+
|
|
1408
|
+
`detectSeries(values)` (what a seed is: `{kind, step}` with kind `copy`,
|
|
1409
|
+
`number`, `date`, `text-number` or `cycle`, or null for blanks) and
|
|
1410
|
+
`projectSeries(values, count, {forceCopy})` (the next `count` values; a
|
|
1411
|
+
series keeps its seed's type) are exported as pure functions for a consumer
|
|
1412
|
+
that builds its own grid. `values` is the seed in fill order.
|
|
1413
|
+
|
|
1414
|
+
### DataTable: column groups and structural edits (0.17.0)
|
|
1415
|
+
|
|
1416
|
+
`columnGroups` draws a second header row over the column headers, and a few
|
|
1417
|
+
callbacks let a person add, rename and delete columns and rows from the
|
|
1418
|
+
headers and the row menu. The consumer owns the data: every edit is a call,
|
|
1419
|
+
and the consumer answers it with `setData`. A table that sets none of these
|
|
1420
|
+
options draws exactly as in 0.16.
|
|
1421
|
+
|
|
1422
|
+
```js
|
|
1423
|
+
const table = DataTable.mount(host, {
|
|
1424
|
+
headers: ['Cash', 'Loans', 'Deposits'],
|
|
1425
|
+
rows,
|
|
1426
|
+
columnGroups: [
|
|
1427
|
+
{ key: 'assets', label: 'Assets', columns: [0, 1] },
|
|
1428
|
+
{ key: 'liabilities', label: 'Liabilities', columns: [2] },
|
|
1429
|
+
{ key: 'equity', label: 'Equity', columns: [] }, // drawn as a placeholder column
|
|
1430
|
+
],
|
|
1431
|
+
onColumnAdd: (groupKey) => model.addColumn(groupKey),
|
|
1432
|
+
onColumnRename: (colIdx, name) => model.renameColumn(colIdx, name),
|
|
1433
|
+
onColumnDelete: (colIdx) => model.deleteColumn(colIdx),
|
|
1434
|
+
onRowAdd: (afterRowIdx) => model.addRow(afterRowIdx), // null: from the header
|
|
1435
|
+
onRowDelete: (rowIdx) => model.deleteRow(rowIdx),
|
|
1436
|
+
});
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
| Option | What it does |
|
|
1440
|
+
|---|---|
|
|
1441
|
+
| `columnGroups: [{key, label?, columns}]` | A group row above the headers: one cell per run of shown columns of a group, spanning them (`th.twm-dt-group`), and a blank cell over the row number, the select column and any column in no group. `columns` are data column indices; a column is in the first group that lists it; a hidden column leaves its group's span; with `columnOrder` the runs follow the order drawn, so a group whose columns are not side by side gets one cell per run. The row is its own table in the header wrap (`table.twm-dt-groups`), which takes the header's column widths, table width and sideways scroll after every fit, auto-size and drag. `setData({columnGroups})` changes the groups. |
|
|
1442
|
+
| An empty group | Keeps one placeholder column so it stays visible and its add control reachable: `th.twm-dt-col--placeholder` in the header, an empty filter cell, and an empty `td.twm-dt-cell--placeholder` in every row. It stands after the columns of the groups listed before it, and is no data column: not in a copy, the CSV, a menu's `ctx.colIdx` or cell mode. |
|
|
1443
|
+
| `onColumnAdd(groupKey)` | An add button (`.twm-dt-group__add`) in every group's header cell and in a placeholder's header. |
|
|
1444
|
+
| `groupActions(group, {key, columns, table}) => items`, `onGroupAction(action, groupKey)` | The consumer's buttons in a group's header cell. Items are `{action, icon?, label?, title?, disabled?}`. |
|
|
1445
|
+
| `columnActions(colIdx, {colIdx, header, table}) => items`, `onColumnAction(action, colIdx)` | The consumer's buttons in a column's header (`.twm-dt-col-action`), with the same items. A click on one does not sort. |
|
|
1446
|
+
| `onColumnRename(colIdx, name)` | Renames a column in place with `attachInlineRenamer`. "Rename column" in the header's right-click menu, `renameColumn(colIdx)`, or a double-click on the header's label (on a table that is not `sortable`) opens it. Enter, or the focus leaving, with a different non-empty name calls it once. Escape, or the name left as it was, restores the header and calls nothing. The new name shows until the next render; returning `false` or `{ok: false}` (or a Promise of it) restores the old one. |
|
|
1447
|
+
| `onColumnDelete(colIdx)` | "Delete column" in the header's right-click menu. |
|
|
1448
|
+
| `onRowAdd(afterRowIdx)` | "Insert row below" in a row's right-click menu (the row's original index), and "Add row" in the header's (`null`), so a table with no rows can get its first. |
|
|
1449
|
+
| `onRowDelete(rowIdx)` | "Delete row" in a row's right-click menu. |
|
|
1450
|
+
|
|
1451
|
+
The structural items follow the consumer's own: in the header menu after the
|
|
1452
|
+
`headerMenu` items and before the column chooser, in a row's menu after the
|
|
1453
|
+
`contextMenuItems` and before the copy items, each behind a separator.
|
|
1454
|
+
|
|
1455
|
+
### DataTable: a crosstab, with spanned header rows and pinned label columns (0.17.0)
|
|
1456
|
+
|
|
1457
|
+
`crosstab` draws the table as a matrix: rows of spanned headers over the
|
|
1458
|
+
column headers, the leading columns as row labels, and all of it in ONE table
|
|
1459
|
+
in ONE scroller. The header rows stick to the top, the label columns (with the
|
|
1460
|
+
select and row-number columns in front of them) stick to the left, and the
|
|
1461
|
+
corner, the header cells over the label columns, sticks to both. Each header
|
|
1462
|
+
row's `top` is the measured height of the rows above it, and each pinned
|
|
1463
|
+
column's `left` the measured width of the pinned columns before it. Both are
|
|
1464
|
+
read after layout and again whenever a header row or a pinned header cell
|
|
1465
|
+
changes size (a label that wraps, a font, a zoom). The consumer supplies every
|
|
1466
|
+
value and every label: rows and columns that add others up are ordinary rows
|
|
1467
|
+
and columns, marked with `rowClass`, `cellClass` and a header cell's
|
|
1468
|
+
`className`. A table without `crosstab` draws exactly as in 0.16.
|
|
1469
|
+
|
|
1470
|
+
```js
|
|
1471
|
+
const table = DataTable.mount(host, {
|
|
1472
|
+
headers: ['Region', 'City', 'Q1', 'Q2', 'Q1', 'Q2', 'All'],
|
|
1473
|
+
rows, // ['North', 'Oslo', 1, 2, 3, 4, 10], …
|
|
1474
|
+
pagination: false,
|
|
1475
|
+
virtualize: true, // a large matrix: draw the rows in view
|
|
1476
|
+
rowClass: (row) => (row[0] === 'All' ? 'my-sum-row' : null),
|
|
1477
|
+
crosstab: {
|
|
1478
|
+
labelColumns: 2, // 'Region' and 'City' are row labels
|
|
1479
|
+
headerRows: [[ // one row of spans per level, top first
|
|
1480
|
+
{ label: '2024', columns: [2, 3] },
|
|
1481
|
+
{ label: '2025', columns: [4, 5] },
|
|
1482
|
+
{ label: 'All', columns: [6], className: 'my-sum-head' },
|
|
1483
|
+
]],
|
|
1484
|
+
},
|
|
1485
|
+
});
|
|
1486
|
+
```
|
|
1487
|
+
|
|
1488
|
+
| `crosstab` key | What it does |
|
|
1489
|
+
|---|---|
|
|
1490
|
+
| `labelColumns` | How many of the leading drawn data columns are row labels (default 0). They are marked `twm-dt-pin` in every row, the header and the filter row, and pinned at the measured widths. One column with the depth drawn by the consumer (`cellClass`, `renderCell`), or one per level: both are just a count. |
|
|
1491
|
+
| `headerRows: [[{label, columns, className?, title?}]]` | The rows of spans above the column headers, top first. A cell spans the run of drawn body columns it lists (`columns` are data column indices, as in `columnGroups`; a column is in the first cell of its row that lists it); a run of columns in no cell of a row is a blank cell. Every row's spans add up to the body's columns. Above the label columns stands the corner: one `th.twm-dt-crosstab__corner` per pinned column, spanning every header row. |
|
|
1492
|
+
| `emptyLabel` | With no body column at all (an empty axis), one column headed with this text (default `'(none)'`) and an empty cell in every row, and no header rows. It is no data column. |
|
|
1493
|
+
|
|
1494
|
+
The table carries `twm-dt-crosstab`, the header rows `tr.twm-dt-crosstab__band`
|
|
1495
|
+
and the column headers `tr.twm-dt-crosstab__leaf`; the spanned header cells are
|
|
1496
|
+
`columnGroups`' group cells (`th.twm-dt-group`) and are styled as those. There
|
|
1497
|
+
is no separate header table, so the split layout's column fit, resize grips,
|
|
1498
|
+
header right-click menu (`columnChooser`, `headerMenu`, and the header items of
|
|
1499
|
+
`onColumnRename`, `onColumnDelete` and `onRowAdd`), `columnActions`,
|
|
1500
|
+
`reorderable` and `columnGroups` row do not apply (`columnGroups` is ignored
|
|
1501
|
+
with a warning).
|
|
1502
|
+
|
|
1503
|
+
A row's background is the custom property `--twm-dt-row-bg` on its `<tr>`
|
|
1504
|
+
(the zebra stripe sets it on even rows, at no specificity). Every crosstab row
|
|
1505
|
+
paints it, and the pinned label cells lay it over their opaque surface, so a
|
|
1506
|
+
coloured row keeps its colour across the label cells too. To colour a row, set
|
|
1507
|
+
the property from its `rowClass`, not `background` on the `tr` (the crosstab's
|
|
1508
|
+
row rule paints the property instead):
|
|
1509
|
+
|
|
1510
|
+
```css
|
|
1511
|
+
.my-sum-row { --twm-dt-row-bg: rgba(255, 255, 255, 0.06); }
|
|
1512
|
+
```
|
|
1513
|
+
The pinned cells' background is one opaque stack the crosstab owns (it is
|
|
1514
|
+
`!important`, so neither a state rule nor an animation can make it see-through):
|
|
1515
|
+
every tint FlexDesk paints on a cell — hover, the clickable hover, row and cell
|
|
1516
|
+
selection, `staged`, the fill's skipped mark, placeholders, `diff` and the
|
|
1517
|
+
new-row flash — is drawn as a layer over the row's `--twm-dt-row-bg` over the
|
|
1518
|
+
surface (`--surface-0`), the same tint the unpinned cells of that row show.
|
|
1519
|
+
Under `virtualize` the columns are held at the widths first measured, so they
|
|
1520
|
+
do not shift as rows scroll in and out. With 1,000 rows and 50 body columns
|
|
1521
|
+
under `virtualize`, the first draw stays well under 300 ms in jsdom (tests/crosstab.test.mjs §6).
|
|
1522
|
+
|
|
959
1523
|
### Scrollbars
|
|
960
1524
|
|
|
961
1525
|
`installAutoScrollbars(root)` (from `@flexdesk/widgets`) replaces the browser's
|
|
@@ -966,6 +1530,99 @@ layer, so a container that redraws its contents cannot lose its bar, and
|
|
|
966
1530
|
textareas get one too. Containers that already use `installOverlayScrollbar`
|
|
967
1531
|
keep their own.
|
|
968
1532
|
|
|
1533
|
+
`installCollapsibleScrollbars(root = document.body)` gives every
|
|
1534
|
+
`.twm-collapsible-content.visible` section under `root` a vertical overlay
|
|
1535
|
+
bar: the ones already visible at once, and the ones mounted or expanded
|
|
1536
|
+
later. It watches `root` only and returns a disconnect. (It used to take no
|
|
1537
|
+
argument and look for a `#twm-fixed-200` that nothing created, so visible
|
|
1538
|
+
sections got no bar until a class changed — BUG-0006.)
|
|
1539
|
+
|
|
1540
|
+
### SliderField: a number box, a readout, a default and an enable toggle (0.17.0)
|
|
1541
|
+
|
|
1542
|
+
`new SliderField({ fieldId, field, onChange })` (from `@flexdesk/widgets`)
|
|
1543
|
+
draws a range input. Its companions are opt-in through `field`; with none of
|
|
1544
|
+
them the DOM is the range and the helper line, as it always was.
|
|
1545
|
+
|
|
1546
|
+
```js
|
|
1547
|
+
import { SliderField } from '@flexdesk/widgets';
|
|
1548
|
+
|
|
1549
|
+
const slider = new SliderField({
|
|
1550
|
+
fieldId: 'rate',
|
|
1551
|
+
field: {
|
|
1552
|
+
min: 0, max: 1, step: 0.01, value: 0.25,
|
|
1553
|
+
numberInput: true, // an editable box, kept in sync both ways
|
|
1554
|
+
readout: (v) => `${Math.round(v * 100)}%`, // or true for the plain value
|
|
1555
|
+
defaultValue: 0.25, // --modified state and a reset button
|
|
1556
|
+
toggleable: true, disabledValue: null, // an enable checkbox before the slider
|
|
1557
|
+
},
|
|
1558
|
+
onChange: (value, { immediate }) => save(value),
|
|
1559
|
+
});
|
|
1560
|
+
host.appendChild(slider.render());
|
|
1561
|
+
```
|
|
1562
|
+
|
|
1563
|
+
| Field / method | What it does |
|
|
1564
|
+
| --- | --- |
|
|
1565
|
+
| `numberInput` | A text box beside the slider. Dragging updates it on every frame; Enter (or leaving the box) commits what was typed, clamped to `[min, max]` and snapped to `step`. Text that is not a finite number (`abc`, empty, `Infinity`) changes nothing and the box shows the value again; Escape does the same |
|
|
1566
|
+
| `readout` | A span after the slider: `true` shows the value, a function `(value: number) => string` formats it. It follows the drag live |
|
|
1567
|
+
| `defaultValue` | While the value differs from it the root carries `twm-slider-field--modified` and the reset button shows; reset restores it and reports it through `onChange`, like a release |
|
|
1568
|
+
| `toggleable`, `enabled`, `disabledValue` | A checkbox before the slider (`enabled: false` starts it off). While off, the inputs are disabled, the root carries `twm-slider-field--off`, and `onChange`/`getValue()` report `disabledValue` (default `null`) |
|
|
1569
|
+
| `onChange(value, { immediate })` | Called once per commit: a drag release, a typed Enter, a reset, a toggle. The value is the slider's value as a string, as before. With `emitOnInput` it is also called on every drag frame with `immediate: true` |
|
|
1570
|
+
| `setValue(x, { silent })`, `setRange({ min, max, step }, { silent })`, `setEnabled(on, { silent })`, `reset({ silent })` | Change the field from code. Each reports at most once, nothing with `silent: true`, and nothing when the reported value did not move, so a caller that writes back from its own `onChange` does not loop. `setValue` refuses a non-finite value (returns `false`); `setRange` refits the value and the default |
|
|
1571
|
+
| `getValue()`, `isEnabled()`, `isModified()` | What `onChange` would report now, the toggle state, and whether the value differs from `defaultValue` |
|
|
1572
|
+
|
|
1573
|
+
### A stack of collapsible sections: `createSectionStack` (0.17.0)
|
|
1574
|
+
|
|
1575
|
+
Inspector-style panel content: titled sections in a column, each opened and
|
|
1576
|
+
closed from its header. It is built from the tree's `.twm-collapsible-*`
|
|
1577
|
+
pieces plus `.twm-section-stack*` (`@flexdesk/widgets`).
|
|
1578
|
+
|
|
1579
|
+
```js
|
|
1580
|
+
const stack = createSectionStack(panel, {
|
|
1581
|
+
sections: [
|
|
1582
|
+
{ id: 'notes', title: 'Notes', icon: 'edit_note', content: notesEl },
|
|
1583
|
+
{ id: 'chat', title: 'Assistant', fill: true,
|
|
1584
|
+
actions: [{ icon: 'delete', label: 'Clear', onClick: (id, event) => clear() }] },
|
|
1585
|
+
{ id: 'params', title: 'Parameters', defaultExpanded: false },
|
|
1586
|
+
],
|
|
1587
|
+
key: 'inspector.sections',
|
|
1588
|
+
stateStore: { read: (key) => saved[key], write: (key, map) => { saved[key] = map; } },
|
|
1589
|
+
});
|
|
1590
|
+
stack.body('params').appendChild(paramsEl);
|
|
1591
|
+
const off = stack.on('toggled', ({ id, expanded, source }) => { /* … */ });
|
|
1592
|
+
```
|
|
1593
|
+
|
|
1594
|
+
- **The header toggles, its actions do not.** A click anywhere on a header
|
|
1595
|
+
opens or closes the section and turns its arrow. A click on one of its
|
|
1596
|
+
`actions` runs that action's `onClick(sectionId, event)` and nothing else.
|
|
1597
|
+
The toggle is a `<button>`, so Enter and Space work.
|
|
1598
|
+
- **`defaultExpanded`** is `true` unless a section says `false`.
|
|
1599
|
+
- **One `fill` section** takes the height the others leave, and its body is
|
|
1600
|
+
what scrolls (`min-height: 0`). A long conversation scrolls inside its
|
|
1601
|
+
section and the panel stays the size of the window. The stack is
|
|
1602
|
+
`height: 100%` of its container, so give the container a height. A closed
|
|
1603
|
+
fill section is only its header.
|
|
1604
|
+
- **Remembering is injected.** `stateStore` is `{ read(key), write(key, map) }`.
|
|
1605
|
+
`map` is `{ [sectionId]: expanded }`, and it is written on every open and
|
|
1606
|
+
close under `key`. `read` may return a promise. Until it settles the
|
|
1607
|
+
sections show their defaults; a section somebody toggled in the meantime
|
|
1608
|
+
keeps that choice, and that toggle is written once the read has settled,
|
|
1609
|
+
over the stored states of the sections nobody touched. `stack.ready` resolves once the read has been applied. A
|
|
1610
|
+
store that throws, rejects or returns anything other than a map leaves every
|
|
1611
|
+
section on its `defaultExpanded`. Without a store nothing is remembered.
|
|
1612
|
+
- **`setVisible(id, false)`** takes a section off the stack and keeps its
|
|
1613
|
+
state, so `setVisible(id, true)` brings it back open or closed as it was.
|
|
1614
|
+
`isVisible(id)` reads it.
|
|
1615
|
+
- `expand(id)`, `collapse(id)`, `toggle(id)` and `isExpanded(id)` work on
|
|
1616
|
+
any section, hidden or not. An unknown id throws.
|
|
1617
|
+
- **`toggled`** fires on each change with `{ id, expanded, source }`, where
|
|
1618
|
+
`source` is `'user'`, `'api'` or `'restore'` (a stored state that arrived
|
|
1619
|
+
after mount). `on('toggled', fn)` returns the function that stops listening.
|
|
1620
|
+
- **`dispose()`** removes the stack's listener and every node it made, along
|
|
1621
|
+
with any `content` you passed in.
|
|
1622
|
+
|
|
1623
|
+
`tests/section_stack.test.mjs` covers each of these in jsdom. The fill
|
|
1624
|
+
layout was also measured in headless Edge.
|
|
1625
|
+
|
|
969
1626
|
## Flow editors
|
|
970
1627
|
|
|
971
1628
|
*New in 0.6.0.* Both entries first ship in FlexDesk 0.6.0.
|
|
@@ -1000,7 +1657,7 @@ touches browser storage — and is shown to fail on a planted one.
|
|
|
1000
1657
|
|
|
1001
1658
|
| Part | What it does |
|
|
1002
1659
|
|---|---|
|
|
1003
|
-
| `createStepCatalogue(types, {categories, idBase})` | The step types the consumer passes in, read as they arrive (`type_id`, `label`, `category`, `role`, `description`, `icon`, `ports`, `config_schema`, `unavailable`; any other key is kept). `get`, `list`, `byCategory`, `role`, `tone`, `inputs(typeId, {flow})`, `outputs`, `port`, `idBase`, and `problems` — a broken type is listed there, never thrown. **Ports keep their declared order**: a lane reads the first input as its own and an outline draws the other outputs as arms in order, so nothing sorts them |
|
|
1660
|
+
| `createStepCatalogue(types, {categories, idBase})` | The step types the consumer passes in, read as they arrive (`type_id`, `label`, `category`, `role`, `description`, `icon`, `ports`, `config_schema`, `unavailable`; any other key is kept). `get`, `list`, `byCategory`, `role`, `tone`, `inputs(typeId, {flow})`, `outputs`, `port`, `idBase`, and `problems` — a broken type is listed there, never thrown. A type may also carry `render`, `update`, `size: {w, h}` and `accent` (a tone), which only the canvas editor reads ([Node bodies](#node-bodies-0170)); `size(typeId)`, `accent(typeId)` and `body(typeId)` answer them, `null` when a type has none, and a `size`, `accent` or hook of the wrong kind is a line in `problems` and is not used (0.17.0). `inline: ['key', …]` names settings of its `config_schema` the canvas editor draws on the node ([Edited on the node](#edited-on-the-node-0170)); `inline(typeId)` answers the names the schema has, `[]` when none, and a name it lacks (or an `inline` that is not a list) is a line in `problems` and is not drawn (0.17.0). `rank: 'source' \| 'sink'` is the hint the canvas editor's `layout: 'layered'` Arrange gives the type's steps (the first or the last column); `rank(typeId)` answers it, `null` when a type has none, and any other value is a line in `problems` and is not used (0.17.0). **Ports keep their declared order**: a lane reads the first input as its own and an outline draws the other outputs as arms in order, so nothing sorts them |
|
|
1004
1661
|
| `checkSettingsSchema(schema)` | The JSON-Schema subset a step's settings may use: `type`, `enum`, `const`, `required`, `properties`, `additionalProperties` (a boolean), `items`, `minItems`/`maxItems`, `minimum`/`maximum`, `minLength`/`maxLength`, `format: "uuid"`, the annotations and any `x-` hint. Returns the first refusal as the server's own sentence, or `null`. `oneOf`, `anyOf`, `$ref` and `pattern` are refused. `tests/fixtures/flow_kit_settings_schema.json` holds 30 schemas and their verdicts. Tables' own `check_schema` gave the same 30 verdicts when the file was written, and is meant to read this file in a test of its own |
|
|
1005
1662
|
| `createWidgetRegistry()` | The controls, by name. The ten generic widgets are registered already; `register(name, widget)` throws for a name that exists, and replacing one takes `{replace: true}`. A widget is `(spec, value, ctx) => {el, destroy?, focus?, caret?, restoreCaret?}` and reports with `ctx.set(value)`; `undefined` removes the setting |
|
|
1006
1663
|
| `createSettingsPanel(options)` | One step's settings, or the flow's own, drawn from the schema (below) |
|
|
@@ -1012,7 +1669,7 @@ touches browser storage — and is shown to fail on a planted one.
|
|
|
1012
1669
|
| `groupFindings(list)`, `createFindingsStrip({onGoTo, nameOf})` | The consumer's findings (`{code, message, severity, node_id?, field?}`) by step, and the strip: *"N things to fix before this can be published."*, a line each, *Go to it* |
|
|
1013
1670
|
| `new FlowHistory({actions, restore, onState, limit, mergeMs})` | Undo and redo over whole snapshots. The editor passes its own list of actions and `commit` throws on any other, so an edit nobody named fails its first test. Loading and publishing are `baseline`s, never entries. Keyed commits within `mergeMs` merge (typing a name is one entry), never across an undo. `keep()` and `adopt(kept, state)` carry the stacks to a successor that loaded the same bytes |
|
|
1014
1671
|
| `bindFlowKeys(root, handlers)`, `ownsUndo(target)` | An editor's keys, bound on its root, never on `window` (below) |
|
|
1015
|
-
| `emptyGraph`, `normalise`, `serialise`, `nextId`, `addNode`, `removeNode`, `connect`, `canConnect`, `disconnect`, `inputNamesOf` | A logic flow's graph — `{nodes: [{id, type, label?, config, position}], connections: [{source, target, sourcePort, targetPort}]}`, the shape the canvas and the outline both save. `serialise` is byte-stable: a graph opened and saved with no edit is the same text |
|
|
1672
|
+
| `emptyGraph`, `normalise`, `serialise`, `nextId`, `addNode`, `removeNode`, `connect`, `addConnection`, `canConnect`, `disconnect`, `inputNamesOf` | A logic flow's graph — `{nodes: [{id, type, label?, config, position}], connections: [{source, target, sourcePort, targetPort}]}`, the shape the canvas and the outline both save. `serialise` is byte-stable: a graph opened and saved with no edit is the same text. `canConnect(graph, catalogue, edge, {strings, dataRead, dataPorts})` refuses only what an editor cannot draw; with `dataPorts: true` a data port connects to a data port, any number of lines out of one. `connect` adds the line when `canConnect` allows it; `addConnection(graph, edge)` adds it asking nothing, for a caller whose own rules already allowed it. It may also carry `groups: [{id, label?, tone?, nodes: [id, …]}]` and `notes: [{id, text, position, size?: {w?, h?}}]` (0.17.0), and only when it has some, so a graph without them is the same text as before. Groups are flat: `normalise` keeps a node in the first group that names it, leaves out ids that are not nodes, and drops a group with no member; `removeNode` takes the node out of its group, and the group with its last member |
|
|
1016
1673
|
| `createStrings(...layers)`, `FLOW_STRINGS` | Every word drawn, English and domain-free by default; an editor lays its own words over these, and the consumer lays its words over both |
|
|
1017
1674
|
|
|
1018
1675
|
**The settings panel.**
|
|
@@ -1069,7 +1726,7 @@ character width and paints a viewport of no lines, and nothing repaints it.
|
|
|
1069
1726
|
| `string-list` | Short strings |
|
|
1070
1727
|
| `enum-chips` | A multi-select of `items.enum`, as chips, in the enum's order |
|
|
1071
1728
|
| `choice-cards` | One of N as radio rows, each with its words |
|
|
1072
|
-
| `upstream-column`, `upstream-columns` | One or several columns of the step's input, by name, from the panel's `columns` |
|
|
1729
|
+
| `upstream-column`, `upstream-columns` | One or several columns of the step's input, by name, from the panel's `columns`. While `columns` answers `null` (the columns are not known yet) the name is typed instead — a text box, or a chip list that takes a name on Enter or a comma — under the hint *Columns not known yet*; once columns arrive the same setting is a select (or toggle chips) with the stored name chosen, and a name the columns lack is kept and marked *(not found)* (0.17.0) |
|
|
1073
1730
|
|
|
1074
1731
|
| Schema hint | Meaning |
|
|
1075
1732
|
|---|---|
|
|
@@ -1099,7 +1756,7 @@ configured renames nothing.
|
|
|
1099
1756
|
|
|
1100
1757
|
**Keys.** `bindFlowKeys(root, handlers)` binds Ctrl/⌘+Z (`undo`), Ctrl/⌘+Y and
|
|
1101
1758
|
Ctrl/⌘+Shift+Z (`redo`), Delete (`remove`), Ctrl/⌘+D, C, X and V (`duplicate`,
|
|
1102
|
-
`copy`, `cut`, `paste`), F2 (`rename`), Alt+↑, Alt+↓ and Alt+← (`moveUp`,
|
|
1759
|
+
`copy`, `cut`, `paste`), Ctrl/⌘+A (`selectAll`, 0.17.0), Ctrl/⌘+G and Ctrl/⌘+Shift+G (`group`, `ungroup`, 0.17.0), F2 (`rename`), Alt+↑, Alt+↓ and Alt+← (`moveUp`,
|
|
1103
1760
|
`moveDown`, `moveOut`), the arrows (`up`, `down`, `left`, `right`), Enter
|
|
1104
1761
|
(`open`) and Escape (`escape`), and returns an unbind. A key whose handler is
|
|
1105
1762
|
absent, or returns `false`, is not taken. An editor's own row or card marks
|
|
@@ -1147,10 +1804,11 @@ the settings of whatever is picked, under a toolbar. It stands on the
|
|
|
1147
1804
|
|
|
1148
1805
|
| Part | What it does |
|
|
1149
1806
|
|---|---|
|
|
1150
|
-
| `new CanvasAdapter({container, gridSize = 16, onViewportChange, onBackgroundMenu, nodeSelector})` | A surface you put nodes and lines on: right-, middle- or Shift-drag pans, Ctrl + the wheel zooms about the pointer, a left press on the background draws a marquee (`onMarquee(box)`, `onSelectionChange(selection)`). Fields `root`, `surface`, `edgeLayer` (an SVG), `nodeLayer`, `marquee`, `selection`, `scale`, `tx`, `ty`, `gridSize`. Methods `clientToCanvas`, `canvasToClient`, `snap`, `setViewport`, `viewport`, `zoomAt`, `fit`, `visibleBox`, `contains`, `bringIntoView` (pans and never zooms, and does nothing when the box is already in view), `consumeRightDrag`, `clear`, `destroy`. **A node is whatever you put in `nodeLayer`**: a press inside one starts no pan and no marquee, and a right-click on one keeps your menu (or the browser's). Pass `nodeSelector` when the node layer holds other things too. `onViewportChange` fires once, synchronously, from the constructor, so declare what it reads before you build the adapter |
|
|
1807
|
+
| `new CanvasAdapter({container, gridSize = 16, onViewportChange, onBackgroundMenu, nodeSelector})` | A surface you put nodes and lines on: right-, middle- or Shift-drag pans, Ctrl + the wheel zooms about the pointer, a left press on the background draws a marquee (`onMarquee(box)`, `onSelectionChange(selection)`). Fields `root`, `surface`, `edgeLayer` (an SVG), `nodeLayer`, `marquee`, `selection`, `scale`, `tx`, `ty`, `gridSize`. Methods `clientToCanvas`, `canvasToClient`, `snap`, `setViewport`, `viewport`, `zoomAt`, `fit`, `visibleBox`, `contains`, `bringIntoView` (pans and never zooms, and does nothing when the box is already in view), `consumeRightDrag`, `clear`, `destroy`. **A node is whatever you put in `nodeLayer`**: a press inside one starts no pan and no marquee, and a right-click on one keeps your menu (or the browser's). Pass `nodeSelector` when the node layer holds other things too. `onViewportChange` fires once, synchronously, from the constructor, so declare what it reads before you build the adapter. `CanvasAdapter.MIN_SCALE` (0.15) and `CanvasAdapter.MAX_SCALE` (4) are the limits every zoom clamps to (0.17.0) |
|
|
1151
1808
|
| `routeEdge(from, to, {obstacles, simple})`, `selfLoop`, `returnEdge(from, to, {floor})` | SVG path data between two anchors `{x, y, side}`: a line with horizontal lead-outs (bent round obstacles up to `OBSTACLE_ROUTING_NODE_LIMIT` nodes), a node's line to itself, and a loop's line back, which runs under both nodes |
|
|
1152
1809
|
| `crowsFoot(point, side, kind)`, `footAnchor(point, side, kind)`, `cardinality({kind, optional})` | Crow's-foot ends (`many`, `one`, `zero-or-many`, `one-or-many`, `zero-or-one`, `exactly-one`) as an SVG group `twm-edge__marker twm-edge__marker--<kind>`, where the line should start so it meets the mark, and the two ends a reference implies |
|
|
1153
1810
|
| `arrange(nodes, edges, {columnGap, rowGap})` | A layered layout. **Two quirks, kept on purpose:** an edge points from child to parent (a node sits one column right of what it points at), and each column is sorted by `label`, which every node must have |
|
|
1811
|
+
| `layeredLayout(nodes, edges, {rank, sizeOf, columnGap = 64, rowGap = 32, componentGap = 80, passes = 8})` (0.17.0) | A layered layout for any directed graph, as a diagram is read: `{from, to}` puts `from` left of `to`. Longest-path columns (cycles broken, never hung on); `rank(node)` answering `'source'` or `'sink'` puts a node in the first or the last column; barycentre sweeps reduce crossings, keeping the order with the fewest (the order given included); `sizeOf(node)` → `{width, height}` (default the node's own `width`/`height`, else 160 × 80) sizes the columns; components no line joins are stacked top to bottom, the largest first, `componentGap` apart. Returns a Map of id → the top-left `{x, y}` from (0, 0). Pure; 300 nodes and 400 edges take a few milliseconds |
|
|
1154
1812
|
| `new NodePlatform({collapsedPorts})` | The nodes and their ports: `hydrate(nodes)`, `get`, `all`, `port`, `setPosition`, `setCollapsed`, `visiblePorts(id)`, `on(event, fn)` (`hydrated`, `moved`, `collapsed`). A node is `{id, position, size, collapsed, ports, …}`; any other key is kept |
|
|
1155
1813
|
| `new ConnectorRouter({platform, selfReference, policy, endsOf, disabledEnds, strings})` | Edges and whether a drop may become one. `canConnect(source, target, {pendingEdges})` refuses, in order, a node that is gone, a port that is gone, a disabled port (with its `disabledReason`), a self-reference (unless `selfReference`), and a port at its `maxConnections`. Only then does it ask your `policy(ctx)`, which may refuse, allow, or answer `{ok: true, offer}`. Also `hydrate`, `edges` (a Map), `edgesFor`, `stage`, `unstage`, `remapNodeConnections(nodeId, portIds)` (keeps the edges whose ports survive and reports the rest) |
|
|
1156
1814
|
| `new HistoryService({actions, apply, onState, limit, requires, refusal})` | Undo over recorded edits (deltas), behind a closed action list: `record` throws on any action you did not declare. Nothing is recorded before `hydrated()`, and a batch (`startBatch`/`endBatch`) is one entry. Also `undo`, `redo`, `reset`, `adopt`, `undoStack`, `redoStack`, `canUndo`, `canRedo`. A missing `actions`, `apply` or anything named in `requires` throws at construction, not at the first silent no-op |
|
|
@@ -1197,38 +1855,183 @@ editor.setRunOverlay({ steps, ports, banner });
|
|
|
1197
1855
|
| `widgets`, `references`, `strings`, `services` | The kit's widget registry, the reference syntaxes by name, your words (`CANVAS_STRINGS` and the kit's), and your bag, handed to every widget unchanged |
|
|
1198
1856
|
| `values` | *Insert a value*: called with `{graph, stepId, field, key, before, loops}`, where `before` is the steps that always run before this one (`alwaysBefore`, nearest first) and `loops` the loops around it. Returns groups, or `{groups, note}` |
|
|
1199
1857
|
| `summarise(node, type)` | A line under a node's ports. `summariseReads(node, type)` is the panel's id line |
|
|
1200
|
-
| `dataPortRead(node, port)` | How a data port is read, in your syntax. It is shown on the port, which cannot be connected |
|
|
1858
|
+
| `dataPortRead(node, port)` | How a data port is read, in your syntax. It is shown on the port, which cannot be connected (unless `connections.dataPorts` is `'connect'`) |
|
|
1859
|
+
| `connections` | `{dataPorts: 'connect', policy(ctx)}`, absent by default, and then nothing below is drawn or bound. With it every line is asked, in order: what the canvas cannot draw (`canConnect` with `dataPorts`: a data port connects only to a data port, and a data output carries any number of lines); the kernel's `ConnectorRouter` over the catalogue ports, which take its descriptor keys (`disabled`, `disabledReason`, `maxConnections`, `allowMultiple`); the port types (an input's `accepts: ['number']` refuses an output whose `provides` names none of them, in a sentence); and your `policy(ctx)` — the router's context plus `connection` and a copy of the `graph` — which may refuse with a `reason`, allow, or answer `{ok: true, offer: {message, accept, apply(draft)}}`. An offer is shown with Accept and Not now: Accept makes the line and runs `apply` on a copy of the graph (change it in place or return a new one), as one undo entry. An output can also be dragged: while it is, the inputs it may end on carry `twm-flow-graph__port--can-drop`; released on one it connects; released on the empty canvas it opens the step picker with the types that have an input it may connect to, and the step picked is added with the line as one `flow:node:add` |
|
|
1201
1860
|
| `flowSettings` | `{title, schema, value}` — the flow's own settings, edited as `flow:settings` |
|
|
1202
1861
|
| `readOnly` | `false`, or `{reason}`, which is shown above the editor. Every verb is then refused with that reason, and no gesture writes |
|
|
1203
|
-
| `actions` | Your toolbar verbs, `[{id, label, icon, primary, danger, title, run(editor)}]`, after Undo, Redo, Arrange and Fit |
|
|
1862
|
+
| `actions` | Your toolbar verbs, `[{id, label, icon, primary, danger, title, run(editor)}]`, after Undo, Redo, Arrange and Fit (and the zoom buttons, with `zoomControls`) |
|
|
1204
1863
|
| `slots` | `toolbarEnd(el)`, `flowPanel(el, editor)` and `stepPanel(el, node, editor)`, drawn by you; `setRunOverlay` redraws the panel slots and nothing else |
|
|
1205
|
-
| `onChange`, `onSelect` | After every committed edit. An undo or a redo is a change too (`action: null`, `via: 'undo' \| 'redo'`), because you autosave what is undone. `onSelect({kind, id})`: `step`, `flow`, `connection` (with `connection`) or `null` |
|
|
1864
|
+
| `onChange`, `onSelect` | After every committed edit. An undo or a redo is a change too (`action: null`, `via: 'undo' \| 'redo'`), because you autosave what is undone. `onSelect({kind, id})`: `step`, `flow`, `connection` (with `connection`) or `null`; with `multiSelect`, two or more picked steps are `{kind: 'many', ids}`; with `notes`, a picked note is `{kind: 'note', id}` and a pick with notes in it `{kind: 'many', ids, notes}` |
|
|
1865
|
+
| `multiSelect` (0.17.0) | `true` makes the pick a set (below, *Many picked*). Without it none of it is bound or drawn |
|
|
1866
|
+
| `groups`, `notes` (0.17.0) | `true` draws the graph's groups as frames behind their steps and its notes as text on the canvas, and lets the reader make and edit them (below, *Groups and notes*). Without them none of it is bound or drawn, and a graph's groups and notes are kept as they are |
|
|
1867
|
+
| `clipboard`, `prepareCopy(node, {from, via})` (0.17.0) | `true` keeps what Ctrl/⌘+C copies inside the editor while it is open; `{read(), write(text)}` hands it to you: `write` hears the copied JSON and `read` (text, or a promise of it) supplies what Ctrl/⌘+V pastes. `prepareCopy` may change a copy's `config` and `label` before it is added (`via`: `'paste'` or `'duplicate'`). Unset, Ctrl/⌘+C, X, V and D are not the editor's (below, *Copy and paste*) |
|
|
1206
1868
|
| `loop`, `waitsForAll` | The loop's port names (`{entry: 'in', next, body, done}`): a line into a loop-role step on any input but `entry` is its body going back round, drawn dashed under both steps. `waitsForAll(join)` goes to `alwaysBefore` |
|
|
1207
1869
|
| `removable(node, type)` | Whether a step may be removed. By default every step except a start step |
|
|
1208
1870
|
| `node` | `{width: 208, header: 34, row: 22}` (`CANVAS_NODE`). Lines are anchored by arithmetic over these numbers, and the stylesheet is held to them |
|
|
1871
|
+
| `edgeStyle(connection, {source, target, back})` | How a line is drawn, asked for each line on every paint with copies of its two steps (`back`: it is a loop's line back). Returns `null` or `{tone, dashed, width, marker, label}`: `tone` is one of `TONES`, `width` a stroke width, `marker` `'arrow'` or a crow's-foot kind (`'many'`, `'one'`, `'zero-or-many'`, `'one-or-many'`, `'zero-or-one'`, `'exactly-one'`) drawn at the input end, and `label` text drawn at the line's midpoint, above the steps (a press on it picks the line). A picked or highlighted line still shows as one. Unset, the lines are drawn exactly as before |
|
|
1872
|
+
| `routing` | `'simple'` (the default: direct curves) or `'obstacles'`: each line is bent round the steps between its ends by the kernel's `routeEdge`, up to `OBSTACLE_ROUTING_NODE_LIMIT` steps. Above that the lines are direct curves and the findings strip says so (code `canvas-routing-simple`) |
|
|
1873
|
+
| `hoverHighlight` | `true`: the pointer over a step gives its lines `twm-flow-graph__edge--highlighted`, and over a line that line; leaving takes it away |
|
|
1209
1874
|
| `history`, `gridSize`, `label` | `{limit, mergeMs}`, the snap grid, and the canvas's accessible name |
|
|
1875
|
+
| `viewport`, `onViewportChange(viewport)` | Where the reader looks, kept by you: the editor stores none of it. `viewport` (`{scale, tx, ty}`, as `getViewport()` gives it) is where the first `load` opens, instead of fitting. `onViewportChange` hears ONE report per finished gesture of the reader's (a pan's release, a run of wheel turns once the wheel has been still for 300 ms, Fit, Arrange, a zoom button, a minimap click, *Go to it*), never one per pointer move, never a gesture that ended where it began, and never what you set yourself (`load`, `setViewport`, `fit()`, `arrange()`). A wheel report still waiting is handed on by the next press and by `destroy` (0.17.0) |
|
|
1876
|
+
| `zoomControls` | `true` adds Zoom out, Zoom in and 100% after Fit. They step through 15%, 25%, 33%, 50%, 67%, 80%, 100%, 125%, 150%, 200%, 300% and 400%, about the centre of the view. At the kernel's limits the button is refused in place with its sentence. Off by default (0.17.0) |
|
|
1877
|
+
| `renameInPlace` | `true`: a double-click on a step's title edits its name on the node ([Edited on the node](#edited-on-the-node-0170)). Off by default, and then a double-click does nothing (0.17.0) |
|
|
1878
|
+
| `collapsible` | `true` puts a fold button on every step's header: it folds the step to its header and back, one `flow:node:collapse` entry each way (below, *Live values and folded steps*). Off by default, and then there is no button in the DOM and a `collapsed` flag in a graph is carried through untouched, the step drawn unfolded (0.17.0) |
|
|
1879
|
+
| `minimap` | `true` draws a small overview over the canvas's lower right corner, with the steps and the view's frame, redrawn when the canvas changes size as well as when the view moves. A click on it centres the view on that point, at the same scale. Off by default, and then there is no minimap in the DOM (0.17.0) |
|
|
1880
|
+
| `layout` (0.17.0) | What Arrange does. `'flow'` (the default): in the order a run takes the steps, from the start step(s). `'layered'`: the kernel's `layeredLayout` over the lines, with each type's `rank` key (`'source'` or `'sink'`, read by the catalogue's `rank(typeId)`) as its hint. Or your own `fn(graph, ctx)` returning a Map of id → `{x, y}` from the origin (a step it leaves out stays where it is; a place that is not finite numbers `{x, y}`, like anything but a Map, throws), handed a copy of what is arranged and `ctx = {catalogue, node, ids, sizeOf(node), layeredLayout}` (`ids` is `null` for the whole graph). With `multiSelect` and several steps picked, the toolbar's Arrange lays out only those steps and the lines between them, and only they move. The places are anchored at the top-left corner of what is arranged and snapped to the grid |
|
|
1210
1881
|
|
|
1211
1882
|
| Method | Does |
|
|
1212
1883
|
|---|---|
|
|
1213
|
-
| `load({graph, flowSettings}, {baseline = true, fit = true})` | Replaces the content. A baseline clears both stacks. With `baseline: false` the stacks are kept, and the loaded content becomes the present they undo from |
|
|
1884
|
+
| `load({graph, flowSettings}, {baseline = true, fit = true, viewport})` | Replaces the content. A baseline clears both stacks. With `baseline: false` the stacks are kept, and the loaded content becomes the present they undo from. With `viewport` it opens there and does not fit |
|
|
1885
|
+
| `getViewport()`, `setViewport({scale, tx, ty})` | The kernel's viewport, as a copy; and putting the view there, clamped to the kernel's limits. `setViewport` drops a fit still waiting for its frame, is not reported to `onViewportChange`, and answers `false` (changing nothing) for anything that is not three finite numbers with a scale above 0 (0.17.0) |
|
|
1214
1886
|
| `getGraph()`, `serialise()`, `snapshot()`, `getFlowSettings()` | A copy of the graph; its byte-stable text; the text the history holds (graph and settings); the settings |
|
|
1215
1887
|
| `setFindings(list)`, `setRunOverlay(overlay \| null)`, `setReadOnly(false \| {reason})` | — |
|
|
1888
|
+
| `setNodeValues({[id]: {badge, tone, ports: {[name]: text}}} \| null)` (0.17.0) | What you show on the steps right now, replacing what you showed before. Painted in place: no undo entry, no `onChange`, nothing rebuilt and the panel untouched (below) |
|
|
1889
|
+
| `setCollapsed(id, collapsed)` (0.17.0) | With `collapsible`: fold a step to its header (`true`) or unfold it, one `flow:node:collapse` entry. `false` when refused (no `collapsible`, read only, no such step, or no change) |
|
|
1216
1890
|
| `setActionState(id, {disabled, reason, busy})`, `setStatus(text)`, `setMessage(text, tone)` | A verb refused in place, with its reason printed beside the toolbar; the status line; the gesture line |
|
|
1217
|
-
| `
|
|
1891
|
+
| `align(kind)`, `distribute(axis)` (0.17.0) | With `multiSelect`: line the picked steps up (`'left'`, `'center'`, `'right'`, `'top'`, `'middle'`, `'bottom'`; two or more picked) or give them equal gaps (`'horizontal'`, `'vertical'`; three or more), one `flow:nodes:align` entry. `false` when refused (too few picked, read only, or no `multiSelect`); an unknown kind or axis throws |
|
|
1892
|
+
| `copy()`, `cut()`, `paste(text?)`, `duplicate()` (0.17.0) | With `clipboard`: the keys' verbs from code. Each answers `{ok: true, ids}` (the steps copied, cut or made) or `{ok: false, reason}`, the reason also on the message line; `paste()` answers a promise when your `read` is async, and `paste(text)` pastes that text. Without `clipboard`: `{ok: false, reason: null}` |
|
|
1893
|
+
| `select(id \| [id, …] \| 'flow' \| null)`, `repaintPanel()`, `focus()`, `undo()`, `redo()`, `fit({whole})` | `select([id, …])` (0.17.0, `multiSelect`) picks several steps at once (unknown ids are left out; one left is one step picked, none lets go) |
|
|
1894
|
+
| `group(ids?, {label, tone})`, `ungroup(groupIds?)`, `setGroup(id, {label, tone})`, `setGroupMembers(id, ids)` (0.17.0) | With `groups`: group these steps (by default the picked ones), each leaving the group it was in, and answer the new group's id (`group`, `group-2`, …) or `null`; remove these groups (by default the ones the picked steps are in), their steps staying put; a group's name and tone; exactly these members, none removing the group. One `flow:group:add`, `…:remove`, `…:label` or `…:members` entry each, `false`/`null` when refused (read only, nothing to do) |
|
|
1895
|
+
| `addNote({text, position, size})`, `editNote(id, text)`, `moveNote(id, {x, y})`, `removeNote(id)` (0.17.0) | With `notes`: a note at `position` (by default the centre of the view), answered as a copy (`note`, `note-2`, …) or `null`; its text; where it is; taking it away. One `flow:note:add`, `…:edit`, `…:move` or `…:remove` entry each |
|
|
1896
|
+
| `setCatalogue(catalogue \| types)` | Swaps the step types while the editor is open, for a type registered after `load`: the palette is redrawn and a node whose type's definition changed is rebuilt, so a node of a type that was unknown now draws. A definition is compared by what the node is drawn from (known or not, `icon`, `label`, tone, ports, `render`, `update` and `inline`), not by object identity: a new catalogue of the same types plus one more rebuilds no node and calls no `render` again. Under the `connections` option the connection rules and the empty-drop picker judge by the new catalogue, and a pending offer is taken away. The graph and the history are untouched (0.17.0) |
|
|
1897
|
+
| `arrange({ids})` | Arrange the whole graph, as the toolbar's button does when fewer than two steps are picked. With `ids` (0.17.0), only those steps and the lines between them are laid out and only they move, still one `flow:arrange` entry; fewer than two known steps, or a read-only editor, answers `false` |
|
|
1218
1898
|
| `history`, `canvas` | The editor's `FlowHistory` (`keep()`, `adopt(kept, editor.snapshot())`, `baseline(editor.snapshot())` for your publish) and its `CanvasAdapter` |
|
|
1219
|
-
| `destroy()` | Removes every listener, the ghost of a drag and any picker it opened. It saves nothing, because the editor holds nothing |
|
|
1899
|
+
| `destroy()` | Removes every listener, the ghost of a drag, the minimap and any picker it opened. It saves nothing, because the editor holds nothing; a viewport report still waiting for the wheel to settle is handed to `onViewportChange` first |
|
|
1220
1900
|
|
|
1221
1901
|
Its edits are the closed list `CANVAS_ACTIONS`: `flow:node:add`,
|
|
1222
1902
|
`flow:node:remove`, `flow:node:move` (one per drag), `flow:edge:connect`,
|
|
1223
1903
|
`flow:edge:disconnect`, `flow:node:label`, `flow:node:config`,
|
|
1224
|
-
`flow:settings`, `flow:arrange` (one entry for the whole layout)
|
|
1904
|
+
`flow:settings`, `flow:arrange` (one entry for the whole layout), and with
|
|
1905
|
+
`multiSelect` `flow:nodes:move`, `flow:nodes:remove` and `flow:nodes:align`
|
|
1906
|
+
(0.17.0; one entry per gesture on several steps), with `clipboard`
|
|
1907
|
+
`flow:paste` and `flow:node:duplicate` (0.17.0; one entry per paste or
|
|
1908
|
+
duplicate, however many steps it makes), with `collapsible`
|
|
1909
|
+
`flow:node:collapse` (0.17.0), and with `groups` and `notes`
|
|
1910
|
+
`flow:group:add`, `flow:group:remove`, `flow:group:label`,
|
|
1911
|
+
`flow:group:members`, `flow:note:add`, `flow:note:remove`, `flow:note:edit` and
|
|
1912
|
+
`flow:note:move` (0.17.0). Typing a name
|
|
1225
1913
|
or a setting is one entry. The geometry under it is exported as well, all of it
|
|
1226
1914
|
pure: `CANVAS_NODE`, `arrangeCanvasGraph(graph, catalogue, {loopEntry})` (where
|
|
1227
1915
|
Arrange puts each step: a Map of id → `{x, y}` from the origin),
|
|
1228
|
-
`canvasPortAnchor`, `canvasNodeBottom`
|
|
1916
|
+
`canvasPortAnchor`, `canvasNodeBottom` (both fold a node with `collapsed: true`
|
|
1917
|
+
to its header, or as the box's `folded(node)` says; 0.17.0), `canvasNodeWidth` (a type's own
|
|
1918
|
+
`size.w`, else the box's; 0.17.0), `isCanvasReturnEdge`,
|
|
1919
|
+
`canvasPathPoints(d, {steps})` (the points along a drawn line's path `d`:
|
|
1920
|
+
absolute `M`, `L`, `C`, `S` and `Z`, each curve sampled at `steps` points) and
|
|
1921
|
+
`canvasPathMidpoint(d)` (the point halfway along it by length, where a style's
|
|
1922
|
+
`label` sits).
|
|
1923
|
+
|
|
1924
|
+
#### Node bodies (0.17.0)
|
|
1925
|
+
|
|
1926
|
+
A catalogue type can draw its own controls and values inside its node:
|
|
1927
|
+
|
|
1928
|
+
```js
|
|
1929
|
+
const gauge = {
|
|
1930
|
+
type_id: 'gauge', label: 'Gauge', ports, size: { w: 280, h: 170 }, accent: 'teal',
|
|
1931
|
+
render(body, node, ctx) { // once, when the node is built
|
|
1932
|
+
const range = document.createElement('input');
|
|
1933
|
+
range.type = 'range';
|
|
1934
|
+
range.addEventListener('change', () => ctx.setConfig({ level: Number(range.value) }));
|
|
1935
|
+
body.append(range);
|
|
1936
|
+
},
|
|
1937
|
+
update(body, node, ctx) { // after render, and on every repaint
|
|
1938
|
+
body.querySelector('input').value = node.config.level ?? 50;
|
|
1939
|
+
body.querySelector('input').disabled = ctx.readOnly;
|
|
1940
|
+
},
|
|
1941
|
+
};
|
|
1942
|
+
editor.setCatalogue(createStepCatalogue([...types, gauge], { categories })); // after load too
|
|
1943
|
+
```
|
|
1944
|
+
|
|
1945
|
+
- **The body sits below the ports** and is built once with the node. `update`
|
|
1946
|
+
runs on the same element on every repaint, so a control inside it keeps its
|
|
1947
|
+
focus and what was typed into it. `node` is a copy: the body writes only
|
|
1948
|
+
through `ctx`. A later `setCatalogue` keeps that body too, unless the type's
|
|
1949
|
+
own definition changed (its ports, `label`, `icon`, category tone, `render`
|
|
1950
|
+
or `update`): then the node is built again and `render` runs again. A new
|
|
1951
|
+
`size` or `accent` is applied in place.
|
|
1952
|
+
- **`ctx`** is `{setConfig(patch), readOnly, values, services}`. `values` is
|
|
1953
|
+
what `setNodeValues` shows on this step now (`{badge, tone, ports}`, a copy),
|
|
1954
|
+
or `null`. `setConfig` merges
|
|
1955
|
+
`patch` into the node's config (an `undefined` value removes the key) as one
|
|
1956
|
+
`flow:node:config` edit: one undo entry, and `onChange` is called. It is
|
|
1957
|
+
refused (answers `false`) while the editor is read only, from inside
|
|
1958
|
+
`render` or `update`, and when a value in `patch` cannot be cloned (a
|
|
1959
|
+
function, a DOM node): then nothing is written. `services` is the editor's `services` option.
|
|
1960
|
+
- **`size: {w, h}`** is the node's box in pixels; either may be left out. Lines
|
|
1961
|
+
leave a node's outputs at its own width, and a node with `h` is exactly that
|
|
1962
|
+
tall, its body taking what is left. A body node without `h` is as tall as
|
|
1963
|
+
it is drawn. Arrange spaces its columns by the widest node.
|
|
1964
|
+
- **`accent`** is a tone (`violet`, `teal`, `amber`, `grey`, `blue`, `indigo`),
|
|
1965
|
+
drawn as a stripe over the node's header.
|
|
1966
|
+
- **A `render` that throws** leaves that node drawn as a type without a body
|
|
1967
|
+
is, and what it threw is one of the editor's own findings: on the node and
|
|
1968
|
+
in the strip, beside yours from `setFindings`. An `update` that throws keeps
|
|
1969
|
+
the body and says so the same way until it succeeds. No other node is
|
|
1970
|
+
affected.
|
|
1971
|
+
- A type without these keys is drawn exactly as before, with the same markup,
|
|
1972
|
+
and `serialise()` is unchanged.
|
|
1973
|
+
|
|
1974
|
+
#### Edited on the node (0.17.0)
|
|
1975
|
+
|
|
1976
|
+
```js
|
|
1977
|
+
const counter = { type_id: 'counter', label: 'Counter', ports, inline: ['count'],
|
|
1978
|
+
config_schema: { type: 'object', properties: { count: { type: 'number' } } } };
|
|
1979
|
+
const editor = createCanvasEditor(host, { catalogue: [counter], renameInPlace: true });
|
|
1980
|
+
```
|
|
1981
|
+
|
|
1982
|
+
- **`renameInPlace: true`.** A double-click on a step's title puts a text box
|
|
1983
|
+
in its place, the name selected. Enter, or leaving the box, is one
|
|
1984
|
+
`flow:node:label` edit, and none when the name is unchanged. Escape puts the
|
|
1985
|
+
name back and records nothing. An empty name is refused with a sentence on
|
|
1986
|
+
the gesture line and the name stays: on Enter the box stays open
|
|
1987
|
+
(`aria-invalid`), on leaving it the box closes. Read only, a double-click
|
|
1988
|
+
opens nothing, and going read only closes an open box without writing. The
|
|
1989
|
+
settings panel, if it is showing that step, shows the new name.
|
|
1990
|
+
- **A type's `inline` settings** are drawn under the node's ports, above its
|
|
1991
|
+
own body, by the same widget registry as the panel (`widgets`), with the
|
|
1992
|
+
same labels and controls. A change on the node is a `flow:node:config` edit,
|
|
1993
|
+
and the panel shows it at once. A change in the panel, or an undo, redraws
|
|
1994
|
+
the node's control. The control being typed in is never redrawn under the
|
|
1995
|
+
reader; a focused control whose value changes elsewhere (an undo pressed
|
|
1996
|
+
while it holds the focus) is redrawn with its focus and caret kept. Typing
|
|
1997
|
+
on the node and then in the panel are two undo entries. Read only disables
|
|
1998
|
+
the node's controls. A node with inline settings and no
|
|
1999
|
+
`size.h` is as tall as it is drawn.
|
|
2000
|
+
- Without the option and the key nothing is bound or drawn: the same markup,
|
|
2001
|
+
and `serialise()` is unchanged.
|
|
2002
|
+
|
|
2003
|
+
#### Live values and folded steps (0.17.0)
|
|
2004
|
+
|
|
2005
|
+
```js
|
|
2006
|
+
const editor = createCanvasEditor(host, { catalogue, collapsible: true });
|
|
2007
|
+
editor.setNodeValues({ mix: { badge: '0.42', tone: 'teal', ports: { out: '0.42' } } }); // as often as you like
|
|
2008
|
+
editor.setNodeValues(null); // none shown
|
|
2009
|
+
```
|
|
2010
|
+
|
|
2011
|
+
- **`setNodeValues`** paints what you are showing right now: `badge` is drawn
|
|
2012
|
+
on the step's header, before its id tag, in a tone of `TONES` when `tone`
|
|
2013
|
+
names one, and each `ports[name]` beside that port. It replaces what the
|
|
2014
|
+
last call showed, and a step it leaves out shows nothing. It extends the run
|
|
2015
|
+
overlay: nothing is rebuilt or recorded, `onChange` is not called, and the
|
|
2016
|
+
settings panel is not touched, so a field being typed into keeps its focus
|
|
2017
|
+
and its selection however often you call it. A type with its own body reads
|
|
2018
|
+
them as `ctx.values`, and its `update` runs for each step whose values
|
|
2019
|
+
changed (a `setConfig` from there is refused, as from any `update`). The
|
|
2020
|
+
badge and port-value elements exist only while there is a value to show;
|
|
2021
|
+
the editor keeps nothing of them past the next call.
|
|
2022
|
+
- **`collapsible: true`** puts a fold button on each header. A folded step
|
|
2023
|
+
shows its header only: its port rows, its body and what is drawn under them
|
|
2024
|
+
are hidden, and its lines stay, meeting the middle of the header on its left
|
|
2025
|
+
(lines in) and its right (lines out). Each fold or unfold is one
|
|
2026
|
+
`flow:node:collapse` entry, so Ctrl+Z unfolds a step just folded. The graph
|
|
2027
|
+
carries `collapsed: true` on a folded step, written last in its `serialise()`
|
|
2028
|
+
text; an unfolded step has no `collapsed` key, so a graph that never folded
|
|
2029
|
+
a step is the text it always was. Read only refuses it.
|
|
1229
2030
|
|
|
1230
2031
|
**Gestures.** A node's header moves it. A connection is made by clicking an
|
|
1231
|
-
output and then an input, and Escape cancels one in progress.
|
|
2032
|
+
output and then an input, and Escape cancels one in progress. Under the
|
|
2033
|
+
`connections` option an output can also be dragged to an input, or onto the
|
|
2034
|
+
empty canvas to pick a step that arrives connected. A line is picked
|
|
1232
2035
|
by clicking it, and Delete removes it or the picked step. A palette step is
|
|
1233
2036
|
added by clicking it (at the centre of the view) or by dragging it onto the
|
|
1234
2037
|
canvas (where it is dropped). The drag uses pointer events and a ghost on
|
|
@@ -1239,10 +2042,72 @@ on the node it went down on. Arrange lays the run out left to right, a branch's
|
|
|
1239
2042
|
arms in their declared order, from the graph's own top-left corner. Fit frames
|
|
1240
2043
|
the graph at natural size or smaller; pressing Fit frames all of it.
|
|
1241
2044
|
|
|
2045
|
+
**Many picked** (`multiSelect: true`, 0.17.0). A left drag on the empty canvas
|
|
2046
|
+
draws a marquee and picks every step it touches; Shift-, Ctrl- or ⌘-click on
|
|
2047
|
+
a step adds it to the pick or takes it away; Ctrl/⌘+A picks every step and
|
|
2048
|
+
Escape lets go. `select([id, …])` picks several from code. Dragging any picked
|
|
2049
|
+
step moves them all (`flow:nodes:move`), Delete removes every one that may be
|
|
2050
|
+
removed, with their lines (`flow:nodes:remove`; a start step stays), and the
|
|
2051
|
+
panel says how many are picked, with their Remove. On the canvas the arrow
|
|
2052
|
+
keys nudge the pick one grid step (`gridSize`), ten with Shift; a held arrow is
|
|
2053
|
+
one entry. Eight icon verbs after Fit align the pick's left edges, centres,
|
|
2054
|
+
right edges, tops, middles and bottoms, and distribute it horizontally and
|
|
2055
|
+
vertically, each refused in place with fewer than two (three) picked. Arrange
|
|
2056
|
+
lays out only the pick and the lines between them, and nothing else moves. Tab and
|
|
2057
|
+
Shift+Tab move the focus from step to step in graph order, and past either end
|
|
2058
|
+
Tab is the browser's again, so the keyboard can leave the canvas. Every one of
|
|
2059
|
+
these is a single undo entry. In read-only mode a marquee still picks, and no
|
|
2060
|
+
gesture writes.
|
|
2061
|
+
|
|
2062
|
+
**Groups and notes** (`groups: true`, `notes: true`, 0.17.0). A group is a
|
|
2063
|
+
named frame, in one of `TONES` if it has a `tone`, drawn behind its steps and
|
|
2064
|
+
round all of them wherever they are. Its title bar is its handle: dragging it
|
|
2065
|
+
moves every member (one `flow:nodes:move`; with `multiSelect` the press picks
|
|
2066
|
+
them), and a double-click renames it in place (Enter keeps, Escape puts it
|
|
2067
|
+
back). Groups are flat and a step is in one at most: Ctrl/⌘+G (or Group on the
|
|
2068
|
+
toolbar) groups the picked steps, taking each out of the group it was in, and
|
|
2069
|
+
a group left empty goes; Ctrl/⌘+Shift+G (or Ungroup) removes the groups the
|
|
2070
|
+
picked steps are in. Removing a step shrinks its group, and removing its last
|
|
2071
|
+
member removes the group. Between the steps the frame takes no pointer, so a
|
|
2072
|
+
marquee or a pan can start inside it. A note is free text on the canvas: Note
|
|
2073
|
+
on the toolbar adds one at the centre of the view and opens it for typing; a
|
|
2074
|
+
double-click, or Enter on a focused note, opens it again (Ctrl/⌘+Enter or
|
|
2075
|
+
leaving the box keeps the text, Escape puts it back); dragging it moves it,
|
|
2076
|
+
and Delete removes the picked one. With `multiSelect` a marquee picks notes
|
|
2077
|
+
with the steps, Shift/Ctrl+click adds or takes one away, Ctrl/⌘+A picks them
|
|
2078
|
+
too, and dragging or deleting the pick takes its notes along, one entry. A
|
|
2079
|
+
graph keeps them as `groups` and `notes` (see `normalise` above) and
|
|
2080
|
+
`serialise()` writes them after the connections; a graph without any is the
|
|
2081
|
+
same text as before.
|
|
2082
|
+
|
|
2083
|
+
**Copy and paste** (`clipboard`, 0.17.0). Ctrl/⌘+C copies the picked steps
|
|
2084
|
+
and the lines among them; a line to a step left out of the pick is not copied,
|
|
2085
|
+
nor is a step whose type the palette does not offer (a start step) or that is
|
|
2086
|
+
`unavailable`. What is copied is the graph's own text, `{nodes, connections}`
|
|
2087
|
+
as `serialise()` writes it, so a graph from elsewhere pastes too. Ctrl/⌘+V
|
|
2088
|
+
gives every pasted step a fresh id (as a new step gets one), keeps the lines
|
|
2089
|
+
between them, sets the copy off two grid steps right and down (each further
|
|
2090
|
+
paste of the same text one more), picks it, and is one `flow:paste` entry. A
|
|
2091
|
+
paste right after Ctrl/⌘+X puts the steps back where they were; a cut is one
|
|
2092
|
+
remove entry. A step whose title the flow already has gets a derived label,
|
|
2093
|
+
`Title (copy)`, then `Title (copy 2)`, …, as the lanes editor's duplicate does.
|
|
2094
|
+
Ctrl/⌘+D does the same beside the pick without touching the clipboard, as one
|
|
2095
|
+
`flow:node:duplicate`. With nothing picked Ctrl/⌘+C, X and D are not the
|
|
2096
|
+
editor's. What is not steps, or names a type the flow does not have, is refused
|
|
2097
|
+
with its sentence on the message line. In read-only mode copying still works,
|
|
2098
|
+
and Paste, Cut and Duplicate are refused with the read-only reason and change
|
|
2099
|
+
nothing. With `notes` the picked notes are copied, cut and duplicated with the
|
|
2100
|
+
steps (a note alone too), and pasted under fresh ids, set off as the steps
|
|
2101
|
+
are; with `groups` a group whose every member is copied is in the copy, and a
|
|
2102
|
+
paste makes it again round the copies of its members (a fresh id, its name and
|
|
2103
|
+
tone kept), while a group with a member left out is not copied. `copy()` and
|
|
2104
|
+
`cut()` answer `notes: [id, …]` and `paste()` and `duplicate()` answer `notes`
|
|
2105
|
+
and `groups`, each only when there are some.
|
|
2106
|
+
|
|
1242
2107
|
**Keys** (bound on the editor's root): Ctrl/⌘+Z and Ctrl/⌘+Y or Ctrl/⌘+Shift+Z,
|
|
1243
2108
|
stopped at the root even inside a text field; Delete; F2 renames the picked
|
|
1244
|
-
step; Enter on a focused node opens its settings; Escape
|
|
1245
|
-
the editor's.
|
|
2109
|
+
step; Enter on a focused node opens its settings; Escape; with `groups`,
|
|
2110
|
+
Ctrl/⌘+G and Ctrl/⌘+Shift+G. Backspace is never the editor's.
|
|
1246
2111
|
|
|
1247
2112
|
**What it does not do.** It does not load, save, validate, publish or run
|
|
1248
2113
|
anything, and it does not keep the stacks across page loads. Those belong to
|
|
@@ -1251,9 +2116,9 @@ flow opens on a start step adds that step to the graph before `load`, so it is
|
|
|
1251
2116
|
part of the baseline and the first Ctrl+Z does not take it away.
|
|
1252
2117
|
|
|
1253
2118
|
**Where each claim was checked.** `tests/canvas_adapter.test.mjs`,
|
|
1254
|
-
`canvas_edge_router`, `canvas_graph`, `flow_canvas_layout`, `flow_canvas_editor
|
|
1255
|
-
and `flow_canvas_css` check the DOM, the events, the
|
|
1256
|
-
as text. In headless Edge, `demo/flow_canvas_probe.mjs` drives
|
|
2119
|
+
`canvas_edge_router`, `canvas_graph`, `flow_canvas_layout`, `flow_canvas_editor`,
|
|
2120
|
+
`flow_canvas_multiselect`, `flow_canvas_clipboard`, `flow_canvas_node_body`, `flow_canvas_live_values`, `flow_canvas_groups_notes` and `flow_canvas_css` check the DOM, the events, the
|
|
2121
|
+
geometry and the stylesheet as text. In headless Edge, `demo/flow_canvas_probe.mjs` drives
|
|
1257
2122
|
`demo/flow_canvas.html` with real input:
|
|
1258
2123
|
- `elementFromPoint` at the centre of Undo, Arrange, Fit, a consumer verb, a
|
|
1259
2124
|
palette item, a node's header, a port, and the panel's name box, first field
|
|
@@ -1266,8 +2131,43 @@ as text. In headless Edge, `demo/flow_canvas_probe.mjs` drives
|
|
|
1266
2131
|
- A real click-then-click connected two steps.
|
|
1267
2132
|
- A header drag moved its node, and a real Ctrl+Z put it back. Backspace removed
|
|
1268
2133
|
nothing, Delete removed the step, and the focus stayed on the canvas.
|
|
2134
|
+
- On a 300-step graph with `multiSelect`, a real marquee picked three steps, a
|
|
2135
|
+
real drag of one moved all three and one real Ctrl+Z put them back; a move
|
|
2136
|
+
of 50 picked steps and its undo each finished in under 100 ms.
|
|
2137
|
+
- With `groups` and `notes`, a real Ctrl+G on two picked steps drew one frame
|
|
2138
|
+
round both and not round the third. Its title bar, and a step inside it, were
|
|
2139
|
+
each the element at their own centre, and between the steps the canvas was
|
|
2140
|
+
(the frame takes no pointer). A real drag of the title bar moved both by the
|
|
2141
|
+
pointer's travel as one entry, and one real Ctrl+Z put them back. A real
|
|
2142
|
+
double-click opened a note for typing, real keys and Ctrl+Enter kept the
|
|
2143
|
+
text, a real drag moved it, and a real marquee picked the steps and the note
|
|
2144
|
+
together.
|
|
2145
|
+
- With `collapsible`, ten `setNodeValues` calls 100 ms apart each put their
|
|
2146
|
+
value on the step's badge, inside its header, while real keys typed into a
|
|
2147
|
+
settings field: the field kept the focus and every key, and the values made
|
|
2148
|
+
no undo entry and no `onChange`. A real click on a header's fold button hid
|
|
2149
|
+
every port row and left the step as tall as its header, with its lines
|
|
2150
|
+
ending on the header's middle at its edges, within 1px; a real Ctrl+Z
|
|
2151
|
+
unfolded it and its lines met their port dots again.
|
|
2152
|
+
- A type registered with `setCatalogue` after the page opened was in the
|
|
2153
|
+
palette. Its node was drawn at its 280×170 size, with its body below its
|
|
2154
|
+
ports and its accent. Every line into and out of it began and ended on its
|
|
2155
|
+
port dots, within 1px.
|
|
1269
2156
|
- At 700px the canvas took the editor's whole width.
|
|
1270
2157
|
|
|
2158
|
+
`tests/flow_canvas_edge_style.test.mjs` checks `edgeStyle`, `routing` and
|
|
2159
|
+
`hoverHighlight` in the DOM, and `demo/flow_canvas_edges_probe.mjs` in headless
|
|
2160
|
+
Edge:
|
|
2161
|
+
- A dashed line had a computed dash pattern, and an unstyled line kept its old
|
|
2162
|
+
stroke and width.
|
|
2163
|
+
- An arrowhead's tip sat on the input port's centre line, and the line ended at
|
|
2164
|
+
its base.
|
|
2165
|
+
- A label sat on its line's midpoint over a step, and `elementFromPoint` there
|
|
2166
|
+
returned the label.
|
|
2167
|
+
- With `routing: 'obstacles'`, no point of a line lay inside the drawn box of
|
|
2168
|
+
the step between its ends. Above the node limit the strip said so.
|
|
2169
|
+
- A real pointer over a step highlighted its lines, and moving off cleared them.
|
|
2170
|
+
|
|
1271
2171
|
### The outline editor
|
|
1272
2172
|
|
|
1273
2173
|
`createOutlineEditor` draws a logic flow as a list of blocks: *If … otherwise*,
|
|
@@ -1497,8 +2397,14 @@ input (`lanes_two_inputs`), a cycle (`lanes_cycle`) or two steps with one id
|
|
|
1497
2397
|
read only with the sentence, is drawn as well as it can be, and is never
|
|
1498
2398
|
repaired. The sizes are `LANE_GEOMETRY`: `regular` is 200 × 62 (the editor),
|
|
1499
2399
|
`small` is 160 × 50 (two lines a card, for a narrower host) and `strip` is
|
|
1500
|
-
92 × 26 (`compact`). The pure edits — splice, add a source, remove,
|
|
1501
|
-
input, the input candidates, parameters — are `lanePipeline`.
|
|
2400
|
+
92 × 26 (`compact`). The pure edits — splice, add a source, remove, duplicate,
|
|
2401
|
+
set an input, the input candidates, parameters — are `lanePipeline`.
|
|
2402
|
+
`lanePipeline.duplicateStep(pipeline, catalogue, id)` (0.17.0) puts a copy right
|
|
2403
|
+
after the step: a fresh id, a label derived from its title (`Title (copy)`, then
|
|
2404
|
+
`(copy 2)`, …, words in `LANE_PIPELINE_STRINGS`), a deep copy of its config and
|
|
2405
|
+
the same lines in, none out. It answers `{ok: true, node}`, or `{ok: false,
|
|
2406
|
+
reason}` and changes nothing when there is no such step or its type is
|
|
2407
|
+
`unavailable`.
|
|
1502
2408
|
|
|
1503
2409
|
| Option | Meaning |
|
|
1504
2410
|
|---|---|
|
|
@@ -1520,6 +2426,7 @@ input, the input candidates, parameters — are `lanePipeline`.
|
|
|
1520
2426
|
| `parameterTypes` | The parameter types a parameter may be: `text`, `number`, `integer`, `boolean`, `date` by default |
|
|
1521
2427
|
| `select` | The step chosen on load (the first step, left to right, by default) |
|
|
1522
2428
|
| `history` | `{limit, mergeMs}` |
|
|
2429
|
+
| `menuItems(step)` | Your own items on a card's menu, after the built-in ones: `[{id, label, icon?, danger?, disabled?, reason?, run}]`. `run({step, pipeline, editor})` gets a DRAFT of the flow to change in place (it may answer later, with a promise); what it changed is one undoable edit, `flow:step:menu`. Answering `false` takes nothing, and neither does a draft that answers after another edit. `disabled` greys the item with its `reason`; a read-only flow greys every one with the read-only reason (0.17.0) |
|
|
1523
2430
|
| `onChange`, `onSelect({kind, id})`, `onStepsShow(mode)` | Callbacks |
|
|
1524
2431
|
|
|
1525
2432
|
| Method | Does |
|
|
@@ -1532,12 +2439,16 @@ input, the input candidates, parameters — are `lanePipeline`.
|
|
|
1532
2439
|
| `setReadOnly(false \| {reason})`, `readOnly` | — |
|
|
1533
2440
|
| `setActionState(id, {disabled, reason, busy})`, `setStatus(text)` | A refused verb shows its reason beside it |
|
|
1534
2441
|
| `select(id \| 'flow' \| null)`, `selected`, `focus()` | — |
|
|
1535
|
-
| `undo()`, `redo()`, `history` | Over whole snapshots. The actions are `LANE_ACTIONS` (`flow:step:add`, `:remove`, `:label`, `:config`, `flow:join:set`, `flow:source:add`, `flow:parameter:add`, `:change`, `:remove`, `flow:settings`) |
|
|
2442
|
+
| `undo()`, `redo()`, `history` | Over whole snapshots. The actions are `LANE_ACTIONS` (`flow:step:add`, `:remove`, `:label`, `:config`, `:duplicate`, `:menu`, `flow:join:set`, `flow:source:add`, `flow:parameter:add`, `:change`, `:remove`, `flow:settings`) |
|
|
1536
2443
|
| `refreshPreview()` | Asks the providers now, without the debounce |
|
|
1537
2444
|
| `inputColumns(stepId, port?)` | The columns of the frame a step reads, from the last description, else the last preview. The `upstream-column(s)` widgets read the same |
|
|
1538
2445
|
| `layout`, `placed` | The current layout and its coordinates |
|
|
1539
2446
|
| `destroy()` | Removes every listener, popover and table, and aborts the preview in flight |
|
|
1540
2447
|
|
|
2448
|
+
**Columns that arrive while you are in the settings** are drawn when the focus
|
|
2449
|
+
leaves them, so a column typed by name before the columns were known becomes a
|
|
2450
|
+
choice then, and nothing is rebuilt under the reader's caret.
|
|
2451
|
+
|
|
1541
2452
|
**The preview** is one request for the whole flow to each provider. It is sent
|
|
1542
2453
|
800 ms after the last edit and once after a load. A rename does not send one,
|
|
1543
2454
|
because it changes no row. Every request carries a sequence number and an
|
|
@@ -1550,10 +2461,14 @@ business; the editor knows only what it is handed.
|
|
|
1550
2461
|
|
|
1551
2462
|
**Keys.** These are the kit's, on the editor's root. ← → ↑ ↓ move between cards
|
|
1552
2463
|
and between the step tabs. Enter opens a step's settings on their first field,
|
|
1553
|
-
and Escape goes back to the card. Delete removes the step
|
|
2464
|
+
and Escape goes back to the card. Delete removes the step, Ctrl/⌘+D duplicates
|
|
2465
|
+
it (the copy is chosen and focused) and F2 renames it.
|
|
1554
2466
|
Ctrl/⌘+Z and Ctrl/⌘+Y undo and redo, and are stopped at the editor even inside
|
|
1555
2467
|
a field. The context-menu key or a right press opens a card's menu: *Open its
|
|
1556
|
-
settings*, *Add a step after it*, *Remove step
|
|
2468
|
+
settings*, *Add a step after it*, *Duplicate*, *Remove step*, then your
|
|
2469
|
+
`menuItems`. An item that cannot be chosen — *Duplicate* on a read-only flow, or
|
|
2470
|
+
on a step whose type is not available — is greyed with its reason in its own
|
|
2471
|
+
line, and a refused Ctrl/⌘+D says the reason beside the status. Backspace is never the
|
|
1557
2472
|
editor's. The cards are one Tab stop, the chosen one, and its "+" comes next in
|
|
1558
2473
|
the Tab order. The "+" shows on hover, on focus and on the chosen card, never
|
|
1559
2474
|
on hover alone.
|
|
@@ -1593,9 +2508,15 @@ keystrokes ask the preview once.
|
|
|
1593
2508
|
`openForm({ title, fields, defaults, submitLabel })` (in `@flexdesk/widgets`)
|
|
1594
2509
|
opens a modal form and resolves with `{ [name]: value }`, or `null` when it is
|
|
1595
2510
|
cancelled. Fields are `text`, `number`, `password`, `select`, `textarea`,
|
|
1596
|
-
`checkbox`
|
|
1597
|
-
|
|
1598
|
-
|
|
2511
|
+
`checkbox`, `toggle`, `picker`, `checklist`, `radio`, `color` and `colorList`;
|
|
2512
|
+
`{ section: 'Label' }` entries are headings and `{ row: [...] }` entries put
|
|
2513
|
+
fields side by side. The additions below are opt-in, with one change every form
|
|
2514
|
+
sees, even one that uses none of them: a `select` or `radio` comes back with
|
|
2515
|
+
its option's OWN value and type (a numeric option submits `2`, not `'2'`; an
|
|
2516
|
+
option whose value is `null` submits `null`), a default of that type selects
|
|
2517
|
+
its option (`defaults: { s: 2 }` selects the option whose value is `2`), and an
|
|
2518
|
+
option object with no `label` shows its value. A select with no option
|
|
2519
|
+
selected still reads and submits `''`.
|
|
1599
2520
|
|
|
1600
2521
|
### A form that adapts: `onFieldChange`
|
|
1601
2522
|
|
|
@@ -1619,12 +2540,23 @@ openForm({
|
|
|
1619
2540
|
every field's `change`. `api` addresses fields and named sections by name:
|
|
1620
2541
|
|
|
1621
2542
|
- `setVisible(name, on)` hides or shows the row. A hidden row keeps its value
|
|
1622
|
-
and is still submitted
|
|
2543
|
+
and is still submitted (a disabled option it holds submits as no value:
|
|
2544
|
+
`''` for a select or combobox, `null` for a radio), but it is not asked
|
|
2545
|
+
about: its `required` and its
|
|
1623
2546
|
`validator` stand down until it is shown again.
|
|
1624
|
-
- `get(name)` reads the control as it stands: a checkbox as a boolean,
|
|
1625
|
-
|
|
2547
|
+
- `get(name)` reads the control as it stands: a checkbox as a boolean, a
|
|
2548
|
+
select or radio as its option's own value (a select with no option selected
|
|
2549
|
+
as `''`, a radio with none as `null`), a combobox as the value of the option
|
|
2550
|
+
its text names — exactly what it submits — or as its text when it names
|
|
2551
|
+
none, a checklist as the array of chosen values, anything else as its
|
|
2552
|
+
string.
|
|
1626
2553
|
- `set(name, value)` writes it. It does not call `onFieldChange` back, so a
|
|
1627
|
-
hook that fills one field from another cannot loop.
|
|
2554
|
+
hook that fills one field from another cannot loop. A value a select or
|
|
2555
|
+
radio does not offer, or offers only disabled, clears it (a select then
|
|
2556
|
+
submits `''`, a radio `null`).
|
|
2557
|
+
- `setOptions(name, options)` replaces the options of a `select` (a
|
|
2558
|
+
combobox's suggestions too), `checklist` or `radio` while the form is open
|
|
2559
|
+
— see below.
|
|
1628
2560
|
|
|
1629
2561
|
A hook that throws is logged and the form carries on.
|
|
1630
2562
|
|
|
@@ -1666,6 +2598,571 @@ submit with "onCreate is not a function".
|
|
|
1666
2598
|
`tests/open_form.test.mjs` mounts the real `openForm` in jsdom and covers all
|
|
1667
2599
|
four, plus a plain form submitting as before.
|
|
1668
2600
|
|
|
2601
|
+
### Options: typed, unavailable, grouped, and changed while open (0.17.0)
|
|
2602
|
+
|
|
2603
|
+
```js
|
|
2604
|
+
openForm({
|
|
2605
|
+
fields: [
|
|
2606
|
+
{ name: 'country', label: 'Country', type: 'select', options: ['no', 'it'] },
|
|
2607
|
+
{ name: 'town', label: 'Town', type: 'select', options: [] },
|
|
2608
|
+
{ name: 'plan', label: 'Plan', type: 'select', options: [
|
|
2609
|
+
{ value: 1, label: 'Free' },
|
|
2610
|
+
{ value: 2, label: 'Pro', disabled: true, reason: 'needs a licence' },
|
|
2611
|
+
{ group: 'Teams', options: [{ value: 10, label: 'Small team' }, { value: 50, label: 'Large team' }] },
|
|
2612
|
+
] },
|
|
2613
|
+
{ name: 'days', label: 'Days', type: 'checklist', required: true,
|
|
2614
|
+
options: [{ value: 'mon', label: 'Mon' }, { value: 'tue', label: 'Tue' }] },
|
|
2615
|
+
{ name: 'mode', label: 'Mode', type: 'radio', options: [
|
|
2616
|
+
{ value: 'fast', label: 'Fast', description: 'Skips the checks.' },
|
|
2617
|
+
{ value: 'safe', label: 'Safe' },
|
|
2618
|
+
] },
|
|
2619
|
+
],
|
|
2620
|
+
onFieldChange: (name, value, api) => {
|
|
2621
|
+
if (name === null || name === 'country') api.setOptions('town', townsOf(api.get('country')));
|
|
2622
|
+
},
|
|
2623
|
+
});
|
|
2624
|
+
// → { country: 'it', town: 'Rome', plan: 10, days: ['mon'], mode: 'fast' }
|
|
2625
|
+
```
|
|
2626
|
+
|
|
2627
|
+
An option is a bare value or `{ value, label?, disabled?, reason?,
|
|
2628
|
+
description? }`.
|
|
2629
|
+
|
|
2630
|
+
- **Values keep their type.** An option whose value is the number 10 submits
|
|
2631
|
+
10, and `api.get` reads 10 — not `'10'`. A default matches an option of the
|
|
2632
|
+
same text, so `defaults: { plan: '10' }` selects 10. A combobox entry typed
|
|
2633
|
+
as an option's text submits (and `api.get` reads) that option's value. An
|
|
2634
|
+
option whose value IS the given one wins over one that only has its text:
|
|
2635
|
+
with options `[1, '1']`, a default `'1'` submits `'1'`.
|
|
2636
|
+
- **A disabled option** is shown with its `reason` (in a select's option text
|
|
2637
|
+
and tooltip, under a radio, on a chip) and is never the submitted value: a
|
|
2638
|
+
default naming it does not select it, `api.set` to it clears a select,
|
|
2639
|
+
radio or combobox and leaves a checklist's chip unpressed, the reader
|
|
2640
|
+
cannot pick it, a combobox does not suggest it, and a submit
|
|
2641
|
+
that finds one selected anyway (a script set `.value`, or every option is
|
|
2642
|
+
disabled) is refused with "Not available: *reason*" on the field. In a
|
|
2643
|
+
hidden row, which is not asked about, it submits as no value instead.
|
|
2644
|
+
- **`{ group, options }`** in a select's list is an `<optgroup>` headed
|
|
2645
|
+
*group*. In a checklist or radio it is a `.twm-choice__group` heading over
|
|
2646
|
+
that group's chips or rows (a group with no options draws none). A combobox
|
|
2647
|
+
suggests a group's enabled options, without headings.
|
|
2648
|
+
- **`api.setOptions(name, options)`** replaces the list while the form is
|
|
2649
|
+
open and keeps the current value when the new list offers it enabled.
|
|
2650
|
+
Otherwise a select falls to its first enabled option, a radio to none and a
|
|
2651
|
+
checklist drops what is gone. It does not call `onFieldChange` back.
|
|
2652
|
+
- **`type: 'checklist'`** draws toggle chips and submits the array of chosen
|
|
2653
|
+
values in the list's order (`[]` for none). `required` means at least one,
|
|
2654
|
+
and nothing chosen is "Required." on the field.
|
|
2655
|
+
- **`type: 'radio'`** draws one radio row per option, with its `description`
|
|
2656
|
+
under it, and submits the chosen value, or `null`. `required` means one is
|
|
2657
|
+
chosen.
|
|
2658
|
+
|
|
2659
|
+
The select, the chips and the radio list are the flow kit's
|
|
2660
|
+
(`src/ui/components/choice_controls.js`): the settings panel's `enum` select,
|
|
2661
|
+
`enum-chips`, `upstream-columns` and `choice-cards` draw with the same code, so
|
|
2662
|
+
a form and a settings panel cannot drift apart. In a form they are classed
|
|
2663
|
+
`.twm-flow-togglechips` / `.twm-flow-togglechip` and `.twm-flow-choices` /
|
|
2664
|
+
`.twm-flow-choice`; an unavailable option's reason is `.twm-choice__reason`.
|
|
2665
|
+
|
|
2666
|
+
`tests/open_form_choices.test.mjs` covers each of these in jsdom.
|
|
2667
|
+
|
|
2668
|
+
### Colours: `type: 'color'` and `type: 'colorList'` (0.17.0)
|
|
2669
|
+
|
|
2670
|
+
```js
|
|
2671
|
+
openForm({
|
|
2672
|
+
fields: [
|
|
2673
|
+
{ name: 'accent', label: 'Accent', type: 'color', default: 'rgb(31,119,180)',
|
|
2674
|
+
presets: ['#1f77b4', '#ff7f0e', '#2ca02c'] },
|
|
2675
|
+
{ name: 'palette', label: 'Palette', type: 'colorList', default: ['#1f77b4', '#ff7f0e'] },
|
|
2676
|
+
],
|
|
2677
|
+
});
|
|
2678
|
+
// -> { accent: '#1f77b4', palette: ['#1f77b4', '#ff7f0e'] }
|
|
2679
|
+
```
|
|
2680
|
+
|
|
2681
|
+
A `color` row is a swatch that opens the native picker, a hex box kept in step
|
|
2682
|
+
with it (picking writes the box; typing a valid hex moves the swatch), and,
|
|
2683
|
+
with `presets`, a strip of preset swatches. It submits lowercase `#rrggbb`, or
|
|
2684
|
+
`''` when left empty. A `colorList` row is an ordered list of such fields: add,
|
|
2685
|
+
remove, and reorder by drag or by the up / down buttons; `presets` are offered
|
|
2686
|
+
on every row, and it submits the colours in their current order as an array of
|
|
2687
|
+
`#rrggbb`. An `rgb()` / `rgba()` default (or `api.set` value) is shown as its
|
|
2688
|
+
hex; alpha is dropped, because the native picker has none. A box that does not
|
|
2689
|
+
hold `#rgb` or `#rrggbb` is a field error ("Not a hex colour", with the row
|
|
2690
|
+
numbers for a list) and the form does not resolve; `required` on either is
|
|
2691
|
+
checked by the form. `api.get` reads a `color` as its box text and a
|
|
2692
|
+
`colorList` as the array of its rows' text.
|
|
2693
|
+
|
|
2694
|
+
Both are also components of their own in `@flexdesk/widgets`:
|
|
2695
|
+
`new ColorField({ value, presets, name, label, onChange })` and
|
|
2696
|
+
`new ColorListField({ value, presets, newColor, addLabel, name, onChange })`,
|
|
2697
|
+
each with `render()`, `getValue()` (null while something typed is not a
|
|
2698
|
+
colour), `setValue()`, `focus()` and `dispose()`; `onChange(value, { commit })`
|
|
2699
|
+
reports every edit and, with `commit: true`, every committed one.
|
|
2700
|
+
`normalizeHex(value)` (`#rgb`, `#rrggbb`, `rgb()` -> `#rrggbb` or null) and
|
|
2701
|
+
`isHexColor(value)` are exported with them, and `validateField` (in
|
|
2702
|
+
`@flexdesk/tiles`) now rejects a `FieldTypes.COLOR` value that is not a hex
|
|
2703
|
+
colour. The markup is `.twm-color-field` > `.twm-color-field__swatch` +
|
|
2704
|
+
`.twm-color-field__hex` (+ `.twm-color-field__presets`), and `.twm-color-list`
|
|
2705
|
+
rows are `.twm-color-list__row`. `tests/color_field.test.mjs` covers all of it.
|
|
2706
|
+
|
|
2707
|
+
### Number bounds and fields side by side: `min`, `max` and `{ row }` (0.17.0)
|
|
2708
|
+
|
|
2709
|
+
```js
|
|
2710
|
+
openForm({
|
|
2711
|
+
fields: [
|
|
2712
|
+
{ name: 'steps', label: 'Steps', type: 'number', min: 2, max: 200, step: 1 },
|
|
2713
|
+
{ row: [
|
|
2714
|
+
{ name: 'lo', label: 'Min', type: 'number', hint: 'lowest value' },
|
|
2715
|
+
{ name: 'hi', label: 'Max', type: 'number', min: 0 },
|
|
2716
|
+
] },
|
|
2717
|
+
],
|
|
2718
|
+
});
|
|
2719
|
+
// → { steps: 50, lo: 0, hi: 10 }
|
|
2720
|
+
```
|
|
2721
|
+
|
|
2722
|
+
- **`min` / `max`** on a `number` field are the input's own `min` and `max`
|
|
2723
|
+
attributes, and a value outside them is refused on submit with an error
|
|
2724
|
+
under that field: "Must be between 2 and 200." (or "Must be at least 2." /
|
|
2725
|
+
"Must be at most 200." with only one). The browser's own range check stops
|
|
2726
|
+
such a submit first; the form cancels its bubble and shows the field error
|
|
2727
|
+
instead, and it checks the range itself as well, before the field's
|
|
2728
|
+
`validator`. An empty field that is not `required` is not out of range. A
|
|
2729
|
+
row hidden by `setVisible` drops its bounds until it is shown again, as it
|
|
2730
|
+
drops `required`. As with any `<input type="number">`, a `min` is also where
|
|
2731
|
+
`step` counts from.
|
|
2732
|
+
- **`{ row: [field, ...] }`** puts its fields on one line, sharing the width.
|
|
2733
|
+
The row is layout only and has no name: each field in it is an ordinary
|
|
2734
|
+
field with its own label, hint, error, `setVisible`, value and submitted
|
|
2735
|
+
key, so hiding one hides only that one. The first keeps the form's label
|
|
2736
|
+
column; the others size their label to its text. A form narrower than
|
|
2737
|
+
360px stacks them (a container query, `twm-form`, on a form that has a row;
|
|
2738
|
+
a form without one is not made a container). A form with a row opens at the
|
|
2739
|
+
wide (640px) layout. The row is `.twm-modal__field-group`.
|
|
2740
|
+
|
|
2741
|
+
A form with neither renders exactly as before.
|
|
2742
|
+
`tests/open_form_rows_bounds.test.mjs` covers both in jsdom.
|
|
2743
|
+
|
|
2744
|
+
### A switch: `ToggleField` and `type: 'toggle'` (0.17.0)
|
|
2745
|
+
|
|
2746
|
+
```js
|
|
2747
|
+
import { ToggleField } from '@flexdesk/widgets';
|
|
2748
|
+
|
|
2749
|
+
const autosave = new ToggleField({
|
|
2750
|
+
label: 'Autosave', onText: 'On', offText: 'Off', // all optional
|
|
2751
|
+
checked: true, compact: false, disabled: false,
|
|
2752
|
+
onChange: (on) => settings.set('autosave', on),
|
|
2753
|
+
});
|
|
2754
|
+
host.appendChild(autosave.render());
|
|
2755
|
+
autosave.get(); // true
|
|
2756
|
+
autosave.set(false); // does not call onChange
|
|
2757
|
+
autosave.setDisabled(true);
|
|
2758
|
+
autosave.dispose();
|
|
2759
|
+
```
|
|
2760
|
+
|
|
2761
|
+
A track and a knob drawn over a real checkbox with `role="switch"` and an
|
|
2762
|
+
`aria-checked` that follows it. Space and Enter toggle the focused switch, a
|
|
2763
|
+
click on the track, the state text or the label toggles it, the focused input
|
|
2764
|
+
draws a ring on the track, and a disabled switch ignores clicks and keys alike.
|
|
2765
|
+
`ariaLabel` names a switch with no visible label. The markup is
|
|
2766
|
+
`label.twm-toggle` (`--compact`, `--disabled`) > `.twm-toggle__control` >
|
|
2767
|
+
`input.twm-toggle__input` + `.twm-toggle__track` > `.twm-toggle__knob`, then
|
|
2768
|
+
`.twm-toggle__state` and `.twm-toggle__label`; `flexdesk.css` styles it from
|
|
2769
|
+
tokens alone, with no colour literal.
|
|
2770
|
+
|
|
2771
|
+
In a form, `{ name, label, type: 'toggle', onText?, offText?, disabled?,
|
|
2772
|
+
compact? }` mounts one and submits a boolean, like a checkbox; `api.get` reads
|
|
2773
|
+
it as a boolean and `api.set` moves it. `tests/toggle_field.test.mjs` covers
|
|
2774
|
+
both.
|
|
2775
|
+
|
|
2776
|
+
## A settings page: `SettingsView` (0.17.0)
|
|
2777
|
+
|
|
2778
|
+
`new SettingsView({ store, eventBus })` (in `@flexdesk/widgets`) draws a
|
|
2779
|
+
settings store's schema as a page. `store` is a `createSettingsStore()` handle
|
|
2780
|
+
or the app-global binding's own, `getSettingsStore()` from `@flexdesk/core`
|
|
2781
|
+
(its `set` is the one `setSetting` calls); `eventBus` is the bus that store
|
|
2782
|
+
emits on (`registerSettingsEventBus(bus)` for the binding).
|
|
2783
|
+
|
|
2784
|
+
```js
|
|
2785
|
+
import { getSettingsStore, registerSettingsEventBus } from '@flexdesk/core';
|
|
2786
|
+
import { SettingsView, settingsViewContent } from '@flexdesk/widgets';
|
|
2787
|
+
|
|
2788
|
+
registerSettingsEventBus(bus);
|
|
2789
|
+
const view = new SettingsView({ store: getSettingsStore(), eventBus: bus });
|
|
2790
|
+
view.mount(host);
|
|
2791
|
+
view.selectCategory('window');
|
|
2792
|
+
view.setQuery('shadow'); // what the filter box does
|
|
2793
|
+
await view.resetAll(); // asks first; true when it reset
|
|
2794
|
+
view.dispose();
|
|
2795
|
+
|
|
2796
|
+
// or as tile content (props.category picks the category shown first):
|
|
2797
|
+
createShell({ content: { settings: settingsViewContent({ store: getSettingsStore(), eventBus: bus }) } });
|
|
2798
|
+
```
|
|
2799
|
+
|
|
2800
|
+
The sidebar holds a filter box, every category in `CategoryDef.order` and
|
|
2801
|
+
*Reset all*. Beside it the chosen category's settings are listed under its
|
|
2802
|
+
title, description and *Reset category*, grouped by `SettingDef.group` in
|
|
2803
|
+
schema order. A row shows the label, a *Modified* marker while the value
|
|
2804
|
+
differs from its default, the description, the path, its control and a reset
|
|
2805
|
+
button. Text in the filter searches every category's labels, descriptions and
|
|
2806
|
+
paths; the matches are listed under "Category › Group", and choosing a
|
|
2807
|
+
category clears it.
|
|
2808
|
+
|
|
2809
|
+
| `type` | Control |
|
|
2810
|
+
| --- | --- |
|
|
2811
|
+
| `boolean` | a `ToggleField` |
|
|
2812
|
+
| `select` | a select over `options`; a value the options lack (stored, or set elsewhere while the view is open) is kept and shown as *value (not found)* |
|
|
2813
|
+
| `number` | a number box with `min`, `max` and `step`; a written value is always in the range and on the step grid (`min + k·step`, or `k·step` with no `min`): snapped to the nearest grid point, and one outside the range moved inward to the nearest grid point inside it (with `max` below `min` the range holds none, and the value is `min`); with no `step` it is only clamped; an empty or unreadable box goes back to the value |
|
|
2814
|
+
| `text` | a text box, written `textDebounceMs` (default `SETTINGS_TEXT_DEBOUNCE_MS`, 300) after the last keystroke, or at once on Enter or blur; emptied, it writes `null` when the default is `null` |
|
|
2815
|
+
| `colorList` | a `ColorListField`, written on each committed edit while every row holds a colour |
|
|
2816
|
+
|
|
2817
|
+
A type it does not know shows the value as text, read only.
|
|
2818
|
+
|
|
2819
|
+
The view is live both ways: a control writes with `store.set`, and the view
|
|
2820
|
+
listens for `settings:<path>:changed` on every schema path, `settings:changed`
|
|
2821
|
+
with a null path (the resets) and `settings:registered`, which the store now
|
|
2822
|
+
emits from `extend()` / `registerSettings()` with `{ paths, categories }`. So a
|
|
2823
|
+
`setSetting` made anywhere moves the open view, and a slice registered after
|
|
2824
|
+
mount adds its categories and rows without a remount. A row's reset button
|
|
2825
|
+
resets that one setting; *Reset category* and *Reset all* first ask
|
|
2826
|
+
`confirm({ title, message, confirmLabel, danger })` (default `openConfirm`) and
|
|
2827
|
+
change nothing unless it resolves true. Every label, description, category name
|
|
2828
|
+
and query is set as text, so markup in any of them is shown as typed.
|
|
2829
|
+
|
|
2830
|
+
Options: `store`, `eventBus` (both required), `confirm`, `category`,
|
|
2831
|
+
`textDebounceMs`, `title` (the sidebar heading, and the content factory's tab
|
|
2832
|
+
title; default "Settings"). The markup is `.twm-settings` >
|
|
2833
|
+
`.twm-settings__sidebar` + `.twm-settings__content`, with rows
|
|
2834
|
+
`.twm-settings__row` (`--modified`); `flexdesk.css` styles it from tokens.
|
|
2835
|
+
`tests/settings_view.test.mjs` covers it in jsdom.
|
|
2836
|
+
|
|
2837
|
+
## A step wizard: `Wizard` (0.17.0)
|
|
2838
|
+
|
|
2839
|
+
`new Wizard({ steps, data, onFinish, onCancel, labels })` (in `@flexdesk/widgets`)
|
|
2840
|
+
draws a multi-step flow: a step indicator, a body slot and a footer with Cancel,
|
|
2841
|
+
Back and Next (Finish on the last step). Mount it inline with `mount(el)`, or in a
|
|
2842
|
+
modal `ManagedWindow` with `openInWindow({ id, title, ... })`.
|
|
2843
|
+
|
|
2844
|
+
```js
|
|
2845
|
+
const wizard = new Wizard({
|
|
2846
|
+
data: { file: null },
|
|
2847
|
+
steps: [
|
|
2848
|
+
{ id: 'source', label: 'Source', icon: 'upload_file',
|
|
2849
|
+
render(bodyEl, ctx) { // once per visit; may return a cleanup function
|
|
2850
|
+
const input = picker(bodyEl);
|
|
2851
|
+
input.onchange = () => { ctx.data.file = input.files[0]; ctx.revalidate(); };
|
|
2852
|
+
},
|
|
2853
|
+
canAdvance: (ctx) => ctx.data.file
|
|
2854
|
+
? { ok: true } : { ok: false, hint: 'Choose a file first' } },
|
|
2855
|
+
{ id: 'review', label: 'Review', render: (bodyEl, ctx) => review(bodyEl, ctx.data) },
|
|
2856
|
+
],
|
|
2857
|
+
onFinish: async (ctx) => api.import(ctx.data), // a rejection marks the step as error
|
|
2858
|
+
onCancel: () => {},
|
|
2859
|
+
});
|
|
2860
|
+
wizard.openInWindow({ id: 'import', title: 'Import' });
|
|
2861
|
+
```
|
|
2862
|
+
|
|
2863
|
+
- **Gating.** While the current step's `canAdvance(ctx)` answers `{ok: false, hint}`,
|
|
2864
|
+
Next is disabled and the hint is shown in the footer. `revalidate()` (also
|
|
2865
|
+
`ctx.revalidate()`) asks again and redraws the indicator and the footer; it never
|
|
2866
|
+
re-renders the body, so an input keeps its focus and its value.
|
|
2867
|
+
- **Steps** are drawn done / current / upcoming / error, joined by a connector. A
|
|
2868
|
+
done step is a button that jumps back to it; an upcoming step is inert, so the
|
|
2869
|
+
only way forward is Next.
|
|
2870
|
+
- **Moves.** `next()`, `back()` and `goTo(idOrIndex)` (back to any earlier step, or
|
|
2871
|
+
forward one step through the gate) resolve `true` when they moved.
|
|
2872
|
+
`setStepState(id, 'error' | null, message)` marks or clears a step.
|
|
2873
|
+
- **Finish.** On the last step Next reads Finish and calls `onFinish(ctx)`. Until
|
|
2874
|
+
it settles every button is disabled and moves, cancels and closes are refused,
|
|
2875
|
+
not queued. Resolved, the wizard closes; rejected, the step is marked as error
|
|
2876
|
+
and the rejection's message is shown, and Finish can be pressed again.
|
|
2877
|
+
- **Cancel** (the button, `cancel()`, or the window's close button and Escape)
|
|
2878
|
+
closes the wizard and calls `onCancel(ctx)` exactly once. `dispose()` tears it
|
|
2879
|
+
down without calling either callback.
|
|
2880
|
+
|
|
2881
|
+
Every transition goes through a core `StateMachine` (`step`, `finishing`,
|
|
2882
|
+
`finished`, `cancelled`; pass `eventBus` to see its `fsm:*` events). `ctx` is
|
|
2883
|
+
`{data, step, index, revalidate, next, back, goTo, setStepState}`, with `data`
|
|
2884
|
+
the object you passed.
|
|
2885
|
+
|
|
2886
|
+
## Confirms show text, not markup (0.17.0)
|
|
2887
|
+
|
|
2888
|
+
`openConfirm({ title, message, confirmLabel, cancelLabel, danger, icon })`
|
|
2889
|
+
resolves `true` or `false`. Its `message` and both labels are **text**: they
|
|
2890
|
+
are set with `textContent`, so a `<` shows as a `<`. Until 0.17.0 they went into
|
|
2891
|
+
`innerHTML`, and `showDeleteConfirmDialog` / `showCloseConfirmDialog` put
|
|
2892
|
+
`itemName` and `itemType` into that message raw: a flow named
|
|
2893
|
+
`<img src=x onerror=…>` rendered an image and ran its handler, and a name with
|
|
2894
|
+
an unclosed `<` swallowed the rest of the message.
|
|
2895
|
+
|
|
2896
|
+
A caller that passed markup in `message` now sees the tags; write a `\n` for a
|
|
2897
|
+
line break instead (the message body is `white-space: pre-line`).
|
|
2898
|
+
`additionalMessage` on `showDeleteConfirmDialog` follows on its own paragraph
|
|
2899
|
+
exactly as before. A body that needs real markup is `openModal`'s job: it takes
|
|
2900
|
+
the caller's own DOM.
|
|
2901
|
+
|
|
2902
|
+
`tests/confirm_escaping.test.mjs` opens each of them in jsdom with hostile
|
|
2903
|
+
names and labels.
|
|
2904
|
+
|
|
2905
|
+
## Markdown: `renderMarkdown` (0.17.0)
|
|
2906
|
+
|
|
2907
|
+
`renderMarkdown(source, options)` (`@flexdesk/widgets`) returns an HTML
|
|
2908
|
+
string. It covers a CommonMark-ish subset — ATX headings, paragraphs,
|
|
2909
|
+
emphasis, strong, `~~strike~~`, code spans, fenced code, block quotes, nested
|
|
2910
|
+
ordered and unordered lists, GFM pipe tables, rules, links, images, autolinks,
|
|
2911
|
+
hard breaks and `$…$` / `$$…$$` math — and is **safe by default**:
|
|
2912
|
+
|
|
2913
|
+
- raw HTML is escaped: `<img src=x onerror=alert(1)>` shows as that text.
|
|
2914
|
+
`allowHtml: ['kbd', 'br']` lets named tags through, the tag only and never
|
|
2915
|
+
its attributes; a tag that runs or embeds anything (`script`, `iframe`,
|
|
2916
|
+
`svg`, `img`, …) throws. An allowed tag left open is closed at the end of its
|
|
2917
|
+
paragraph.
|
|
2918
|
+
- a link goes out only to `http(s):`, `mailto:` or a `#anchor`, always with
|
|
2919
|
+
`rel="noopener noreferrer"`; a `javascript:` link (or any other) renders its
|
|
2920
|
+
text and no anchor. An image loads from `http(s):` or a relative path, or
|
|
2921
|
+
shows its alt text.
|
|
2922
|
+
|
|
2923
|
+
```js
|
|
2924
|
+
import { renderMarkdown } from '@flexdesk/widgets';
|
|
2925
|
+
|
|
2926
|
+
el.innerHTML = renderMarkdown(source, {
|
|
2927
|
+
highlight: (code, lang) => myHighlighter(code, lang), // null: escaped
|
|
2928
|
+
math: undefined, // default: window.katex when present, else the escaped source
|
|
2929
|
+
// false: `$` is text; or (tex, { display }) => html | null
|
|
2930
|
+
inlineRules: [{ pattern: /\[\[ref:([\w-]+)\]\]/, render: (m, ctx) => `<a href="#${ctx.escape(m[1])}">…</a>` }],
|
|
2931
|
+
blockRules: [{ start: /^:::(\w+)/, end: /^:::$/, render: ({ lines, match, closed }, ctx) => '…' }],
|
|
2932
|
+
inline: false, // true: inline markup only, no blocks, no <p>
|
|
2933
|
+
});
|
|
2934
|
+
```
|
|
2935
|
+
|
|
2936
|
+
What the caller returns — the highlighter's, the math hook's and the rules'
|
|
2937
|
+
HTML — is trusted and goes in as it is; a rule answering `null` declines.
|
|
2938
|
+
`ctx` gives a rule `escape(text)` and `inline(text)`. Not in the subset:
|
|
2939
|
+
setext headings, indented code blocks, reference links, raw HTML blocks.
|
|
2940
|
+
|
|
2941
|
+
**Streaming.** Every element it opens it closes, so each prefix of a document
|
|
2942
|
+
arriving token by token renders to balanced HTML: an open fence is closed at
|
|
2943
|
+
the end of the input, and an emphasis or link whose closer has not arrived is
|
|
2944
|
+
still text. `tests/markdown.test.mjs` renders every prefix of a document using
|
|
2945
|
+
every construct and checks the tags balance; `tests/help_modal_markdown.test.mjs`
|
|
2946
|
+
checks jsdom has nothing to repair.
|
|
2947
|
+
|
|
2948
|
+
**Help topics in markdown.** A `HelpProvider`'s `loadTopic` may now answer
|
|
2949
|
+
`{ title, markdown }`; the modal renders it with `renderMarkdown` and the
|
|
2950
|
+
provider's optional `markdownOptions`. `{ title, html }` is unchanged:
|
|
2951
|
+
pre-rendered, **trusted** HTML, inserted as it is.
|
|
2952
|
+
|
|
2953
|
+
**A table of contents for a help topic.** A topic with two or more headings,
|
|
2954
|
+
markdown or html, gets its contents between the breadcrumb and the article: a
|
|
2955
|
+
`createDocumentOutline` (below) of its `h1`–`h6`, numbered, the one in view
|
|
2956
|
+
marked as the content pane scrolls, a click that scrolls to its heading. The
|
|
2957
|
+
outline points at the heading elements themselves, so your markup needs no
|
|
2958
|
+
ids and the modal adds none; a topic with one heading or none shows no
|
|
2959
|
+
contents (the empty `.help-content__toc` is `hidden`). Until now the modal's
|
|
2960
|
+
documentation promised this and drew nothing. `tests/help_modal_toc.test.mjs`
|
|
2961
|
+
drives it in jsdom (BUG-0010).
|
|
2962
|
+
|
|
2963
|
+
## A document outline: `createDocumentOutline` (0.17.0)
|
|
2964
|
+
|
|
2965
|
+
A long document's sections, listed and numbered, the one in view marked while
|
|
2966
|
+
the document scrolls, and a click that goes to one (in `@flexdesk/widgets`):
|
|
2967
|
+
|
|
2968
|
+
```js
|
|
2969
|
+
const outline = createDocumentOutline(sidebarEl, {
|
|
2970
|
+
entries: [{ id: 'intro', level: 1, label: 'Introduction' },
|
|
2971
|
+
{ id: 'scope', level: 2, label: 'Scope' }],
|
|
2972
|
+
numbering: true, // 1, 1.1, 1.2, 2 …
|
|
2973
|
+
maxDepth: 3, // deeper entries are drawn at depth 3
|
|
2974
|
+
scrollContainer: articleEl, // the element that scrolls; the spy's root
|
|
2975
|
+
resolveTarget: (entry, container) => container.querySelector(`[data-section="${entry.id}"]`),
|
|
2976
|
+
rootMargin: '0px 0px -70% 0px', // the default: the top 30% is "in view"
|
|
2977
|
+
onSelect: (id, entry) => { /* … */ },
|
|
2978
|
+
emptyState: 'No headings',
|
|
2979
|
+
});
|
|
2980
|
+
outline.setEntries(next); // the document has other sections now
|
|
2981
|
+
outline.refresh(); // the same sections, re-rendered: resolve again
|
|
2982
|
+
outline.setActive('scope'); outline.getActive();
|
|
2983
|
+
const off = outline.onActiveChange((id, entry) => { /* … */ });
|
|
2984
|
+
outline.dispose();
|
|
2985
|
+
```
|
|
2986
|
+
|
|
2987
|
+
Where the entries come from is yours: the outline is told `{id, level, label}`
|
|
2988
|
+
and, through `resolveTarget` (by default the element in the container whose
|
|
2989
|
+
`id` is the entry's), which element each one stands for. Labels are text.
|
|
2990
|
+
Numbers are hierarchical, and a skipped level is one step deeper, not two:
|
|
2991
|
+
levels 1, 3, 3, 2 are 1, 1.1, 1.2, 1.3.
|
|
2992
|
+
|
|
2993
|
+
Of the targets an IntersectionObserver rooted at `scrollContainer` reports in
|
|
2994
|
+
view, the one listed first is active; with none in view the last one stays. An
|
|
2995
|
+
entry whose target does not resolve is listed, but never watched and never
|
|
2996
|
+
active, and `setActive` refuses it (`false`). A click marks its entry at once,
|
|
2997
|
+
scrolls its target into view (`scrollOnSelect: false` leaves that to
|
|
2998
|
+
`onSelect`) and suppresses the spy while the scroll passes the sections in
|
|
2999
|
+
between, until the container's `scrollend` or, without one, `scrollEndTimeout`
|
|
3000
|
+
(1000 ms). `dispose()` disconnects the observer and clears that timer. The
|
|
3001
|
+
indent per level is `--twm-doc-outline-indent` (12px).
|
|
3002
|
+
|
|
3003
|
+
`tests/document_outline.test.mjs` drives it in jsdom with a hand-fired
|
|
3004
|
+
observer.
|
|
3005
|
+
|
|
3006
|
+
## Action dropdowns: separators (0.17.0)
|
|
3007
|
+
|
|
3008
|
+
`new ActionDropdown({ trigger, options, onSelect })` draws one button per
|
|
3009
|
+
option. An entry `{ separator: true }` in `options` draws a divider instead: a
|
|
3010
|
+
`<div role="separator" class="twm-action-dropdown-separator">` that takes no
|
|
3011
|
+
focus, that ArrowUp/ArrowDown pass over, and that never reaches `onSelect`.
|
|
3012
|
+
A separator only separates: one at the start or end of the list, or right
|
|
3013
|
+
after another, is dropped, so groups that come out empty leave no stray or
|
|
3014
|
+
doubled rule. `setOptions` follows the same rules, and an option's
|
|
3015
|
+
`data-index` stays its index in `options`.
|
|
3016
|
+
|
|
3017
|
+
`tests/action_dropdown_separators.test.mjs` drives it in jsdom.
|
|
3018
|
+
|
|
3019
|
+
## A list of tags: `createTagInput` (0.17.0)
|
|
3020
|
+
|
|
3021
|
+
`createTagInput(host, { value, placeholder, readOnly, allowDuplicates, onChange })`
|
|
3022
|
+
(in `@flexdesk/widgets`) draws a list of short strings as removable chips
|
|
3023
|
+
followed by a text box, and appends it to `host`.
|
|
3024
|
+
|
|
3025
|
+
```js
|
|
3026
|
+
const tags = createTagInput(host, {
|
|
3027
|
+
value: ['baseline'],
|
|
3028
|
+
placeholder: 'Add a tag…',
|
|
3029
|
+
onChange: (list) => save(list), // a fresh copy after each change the reader makes
|
|
3030
|
+
});
|
|
3031
|
+
tags.getValue(); // ['baseline']
|
|
3032
|
+
tags.setValue(['a', 'b']); // redraws; does not call onChange
|
|
3033
|
+
tags.dispose();
|
|
3034
|
+
```
|
|
3035
|
+
|
|
3036
|
+
- **Enter**, a **comma** and **blur** commit what was typed: it is split at
|
|
3037
|
+
commas and each piece trimmed, so `a, b` then Enter adds two chips and
|
|
3038
|
+
`onChange` receives `['a', 'b']`.
|
|
3039
|
+
- Empty pieces are ignored, and so is a piece already in the list unless
|
|
3040
|
+
`allowDuplicates` is set. A commit that adds nothing calls no `onChange`.
|
|
3041
|
+
- **Backspace** in an empty box removes the last chip.
|
|
3042
|
+
- A **paste** that carries a comma is split into chips at once; a paste
|
|
3043
|
+
without one is ordinary text in the box.
|
|
3044
|
+
- Each chip's remove button has an `aria-label`, "Remove <tag>" by default;
|
|
3045
|
+
`removeLabel(tag)` words it (for another language). `label` names the text
|
|
3046
|
+
box and the group.
|
|
3047
|
+
- `readOnly` draws the chips alone: no remove buttons and no text box.
|
|
3048
|
+
|
|
3049
|
+
The handle is `{ element, input, getValue, setValue, focus, dispose }`
|
|
3050
|
+
(`input` is `null` when read-only). The styles are the `twm-tag-input` block in
|
|
3051
|
+
`flexdesk.css`. `tests/tag_input.test.mjs` covers each of the above in jsdom.
|
|
3052
|
+
|
|
3053
|
+
## Progress: bars, lists and phases (0.17.0)
|
|
3054
|
+
|
|
3055
|
+
Three classes in `widgets`, each building its own element (`.element`, appended
|
|
3056
|
+
to `host` when one is given) and changing it **in place**: an update twenty
|
|
3057
|
+
times a second gets the same nodes back every time, so hover, tooltips and the
|
|
3058
|
+
fill's transition survive. Names and labels are text.
|
|
3059
|
+
|
|
3060
|
+
| Class | Does |
|
|
3061
|
+
|---|---|
|
|
3062
|
+
| `new ProgressBar(host, { value, max, indeterminate, label, showPercent, showCount, showElapsed, onCancel })` | `update(0.42)` (a fraction) or `update(21)` with `max` (a count, read "42% · 21 / 50"); `update({ value, max, label, indeterminate })`; `setState('done' \| 'error' \| 'cancelled')` ends it and stops the elapsed clock, `setState('running')` resumes it; `fraction`, `state`, `elapsed` |
|
|
3063
|
+
| `new ProgressList(host, { items, stats, pulse, emptyText })` | Keyed rows `{ key, name, completed, total?, state, detail? }`, `state` one of `pending`, `running`, `done`, `failed`. `setItems` reconciles by key: a known key keeps its row. A row without `total` is drawn against the largest item. `stats: true` writes a summary line, a function `(items) => string` writes yours, `false` none. Running rows pulse |
|
|
3064
|
+
| `new StepProgress(host, { steps })` | Ordered phases (`{ key, label }` or a label). `setStep(keyOrIndex, { pass, passes })` makes one active, those before it done and those after pending, and reads "pass 2 of 5"; `fail()` marks the active one error; `complete()` |
|
|
3065
|
+
|
|
3066
|
+
```js
|
|
3067
|
+
const list = new ProgressList(panel, { items: [] });
|
|
3068
|
+
job.on('tick', (workers) => list.setItems(workers.map((w) => ({
|
|
3069
|
+
key: w.id, name: w.label, completed: w.done, total: w.total, state: w.state }))));
|
|
3070
|
+
```
|
|
3071
|
+
|
|
3072
|
+
`showRunProgressDialog` and `showComputingWindow` take `progress` (`true`, or
|
|
3073
|
+
ProgressBar options) and `steps`, fixed when the dialog opens. Their handles
|
|
3074
|
+
gain `setProgress(next)` and `setStep(which, opts)`, which throw when the dialog
|
|
3075
|
+
was opened without `progress` or `steps` respectively, and **`result`**, a
|
|
3076
|
+
Promise that settles once with
|
|
3077
|
+
`{ status, message }`: `complete({ ok })` gives `done` or `error` (the bar and
|
|
3078
|
+
the phases show it), `cancelled()` gives `cancelled`, and so does **closing the
|
|
3079
|
+
dialog while it runs** (by its ×, Escape, `close()`, or for `showComputingWindow`
|
|
3080
|
+
opening another). Closing never calls `onCancel`; only the Stop / Cancel button
|
|
3081
|
+
does, as before. `showComputingWindow` also gains `complete` and `cancelled`,
|
|
3082
|
+
after which it shows a Close button.
|
|
3083
|
+
|
|
3084
|
+
```js
|
|
3085
|
+
const dlg = showRunProgressDialog({ title: 'Export', steps: ['Prepare', 'Render', 'Write'],
|
|
3086
|
+
progress: { showElapsed: true }, onCancel: () => job.stop() });
|
|
3087
|
+
dlg.setStep(1); dlg.setProgress(0.4);
|
|
3088
|
+
const { status } = await dlg.result; // 'done' | 'error' | 'cancelled'
|
|
3089
|
+
```
|
|
3090
|
+
|
|
3091
|
+
`tests/progress.test.mjs` holds all of it in jsdom.
|
|
3092
|
+
|
|
3093
|
+
## Tabs: `createTabs` (0.17.0)
|
|
3094
|
+
|
|
3095
|
+
One tab strip, from `@flexdesk/widgets`, keyed by whatever ids you give it — not
|
|
3096
|
+
by file path (`NotebookTabBar`) and not by a bus event (`createBottomPanelTabs`,
|
|
3097
|
+
which is now built on it). It is a strip and nothing else, or a strip with the
|
|
3098
|
+
panes behind it.
|
|
3099
|
+
|
|
3100
|
+
```js
|
|
3101
|
+
const tabs = createTabs(el, {
|
|
3102
|
+
items: [
|
|
3103
|
+
{ id: 'inputs', label: 'Inputs', icon: 'tune', title: 'Model inputs' },
|
|
3104
|
+
{ id: 'results', label: 'Results', badge: { count: 3, level: 'warning' } },
|
|
3105
|
+
{ id: 'draft', label: 'Draft', dirty: true, closable: true },
|
|
3106
|
+
],
|
|
3107
|
+
active: 'inputs',
|
|
3108
|
+
onSelect: (id, item) => …,
|
|
3109
|
+
onReselect: (id, item) => …, // optional: a click on the tab in front
|
|
3110
|
+
onClose: (id) => tabs.update(items.filter((i) => i.id !== id)), // optional
|
|
3111
|
+
onReorder: (ids) => …, // optional: tabs drag
|
|
3112
|
+
onContextMenu: (id, event, item) => …, // optional
|
|
3113
|
+
mountPane: (item, paneEl, tabs) => ({ onShow() {}, destroy() {} }), // optional: strip only
|
|
3114
|
+
compact: false,
|
|
3115
|
+
label: 'Views',
|
|
3116
|
+
});
|
|
3117
|
+
tabs.update(nextItems, 'results');
|
|
3118
|
+
tabs.setBadge('results', 7, 'alert');
|
|
3119
|
+
tabs.setIndicator('inputs', 'info');
|
|
3120
|
+
```
|
|
3121
|
+
|
|
3122
|
+
| Member | Does |
|
|
3123
|
+
|---|---|
|
|
3124
|
+
| `update(items, activeId?)` | Replaces the items, keeping the button of every id it keeps. In front: `activeId` if present, else the tab in front now if it is still there, else the first (none when no items are left). Panes of items that went are destroyed |
|
|
3125
|
+
| `select(id)`, `active()`, `has(id)`, `ids()` | The tab in front; `select` answers false for an id it does not have |
|
|
3126
|
+
| `setBadge(id, n, level?)` | A count, `99+` above 99, none at zero; `level` is `info`, `warning` or `alert` (`TAB_LEVELS`) |
|
|
3127
|
+
| `setIndicator(id, value)` | A dot: a level, `true` for the neutral one, falsy for none |
|
|
3128
|
+
| `instance(id)`, `instances()` | The mounted panes' instances |
|
|
3129
|
+
| `destroy()` | Destroys the panes, lets the bus go, removes the markup |
|
|
3130
|
+
|
|
3131
|
+
- **Panes mount on first selection.** The tab in front at creation counts;
|
|
3132
|
+
`eager: true` on an item mounts its pane at once — at creation, or in the
|
|
3133
|
+
`update` that brings the item in, before the tab in front is settled. Each
|
|
3134
|
+
instance hears `onShow()` whenever its tab comes to the front and `destroy()`
|
|
3135
|
+
when its item goes.
|
|
3136
|
+
- **`onSelect` hears changes**, not the tab chosen at creation. A click on the
|
|
3137
|
+
tab already in front is not a change; `onReselect(id, item)` hears it. An
|
|
3138
|
+
`update` that leaves no items empties the front, heard as `onSelect(null, null)`.
|
|
3139
|
+
- **The bus is opt-in:** pass `eventBus` *and* `selectEvent: 'views:show'` and
|
|
3140
|
+
that event selects `payload.id` (or `selectFrom(payload)`). No event name, no
|
|
3141
|
+
subscription.
|
|
3142
|
+
- **Keys** follow the ARIA tabs pattern with automatic activation:
|
|
3143
|
+
ArrowLeft/ArrowRight (wrapping), Home, End; Delete (and a middle click) closes
|
|
3144
|
+
a closable tab. Only the tab in front is in the tab order. When an `update`
|
|
3145
|
+
removes the tab that has focus (Delete, then your `onClose` drops it), focus
|
|
3146
|
+
moves to the tab now in front instead of falling to the page. Focus that the
|
|
3147
|
+
update put somewhere else (a pane that focuses itself in `onShow`) stays there.
|
|
3148
|
+
- **Overflow scrolls**, never wraps: a vertical wheel moves the strip sideways and
|
|
3149
|
+
the tab coming to the front is scrolled into view (`revealInStrip`,
|
|
3150
|
+
`wheelStripSideways`, shared with `NotebookTabBar`).
|
|
3151
|
+
- **Classes** are BEM under `block` (default `twm-tabs`): `__tabs`, `__tab`,
|
|
3152
|
+
`__tab--on`, `__tab--dirty`, `__tab--closable` (it has a close mark), `__icon`,
|
|
3153
|
+
`__label`, `__badge`, `__indicator`, `__dirty`, `__close`, `__body`,
|
|
3154
|
+
`--compact`, and `twm-tabs-pane`. A dirty tab shows its dot until hovered,
|
|
3155
|
+
then its close mark; one with no close mark keeps the dot. Tabs and panes
|
|
3156
|
+
carry the id in `data-tab-id` (`dataKey`), as a string; callbacks and `ids()`
|
|
3157
|
+
hand back the id as you gave it, so a numeric id stays a number.
|
|
3158
|
+
|
|
3159
|
+
`tests/tabs.test.mjs` covers it in jsdom; `tests/bottom_panel_tabs.test.mjs`
|
|
3160
|
+
runs on the bottom panel built over it. The bottom panel behaves as it did:
|
|
3161
|
+
`api.root` is set inside a pane's `mount`, and a click on the tab in front
|
|
3162
|
+
re-shows its pane, fires `onSelect` and is remembered, as `select` does. A
|
|
3163
|
+
remembered tab that is already in front when `remember.read` answers is shown
|
|
3164
|
+
again: its pane hears `onShow` and `onSelect` fires, and nothing is written back.
|
|
3165
|
+
|
|
1669
3166
|
## Notifications: `NotificationCenter`
|
|
1670
3167
|
|
|
1671
3168
|
Toasts, from `toast:show` on the bus (or `toastInfo` / `toastWarn` /
|
|
@@ -1687,6 +3184,51 @@ const nc = new NotificationCenter({ eventBus, maxVisible: 3, overflow: 'collapse
|
|
|
1687
3184
|
nc.mount(document.body);
|
|
1688
3185
|
```
|
|
1689
3186
|
|
|
3187
|
+
*New in 0.17.0:* **the toast helpers read no global.** `toastInfo` / `toastWarn` /
|
|
3188
|
+
`toastError` / `toastSuccess` emit on the bus of the `NotificationCenter` mounted
|
|
3189
|
+
last; its `dispose()` hands them back. `setToastBus(bus)` points them at another
|
|
3190
|
+
bus (and returns the one it replaced), `setToastBus(null)` back at the console,
|
|
3191
|
+
and `getToastBus()` says which is current. With no bus — before any centre is
|
|
3192
|
+
mounted — a helper logs to the console and throws nothing. Until 0.17.0 they
|
|
3193
|
+
looked for one application's bus on a window global.
|
|
3194
|
+
|
|
3195
|
+
### Progress toasts (0.17.0)
|
|
3196
|
+
|
|
3197
|
+
A toast can track a job. `show({ progress })` takes a 0..1 fraction,
|
|
3198
|
+
`{ value, total }`, or `null` for indeterminate, and draws a `ProgressBar` in
|
|
3199
|
+
the toast; `status` is a line under the message and `counts` a label beside the
|
|
3200
|
+
bar. A progress toast is persistent, has a collapse toggle (or `collapsed: true`),
|
|
3201
|
+
and is not closed by a click on its body or on one of its actions while it runs.
|
|
3202
|
+
|
|
3203
|
+
`update(id, patch)` changes only that toast's nodes: `progress`, `status`,
|
|
3204
|
+
`counts`, `collapsed`, `message`, `title`, `description`, `severity`. No other
|
|
3205
|
+
toast is redrawn, by an update or by toasts arriving and leaving, so a bar, a
|
|
3206
|
+
collapsed state and a hover survive (a toast whose title or description appears
|
|
3207
|
+
or goes is rebuilt in its place). `update()` also reaches a toast held behind
|
|
3208
|
+
"+N more", and `progress` on a plain toast turns it into a running progress
|
|
3209
|
+
toast. The toast **runs until a patch gives it an outcome severity**: `success`
|
|
3210
|
+
ends the bar done, `error` / `fatal` error; any other severity only restyles it.
|
|
3211
|
+
Once finished the bar keeps that state, whatever severity comes later. A
|
|
3212
|
+
`durationMs` in a patch to a toast that is not running (re)starts its
|
|
3213
|
+
auto-dismiss, and `0` keeps it until closed.
|
|
3214
|
+
|
|
3215
|
+
With `overflow: 'collapse'`, a running progress toast is never held behind
|
|
3216
|
+
"+N more" and never counted in it (a held toast that starts running through
|
|
3217
|
+
`update()` comes on screen at once). It counts toward `maxVisible`, so when the
|
|
3218
|
+
screen is full the newest other toast steps back into the queue (first in line
|
|
3219
|
+
to return). Once finished it folds like any other toast. With `'drop'`, the
|
|
3220
|
+
oldest toast that is not a running job leaves.
|
|
3221
|
+
|
|
3222
|
+
```js
|
|
3223
|
+
const id = nc.show({ title: 'Batch', message: 'Running', progress: 0, counts: '0 / 200',
|
|
3224
|
+
actions: [{ label: 'Stop', handler: () => job.stop() }] });
|
|
3225
|
+
job.on('tick', (done) => nc.update(id, { progress: { value: done, total: 200 },
|
|
3226
|
+
counts: `${done} / 200`, status: job.current }));
|
|
3227
|
+
job.on('end', () => nc.update(id, { progress: 1, severity: 'success', message: 'Done', durationMs: 3000 }));
|
|
3228
|
+
```
|
|
3229
|
+
|
|
3230
|
+
`tests/notification_progress.test.mjs` holds it in jsdom.
|
|
3231
|
+
|
|
1690
3232
|
### Alerts with history: a log, a pane, a tab host and a bell (0.14.0)
|
|
1691
3233
|
|
|
1692
3234
|
A toast is the moment. The **alert log** keeps what came in until it is
|
|
@@ -1697,7 +3239,7 @@ dismissed, and four pieces draw it. Each works without the others:
|
|
|
1697
3239
|
| `createAlertLog({ source, limit })` | `widgets` | Entries `{id, at, level, title, body?, ref?, read}`, newest first; `add`, `receive`, `markRead`, `dismiss`, `unreadCount`, `worstUnread`, `subscribe`, `loadMore` |
|
|
1698
3240
|
| `localSource({ read, write })` | `widgets` | Keeps the whole log in one stored value (localStorage, a workspace file) |
|
|
1699
3241
|
| `mountAlertsPane(el, { log, onOpen, onBadge, rules?, stillMatches? })` | `widgets` | Level chips with counts, a filter, day groups, relative times, Open / Dismiss / Mark all read / Clear, *Load older* |
|
|
1700
|
-
| `createBottomPanelTabs(el, { panes, remember, store, eventBus })` | `wm` | The `.twm-bp` tab strip: `select`, `setBadge(id, n, level)`, `bottompanel:show {tab}` |
|
|
3242
|
+
| `createBottomPanelTabs(el, { panes, remember, store, eventBus })` | `wm` | The `.twm-bp` tab strip, on [`createTabs`](#tabs-createtabs-0170): `select`, `setBadge(id, n, level)`, `bottompanel:show {tab}` |
|
|
1701
3243
|
| `new NotificationHistory({ alertLog, mountPoint, alertsTab, onOpen })` | `widgets` | The bell shows the log's unread count in the worst level's colour; a click (and "+N more") opens the Alerts tab |
|
|
1702
3244
|
|
|
1703
3245
|
A **source** is where entries live: `load({offset, limit})` answers
|
|
@@ -1724,6 +3266,73 @@ before it returns. Reach the host through `mount`'s `api` argument, not the
|
|
|
1724
3266
|
variable being assigned. Without `alertLog` and `mountPoint`, `NotificationHistory`
|
|
1725
3267
|
is the bell it was.
|
|
1726
3268
|
|
|
3269
|
+
## A conversation: `mountConversationView` (0.17.0)
|
|
3270
|
+
|
|
3271
|
+
A transcript of role-tagged messages above a composer, in `@flexdesk/widgets`.
|
|
3272
|
+
It has no markdown and no model in it: you draw a message's body, and it tells
|
|
3273
|
+
you when the user submits or cancels.
|
|
3274
|
+
|
|
3275
|
+
```js
|
|
3276
|
+
const view = mountConversationView(el, {
|
|
3277
|
+
render: (message, bodyEl, { streaming }) => { bodyEl.innerHTML = toHtml(message.text); },
|
|
3278
|
+
onSubmit: (text) => { view.append({ role: 'user', text }); view.setBusy(true); ask(text); },
|
|
3279
|
+
onCancel: () => abort(),
|
|
3280
|
+
placeholder: 'Ask anything',
|
|
3281
|
+
});
|
|
3282
|
+
view.beginStream({ role: 'assistant' });
|
|
3283
|
+
for await (const chunk of reply) view.appendChunk(chunk); // re-renders this message only
|
|
3284
|
+
view.endStream();
|
|
3285
|
+
view.setBusy(false);
|
|
3286
|
+
```
|
|
3287
|
+
|
|
3288
|
+
| Method | Does |
|
|
3289
|
+
|---|---|
|
|
3290
|
+
| `append({ id?, role, text, meta? })` → id | Adds a message; `role` becomes `twm-chat__msg--<role>` |
|
|
3291
|
+
| `replaceAll(messages)`, `clear()` | Restores a transcript (and scrolls to its end), or empties it |
|
|
3292
|
+
| `beginStream(message?)` → id, `appendChunk(text)`, `endStream()` | A message that grows; each chunk re-renders that message's body and nothing else |
|
|
3293
|
+
| `slot(id)` | The element below a message's body, yours to fill (tool output, buttons); a re-render leaves it alone |
|
|
3294
|
+
| `messages()`, `streamingId()` | Copies of the messages; the one streaming |
|
|
3295
|
+
| `getScrollTop()`, `setScrollTop(px)`, `scrollToBottom()`, `isPinned()` | The transcript's scroll position, to keep across a remount |
|
|
3296
|
+
| `setBusy(bool)`, `isBusy()` | `true` turns Send into Cancel |
|
|
3297
|
+
| `getText()`, `setText(text)`, `focus()`, `destroy()` | The composer, and teardown |
|
|
3298
|
+
|
|
3299
|
+
- **`render(message, bodyEl, { streaming })`** fills an emptied body. One that
|
|
3300
|
+
throws leaves that message as its plain text and the view carries on; the
|
|
3301
|
+
error goes to `onRenderError(error, message)`. Without `render`, every body
|
|
3302
|
+
is plain text (`textContent`, never markup).
|
|
3303
|
+
- **Pinned to the bottom.** The transcript starts out following new content.
|
|
3304
|
+
Any scroll up, even a few px from the end (the first frame of a smooth wheel
|
|
3305
|
+
or arrow-key scroll), stops that and shows *Jump to latest*, and later chunks
|
|
3306
|
+
leave the reader where they are. It follows again only after a scroll down
|
|
3307
|
+
that ends within `pinThreshold` px (32) of the end, a click on *Jump to
|
|
3308
|
+
latest*, `scrollToBottom()`, `replaceAll()` / `clear()`, or a
|
|
3309
|
+
`setScrollTop(px)` that lands within the threshold. Content growing below
|
|
3310
|
+
never unpins it, nor does content shrinking under it (the browser moves
|
|
3311
|
+
`scrollTop` back to the new end, which is not the reader scrolling). A wheel
|
|
3312
|
+
over a scroller nested in a body or a slot (a `<pre>`, a tool-output box)
|
|
3313
|
+
scrolls that scroller (or, at its top with `overscroll-behavior: contain`,
|
|
3314
|
+
keeps the wheel), and a wheel a handler in the transcript cancels, a
|
|
3315
|
+
Ctrl+wheel (page zoom, or a trackpad pinch) and a wheel up over a transcript
|
|
3316
|
+
already at its top move nothing, so in all these cases the transcript keeps
|
|
3317
|
+
following.
|
|
3318
|
+
- **The composer** grows with its text up to `composerMaxHeight` (200 px).
|
|
3319
|
+
Enter submits and clears it (blank text is ignored, and so is Enter during
|
|
3320
|
+
IME composition); Shift+Enter is left to the browser, which inserts the
|
|
3321
|
+
newline (so undo and `input` listeners see it). While busy, Enter submits
|
|
3322
|
+
nothing, and Esc (wherever focus is inside the view) or a click on Cancel
|
|
3323
|
+
calls `onCancel` once per busy period. That Esc goes no further
|
|
3324
|
+
(`stopPropagation`), so a `ManagedWindow` or panel holding the view that
|
|
3325
|
+
closes on Escape stays open. An Esc a handler inside the view has already
|
|
3326
|
+
consumed (`preventDefault`, e.g. an inline popup in the transcript closing
|
|
3327
|
+
itself) is left alone: it neither cancels nor is stopped.
|
|
3328
|
+
- **Ids are unique.** `append`, `beginStream` or `replaceAll` with an id already
|
|
3329
|
+
in the transcript (or repeated within the `replaceAll` list) throws, and the
|
|
3330
|
+
refused call changes nothing: no row, no open stream, the old transcript kept.
|
|
3331
|
+
- `labels: { send, cancel, jump, input }` replaces the four texts.
|
|
3332
|
+
|
|
3333
|
+
`tests/conversation_view.test.mjs` drives all of it in jsdom with a fake
|
|
3334
|
+
scroll geometry.
|
|
3335
|
+
|
|
1727
3336
|
## The host port
|
|
1728
3337
|
|
|
1729
3338
|
FlexDesk never touches `pywebview`, `fetch`, `localStorage` or the filesystem. It
|
|
@@ -1734,8 +3343,10 @@ library degrades rather than throwing.
|
|
|
1734
3343
|
```js
|
|
1735
3344
|
createHost({
|
|
1736
3345
|
state: { read, write }, // layout persistence
|
|
1737
|
-
dialogs: { saveFile },
|
|
3346
|
+
dialogs: { saveFile, openFile? }, // optional — CSV export falls back to a Blob;
|
|
3347
|
+
// openFile (itself optional) to pickFile()
|
|
1738
3348
|
window: { minimize, maximize, ... }, // optional — no native chrome buttons without it
|
|
3349
|
+
gallery: { list }, // optional (0.17.0) — mountGalleryPicker's entries
|
|
1739
3350
|
})
|
|
1740
3351
|
```
|
|
1741
3352
|
|
|
@@ -1757,6 +3368,47 @@ const host = createPhotinoHost({ state: myServerState, onEvent: (name, value) =>
|
|
|
1757
3368
|
the messages the C# side has to answer are in
|
|
1758
3369
|
[`src/host/photino_host.md`](src/host/photino_host.md).
|
|
1759
3370
|
|
|
3371
|
+
**The dialogs a pywebview bridge answers.** `createPywebviewHost()` calls these
|
|
3372
|
+
methods on `window.pywebview.api`, positionally. Each is optional: a missing
|
|
3373
|
+
one makes its capability answer `null`, and FlexDesk degrades.
|
|
3374
|
+
|
|
3375
|
+
| Bridge method | Arguments | Reply | Behind |
|
|
3376
|
+
|---|---|---|---|
|
|
3377
|
+
| `save_file_dialog` | `(data, filename, kind)`: the file's text (`csv`) or base64 bytes (`png`), the suggested name, the type | `{ok: true, path}` \| `{ok: false, cancelled: true}` \| `{ok: false, error}` | `dialogs.saveFile` |
|
|
3378
|
+
| `read_file_dialog` (0.17.0) | `(accept, kind, encoding)`: a list of extensions or MIME types to filter by (`['.json']`, may be empty), a label for the filter or `null`, and `'text'` or `'base64'` | `{ok: true, name, path, text}` (`'text'`, read as UTF-8) or `{ok: true, name, path, data}` (`'base64'`, the bytes) \| `{ok: false, cancelled: true}` \| `{ok: false, error}` | `dialogs.openFile` |
|
|
3379
|
+
|
|
3380
|
+
### Opening a file: `pickFile` (0.17.0)
|
|
3381
|
+
|
|
3382
|
+
```js
|
|
3383
|
+
import { pickFile } from '@flexdesk/host';
|
|
3384
|
+
|
|
3385
|
+
const r = await pickFile(host, { accept: ['.json'], kind: 'json' }); // encoding: 'text' (default) or 'base64'
|
|
3386
|
+
if (r.ok) load(JSON.parse(r.text));
|
|
3387
|
+
else if (r.error) toast(r.error); // r.cancelled: say nothing
|
|
3388
|
+
```
|
|
3389
|
+
|
|
3390
|
+
`pickFile(host, {accept, kind, encoding})` takes `accept` as a list or as a
|
|
3391
|
+
comma-separated string (`'.json,.csv'`); the host always receives the list
|
|
3392
|
+
(`['.json', '.csv']`). It asks the host's
|
|
3393
|
+
`dialogs.openFile` first, a new optional member of `dialogs`: a host whose
|
|
3394
|
+
`dialogs` has only `saveFile` is still complete. Without it, or when it
|
|
3395
|
+
answers `null` (no dialog behind it), a transient hidden
|
|
3396
|
+
`<input type="file">` opens the browser's chooser, so the same call works in a
|
|
3397
|
+
browser tab, under `NULL_HOST`. Either way it resolves one of
|
|
3398
|
+
|
|
3399
|
+
```
|
|
3400
|
+
{ok: true, name, path?, text} encoding 'text'
|
|
3401
|
+
{ok: true, name, path?, data} encoding 'base64': the file's bytes
|
|
3402
|
+
{ok: false, cancelled: true} the user closed the chooser
|
|
3403
|
+
{ok: false, error} show it
|
|
3404
|
+
```
|
|
3405
|
+
|
|
3406
|
+
`path` comes only from a native host. The pywebview adapter maps `openFile` to
|
|
3407
|
+
`read_file_dialog(accept, kind, encoding)` (the table above); the Photino
|
|
3408
|
+
adapter sends `dialogs.openFile`, documented in
|
|
3409
|
+
[`photino_host.md`](src/host/photino_host.md). The browser chooser reports a
|
|
3410
|
+
cancel through the input's `cancel` event, which every current engine fires.
|
|
3411
|
+
|
|
1760
3412
|
### A window with its own chrome (0.15.0)
|
|
1761
3413
|
|
|
1762
3414
|
The same app runs three ways, and the page needs no switch for it:
|
|
@@ -1815,6 +3467,61 @@ Reload, Print, Inspect) on any right-click that no handler claimed with
|
|
|
1815
3467
|
text, where Paste and Copy are the point, and wherever `allow(event)` says so.
|
|
1816
3468
|
Both calls return `{ dispose }`.
|
|
1817
3469
|
|
|
3470
|
+
## The state guard: who wrote that attribute (0.17.0)
|
|
3471
|
+
|
|
3472
|
+
`StateGuardService` blocks (`strict`) or reports (`warn`) `data-*` writes that
|
|
3473
|
+
are on no allow-list. A module override lets one module write a key nobody
|
|
3474
|
+
else may:
|
|
3475
|
+
|
|
3476
|
+
```js
|
|
3477
|
+
import { StateGuardService, DEFAULT_STATE_GUARD_CONFIG } from '@flexdesk/core';
|
|
3478
|
+
|
|
3479
|
+
const guard = new StateGuardService({ eventBus, config: { ...DEFAULT_STATE_GUARD_CONFIG, mode: 'strict' } });
|
|
3480
|
+
guard.allow('src/app/my_widget.js', ['data-foo']); // a literal substring of the module, or a RegExp
|
|
3481
|
+
```
|
|
3482
|
+
|
|
3483
|
+
A write is attributed to the frame that made it: the URL or path of the file
|
|
3484
|
+
that called `setAttribute`, `removeAttribute` or `dataset`, without line and
|
|
3485
|
+
column, whatever your directory layout. A FlexDesk widget's write is the
|
|
3486
|
+
widget's, not that of the app module that called the widget. To attribute
|
|
3487
|
+
writes further up the stack instead, pass `moduleFramePattern`: the first
|
|
3488
|
+
frame from the writer up whose location matches names the module, by the
|
|
3489
|
+
pattern's first capture group if it has one.
|
|
3490
|
+
|
|
3491
|
+
```js
|
|
3492
|
+
config: { ...DEFAULT_STATE_GUARD_CONFIG, moduleFramePattern: /(src\/app\/[^/]+\.js)/ }
|
|
3493
|
+
// a write made inside @flexdesk/widgets on behalf of src/app/my_widget.js
|
|
3494
|
+
// is now attributed to 'src/app/my_widget.js'
|
|
3495
|
+
```
|
|
3496
|
+
|
|
3497
|
+
The defaults are `mode: 'warn'`, no module overrides and no frame pattern.
|
|
3498
|
+
Before 0.17.0 the module was only found under a `js_new/` path, the defaults
|
|
3499
|
+
carried one application's override, and the mode read one application's debug
|
|
3500
|
+
global; choose strict mode in your own code.
|
|
3501
|
+
|
|
3502
|
+
### The defaults cover FlexDesk's own keys (0.17.0)
|
|
3503
|
+
|
|
3504
|
+
`DEFAULT_STATE_GUARD_CONFIG` includes every data key FlexDesk's own widgets
|
|
3505
|
+
write, so the shell, dialogs, forms, tooltips, tiles and flow editors run under
|
|
3506
|
+
`mode: 'strict'` with no violation. The list also carries some keys that
|
|
3507
|
+
FlexDesk itself does not write. Add your application's keys on top:
|
|
3508
|
+
|
|
3509
|
+
```js
|
|
3510
|
+
const guard = new StateGuardService({ eventBus, logger, config: {
|
|
3511
|
+
...DEFAULT_STATE_GUARD_CONFIG,
|
|
3512
|
+
allowedDatasetKeys: [...DEFAULT_STATE_GUARD_CONFIG.allowedDatasetKeys, 'order-id'],
|
|
3513
|
+
} });
|
|
3514
|
+
```
|
|
3515
|
+
|
|
3516
|
+
An allow-list key may be spelled either way: `orderId` and `order-id` are the
|
|
3517
|
+
same key, in the allow-list and in module overrides
|
|
3518
|
+
(`allow(pattern, attributes, { datasetKeys })` and `config.moduleOverrides`)
|
|
3519
|
+
alike. camelCase maps to kebab-case as the DOM does for ordinary keys: each
|
|
3520
|
+
capital letter becomes `-` plus its lower-case form. So `rowID` is `row-i-d`
|
|
3521
|
+
(what `dataset.rowID` writes), not `rowid`; to allow `data-id`, list `id`.
|
|
3522
|
+
Before 0.17.0 a camelCase entry never matched, and some entries named keys no
|
|
3523
|
+
element carries.
|
|
3524
|
+
|
|
1818
3525
|
## Peer dependencies
|
|
1819
3526
|
|
|
1820
3527
|
`@flexdesk/charts` needs Plotly (4.4 MB). `@flexdesk/editor` needs Monaco (14 MB).
|
|
@@ -1833,9 +3540,355 @@ There is deliberately **no default path**. A hardcoded `'vendor/monaco'` is one
|
|
|
1833
3540
|
application's directory layout, and for everyone else it is a 404 that surfaces as
|
|
1834
3541
|
an editor which silently never appears. `initMonaco()` throws instead.
|
|
1835
3542
|
|
|
3543
|
+
`getEditorFactory()` and `initMonaco()` **reject** when no path was set, when
|
|
3544
|
+
`vs/loader.js` fails to load, or when Monaco's AMD loader reports an error, so a
|
|
3545
|
+
consumer can show a message instead of waiting forever. What happens next
|
|
3546
|
+
depends on which of those it was:
|
|
3547
|
+
|
|
3548
|
+
- **`vs/loader.js` failed to load** (a 404, a wrong path): not sticky. Fix the
|
|
3549
|
+
path (`setMonacoBasePath`, or pass it to `initMonaco`) and the next call
|
|
3550
|
+
tries again.
|
|
3551
|
+
- **Monaco's AMD loader reported an error** (`vs/editor/editor.main` or a
|
|
3552
|
+
dependency failed): **final for the page**. Monaco's loader caches the failed
|
|
3553
|
+
module and has no way to forget it, so every later call rejects with that
|
|
3554
|
+
same first error, whatever path it is given. Fix the path and reload.
|
|
3555
|
+
|
|
3556
|
+
Loading is shared: if you call `initMonaco(path)` yourself, `getEditorFactory()`
|
|
3557
|
+
uses that load and needs no `setMonacoBasePath()`. `monacoReady` only ever
|
|
3558
|
+
resolves — it is the "Monaco has arrived" signal, not the place to learn that
|
|
3559
|
+
an attempt failed.
|
|
3560
|
+
|
|
1836
3561
|
If you already load Plotly yourself (a `<script>` tag that sets `window.Plotly`),
|
|
1837
3562
|
the chart loader short-circuits and you can skip `setPlotlySource`.
|
|
1838
3563
|
|
|
3564
|
+
### Auto-height editors: `maxHeight` (0.17.0)
|
|
3565
|
+
|
|
3566
|
+
`createEditor()` sizes its container to the content (80px at least, see
|
|
3567
|
+
`setMinHeight`). Cap it with `maxHeight`, in px; `Infinity`, the default, is
|
|
3568
|
+
no cap. Past the cap Monaco scrolls internally and takes the mouse wheel;
|
|
3569
|
+
below it the wheel reaches whatever scrolls around the editor.
|
|
3570
|
+
|
|
3571
|
+
```js
|
|
3572
|
+
const h = factory.createEditor(el, source, { maxHeight: 400 });
|
|
3573
|
+
h.setMaxHeight(Infinity); // applies at once: grows to the content
|
|
3574
|
+
```
|
|
3575
|
+
|
|
3576
|
+
A non-positive or non-number cap throws a `RangeError`. With `noAutoHeight`
|
|
3577
|
+
the container's own layout decides the size and `setMaxHeight` has no effect.
|
|
3578
|
+
Before 0.17.0 the cap was a fixed 400px, switched on by `data-code-max-height="true"`
|
|
3579
|
+
on a `.notebook-editor-container` ancestor; that attribute is no longer read.
|
|
3580
|
+
|
|
3581
|
+
## Storage keys and names are yours (0.17.0)
|
|
3582
|
+
|
|
3583
|
+
Every localStorage key FlexDesk writes is an option, and so is each name in the
|
|
3584
|
+
table below that it registers where other code can see it, except the last row:
|
|
3585
|
+
the tile tab MIME is fixed. The defaults are FlexDesk's own; pass yours at boot,
|
|
3586
|
+
before the first read:
|
|
3587
|
+
|
|
3588
|
+
| What | Default | Set it with |
|
|
3589
|
+
|---|---|---|
|
|
3590
|
+
| Settings (the `getSetting`/`setSetting` binding) | `'flexdesk.settings.v1'` | `configureSettings({ storageKey })` from `@flexdesk/core` |
|
|
3591
|
+
| ManagedWindow geometry | `'flexdesk.managedWindows.v1'` | `ManagedWindow.configure({ storageKey })` from `@flexdesk/widgets` |
|
|
3592
|
+
| Saved tile layouts | `'twm.tiles.layouts'` | `configureLayoutPersistence({ layoutsKey })` from `@flexdesk/tiles` |
|
|
3593
|
+
| Saved tile configurations | `'twm.tiles.configs'` | `configureLayoutPersistence({ configsKey })` from `@flexdesk/tiles` |
|
|
3594
|
+
| Built-in Monaco themes | `'flexdesk-dark'`, `'flexdesk-light'` | `setEditorThemeNames({ dark, light })` from `@flexdesk/editor` |
|
|
3595
|
+
| MIME type of a dragged editor tab | `'application/x-flexdesk-tab'` | `setTabDragMime(mime)` from `@flexdesk/editor` |
|
|
3596
|
+
| Chart PNG export file name | `'chart'` (saved as `chart.png`) | `setChartExportFilename(name)` from `@flexdesk/charts` |
|
|
3597
|
+
| `LoggingService` service name on each entry | `'flexdesk'` | `new LoggingService({ name })` from `@flexdesk/core` |
|
|
3598
|
+
| MIME type of a dragged tile tab | `'application/x-twm-tile-tab'` | fixed: not an option; read it as `TILE_TAB_MIME` (or `TileRenderer.TAB_MIME`) from `@flexdesk/wm` |
|
|
3599
|
+
|
|
3600
|
+
```js
|
|
3601
|
+
import { configureSettings } from '@flexdesk/core';
|
|
3602
|
+
import { ManagedWindow } from '@flexdesk/widgets';
|
|
3603
|
+
import { setEditorThemeNames, setTabDragMime } from '@flexdesk/editor';
|
|
3604
|
+
import { setChartExportFilename } from '@flexdesk/charts';
|
|
3605
|
+
|
|
3606
|
+
configureSettings({ storageKey: 'myapp.settings.v1' });
|
|
3607
|
+
ManagedWindow.configure({ storageKey: 'myapp.windows.v1' });
|
|
3608
|
+
setEditorThemeNames({ dark: 'myapp-dark', light: 'myapp-light' });
|
|
3609
|
+
setTabDragMime('application/x-myapp-tab');
|
|
3610
|
+
setChartExportFilename('myapp_chart');
|
|
3611
|
+
```
|
|
3612
|
+
|
|
3613
|
+
Each default is exported beside its setter (`DEFAULT_SETTINGS_STORAGE_KEY`,
|
|
3614
|
+
`DEFAULT_WINDOW_STORAGE_KEY`, `DEFAULT_EDITOR_THEME_NAMES`,
|
|
3615
|
+
`DEFAULT_TAB_DRAG_MIME`, `DEFAULT_CHART_EXPORT_FILENAME`), and each setter has a
|
|
3616
|
+
getter (`getSettingsStorageKey()`, `ManagedWindow.storageKey`,
|
|
3617
|
+
`getEditorThemeNames()`, `getTabDragMime()`, `getChartExportFilename()`). An
|
|
3618
|
+
embedder that passes nothing gets the defaults silently: nothing is logged.
|
|
3619
|
+
|
|
3620
|
+
- **Settings** are read lazily: importing `@flexdesk/core` touches no storage,
|
|
3621
|
+
and the binding reads its key on the first `getSetting`/`setSetting`/reset.
|
|
3622
|
+
`registerSettings()` and `registerSettingsEventBus()` may come before or after
|
|
3623
|
+
`configureSettings()`. Configured after the binding was in use, it moves: values
|
|
3624
|
+
return to their defaults, are reloaded from the new key, and one
|
|
3625
|
+
`settings:changed` (`path: null`) is emitted. The old key is not touched. A
|
|
3626
|
+
private store from `createSettingsStore()` moves the same way with
|
|
3627
|
+
`store.setStorageKey(key)` (and answers `store.getStorageKey()`).
|
|
3628
|
+
- **ManagedWindow** reads geometry when a window is first shown; configure it
|
|
3629
|
+
before the first `show()`.
|
|
3630
|
+
- **Editor themes**: a factory starts on the dark name current when it is
|
|
3631
|
+
created, and `init()` defines the built-in themes under the names current
|
|
3632
|
+
then. An app with a theme of its own defines it with `monaco.editor.defineTheme`
|
|
3633
|
+
and passes its name to `factory.setTheme(name)`.
|
|
3634
|
+
- **The tab MIME** is read at drag time by every `NotebookTabBar` and
|
|
3635
|
+
`EditorPane`, so the two always agree.
|
|
3636
|
+
|
|
3637
|
+
**Upgrading from 0.16.** The old defaults were one application's names:
|
|
3638
|
+
`'ecosim.settings.v1'`, `'ecosim.managedWindows.v1'`, `'ecosim-dark'` /
|
|
3639
|
+
`'ecosim-light'`, `'application/x-ecosim-tab'`, `'ecosim_chart'` and the log
|
|
3640
|
+
service name `'ecosim'`. If your users have settings or window geometry saved
|
|
3641
|
+
under the old keys, pass those keys explicitly and nothing is lost:
|
|
3642
|
+
|
|
3643
|
+
```js
|
|
3644
|
+
configureSettings({ storageKey: 'ecosim.settings.v1' });
|
|
3645
|
+
ManagedWindow.configure({ storageKey: 'ecosim.managedWindows.v1' });
|
|
3646
|
+
```
|
|
3647
|
+
|
|
3648
|
+
Code that called `factory.setTheme('ecosim-light')` now calls
|
|
3649
|
+
`factory.setTheme(getEditorThemeNames().light)` (or names the themes back with
|
|
3650
|
+
`setEditorThemeNames`), and code that read `'application/x-ecosim-tab'` off a drag
|
|
3651
|
+
reads `getTabDragMime()`.
|
|
3652
|
+
|
|
3653
|
+
## Derived series: `seriesTransforms` (0.17.0)
|
|
3654
|
+
|
|
3655
|
+
A plot can draw series worked out from its own: a trend, a moving average, a
|
|
3656
|
+
decomposition. `@flexdesk/charts` ships none; you pass them to
|
|
3657
|
+
`PlotPopoutWindow` (or `openPlotPopoutWindow`) as `seriesTransforms`:
|
|
3658
|
+
|
|
3659
|
+
```js
|
|
3660
|
+
import { openPlotPopoutWindow, renderSeriesTransformControls } from '@flexdesk/charts';
|
|
3661
|
+
|
|
3662
|
+
const seriesTransforms = {
|
|
3663
|
+
hp: {
|
|
3664
|
+
label: 'HP filter',
|
|
3665
|
+
params: { lambda: { label: 'Lambda', default: 1600, min: 1, step: 100 } },
|
|
3666
|
+
outputs: {
|
|
3667
|
+
trend: { label: 'Trend', suffix: ' (HP Trend)', lineStyle: 'dashed' },
|
|
3668
|
+
cycle: { label: 'Cycle', suffix: ' (HP Cycle)', lineStyle: 'dotted', lineWidth: 1 },
|
|
3669
|
+
},
|
|
3670
|
+
run: (y, { lambda }) => myBackend.hpFilter(y, lambda), // -> { trend: [...], cycle: [...] }
|
|
3671
|
+
},
|
|
3672
|
+
};
|
|
3673
|
+
|
|
3674
|
+
openPlotPopoutWindow({ id, title, plotConfig, getResults, services, seriesTransforms,
|
|
3675
|
+
configRenderer: (el) => renderSeriesTransformControls(el, plotConfig.subplots[0],
|
|
3676
|
+
seriesTransforms, { onChange: redraw }) });
|
|
3677
|
+
```
|
|
3678
|
+
|
|
3679
|
+
What a subplot switches on is in the subplot:
|
|
3680
|
+
`subplot.transforms = { hp: { outputs: { trend: true }, params: { lambda: 100 } } }`.
|
|
3681
|
+
For every transform with an output switched on, `run(y, params)` is called
|
|
3682
|
+
once per line or scatter trace of the subplot (params: the declared defaults
|
|
3683
|
+
under the subplot's own), and each switched-on output it returns is drawn as
|
|
3684
|
+
an extra trace in the source trace's colour, on its axis, named
|
|
3685
|
+
`<source name><suffix>`, with the output's `lineStyle` and `lineWidth`
|
|
3686
|
+
(default 2). A transform that throws or rejects is skipped for that trace,
|
|
3687
|
+
reported to `services.logger.warn`, and the figure is drawn without it.
|
|
3688
|
+
`deriveSeriesTraces(traces, subplot, seriesTransforms, { onError })` is that
|
|
3689
|
+
step on its own, for a renderer of your own.
|
|
3690
|
+
|
|
3691
|
+
`renderSeriesTransformControls(container, subplot, seriesTransforms, { onChange })`
|
|
3692
|
+
renders, per transform, a toggle for each output and a number field for each
|
|
3693
|
+
parameter (disabled while no output is on); it edits `subplot.transforms` in
|
|
3694
|
+
place and returns `{ dispose }`. With no transforms it renders nothing.
|
|
3695
|
+
|
|
3696
|
+
Until 0.17.0 the plot window called one application's backend for a
|
|
3697
|
+
Hodrick-Prescott filter, and every default subplot carried `showHpTrend`,
|
|
3698
|
+
`showHpCycle` and `hpLambda`. Those keys are gone and are ignored in saved
|
|
3699
|
+
configs; without `seriesTransforms` no derived trace is drawn.
|
|
3700
|
+
`tests/series_transforms.test.mjs` pins all of it.
|
|
3701
|
+
|
|
3702
|
+
## No application globals: the gallery picker and plot-window events (0.17.0)
|
|
3703
|
+
|
|
3704
|
+
Nothing under `src/` outside `src/host` reads `window.pywebview` or an
|
|
3705
|
+
application's global; `tests/no_app_globals.test.mjs` pins it, with the toast
|
|
3706
|
+
helpers ([Notifications](#notifications-notificationcenter)) and these two:
|
|
3707
|
+
|
|
3708
|
+
**`mountGalleryPicker(el, { entries | host, kind, ... })`** takes its entries from
|
|
3709
|
+
`entries`, or else from the host's optional `gallery` capability:
|
|
3710
|
+
`host.gallery.list(kind)` answers `[{id, kind, label, description?, fields?}]`
|
|
3711
|
+
(or a promise of them). Neither shipped adapter advertises it; add your own:
|
|
3712
|
+
|
|
3713
|
+
```js
|
|
3714
|
+
const host = createHost({ ...createPywebviewHost({ bridgeRef }),
|
|
3715
|
+
gallery: { list: (kind) => myBackend.starters(kind) } });
|
|
3716
|
+
await mountGalleryPicker(el, { host, kind: 'report', onChange });
|
|
3717
|
+
```
|
|
3718
|
+
|
|
3719
|
+
With neither `entries` nor the capability, the picker shows its `emptyLabel`
|
|
3720
|
+
and logs one warning; a `list` that throws does the same. Until 0.17.0 it
|
|
3721
|
+
called one application's bridge method.
|
|
3722
|
+
|
|
3723
|
+
**`PlotPopoutWindow({ streamEvents })`** names the bus events a live data
|
|
3724
|
+
source emits, next to `refreshEvent`: `{ delta, complete }`. The window
|
|
3725
|
+
redraws on each while live update is on. There are no default names: without
|
|
3726
|
+
`streamEvents`, or for a name left out or given as `null`, nothing is listened
|
|
3727
|
+
for, and the window updates through `refreshEvent` and explicit calls only.
|
|
3728
|
+
|
|
3729
|
+
```js
|
|
3730
|
+
openPlotPopoutWindow({ id, title, plotConfig, getResults, services,
|
|
3731
|
+
refreshEvent: 'run:finished',
|
|
3732
|
+
streamEvents: { delta: 'sim:delta', complete: 'sim:done' } });
|
|
3733
|
+
```
|
|
3734
|
+
|
|
3735
|
+
## A plot config in any container: `renderPlotGrid` (0.17.0)
|
|
3736
|
+
|
|
3737
|
+
`PlotPopoutWindow` used to keep its subplot renderer private, so anything else
|
|
3738
|
+
that showed the same plot config (a notebook cell, a dashboard tile) had to
|
|
3739
|
+
copy it. It is now exported, and the window is one of its callers:
|
|
3740
|
+
|
|
3741
|
+
```js
|
|
3742
|
+
import { renderPlotGrid, migrateData } from '@flexdesk/charts';
|
|
3743
|
+
|
|
3744
|
+
const grid = renderPlotGrid(el, migrateData(savedConfig), { time, series }, {
|
|
3745
|
+
overlays, // { [series.id]: { years, values } } for series with an `overlay`
|
|
3746
|
+
seriesTransforms, // as above
|
|
3747
|
+
hiddenSeries: new Set(), // trace names not to draw
|
|
3748
|
+
});
|
|
3749
|
+
await grid.update({ results: next }); // any of plotConfig, results, overlays, hiddenSeries
|
|
3750
|
+
grid.resize();
|
|
3751
|
+
grid.dispose();
|
|
3752
|
+
```
|
|
3753
|
+
|
|
3754
|
+
It lays a `.twm-plot-grid` out from `PLOT_LAYOUTS` (a cell per slot, a
|
|
3755
|
+
`.twm-plot-grid__cell` in that slot's grid area) and draws one figure per
|
|
3756
|
+
subplot: its chart type, interpolation, data points and stack groups, every
|
|
3757
|
+
y-axis (left or right, linear or log, min, max, step), reference lines and
|
|
3758
|
+
shaded bands, the NaN strategy, the x-axis clipped to the results' time
|
|
3759
|
+
range, or an x variable instead of time (`xAxis: { useTime: false, variable }`).
|
|
3760
|
+
A subplot with nothing to draw shows `emptyMessage` (default `No data yet.`); a
|
|
3761
|
+
slot with no subplot shows `noSubplotMessage` (`No subplot configured.`); null
|
|
3762
|
+
results empty every subplot. A cell that is detached or has no size is not
|
|
3763
|
+
drawn: the grid's own ResizeObserver draws it once it has one. `dispose()`
|
|
3764
|
+
purges every figure and removes the grid; `grid.charts` is the cells, for
|
|
3765
|
+
Plotly calls of your own (relayout, toImage), and `grid.plotted` is the cells
|
|
3766
|
+
that show a figure right now, however it got there (an `update()`, or a
|
|
3767
|
+
deferred draw on a resize). `update()` resolves whether it drew anything, but a
|
|
3768
|
+
cell it deferred is drawn later, so ask `grid.plotted`, not that value, before
|
|
3769
|
+
acting on a figure.
|
|
3770
|
+
|
|
3771
|
+
The other options:
|
|
3772
|
+
|
|
3773
|
+
| Option | What it does |
|
|
3774
|
+
|---|---|
|
|
3775
|
+
| `resolveSeries(series, results, subplot)` | Returns `{ y, x? }` or null. Default `resolveSeriesData`: `series[_resultKey ?? variable]`, then `variable`, then the name after its first `.`; `x` is `_resultTime`. Also resolves the x variable. |
|
|
3776
|
+
| `derivedTraces(traces, subplot, { results, index })` | Extra Plotly traces (or a promise of them) appended to a subplot's figure. |
|
|
3777
|
+
| `onTransformError`, `onError` | A transform that failed (`{ transformId, traceName, error }`); a subplot that failed to draw (`{ index, error }`), on an `update()` or on the resize that draws a deferred one, which then shows the empty message. |
|
|
3778
|
+
| `theme` | Overrides for `PLOT_GRID_THEME`: `fontFamily`, `fontColor`, `fontSize`, `paperBackground`, `plotBackground`, `gridColor`, `lineColor`, `tickColor`, `zeroLineColor`, `hoverBackground`, `hoverBorder`. |
|
|
3779
|
+
| `alwaysShowLegend` | Draws a subplot whose legend is `'hidden'` with one at the top. |
|
|
3780
|
+
| `layout` | Plotly layout fields merged over every figure's (`hovermode`, `margin`, ...). |
|
|
3781
|
+
|
|
3782
|
+
`PlotPopoutWindow` takes `resolveSeries` too, for its chart and its data
|
|
3783
|
+
table. Its chart cells were `div.plot-tile-chart` and are now
|
|
3784
|
+
`div.twm-plot-grid__cell` inside a `.twm-plot-grid`. An overlay is drawn for
|
|
3785
|
+
any series with an `overlay` and data under its id in `actuals`; it used to
|
|
3786
|
+
need `overlay.etlKey` as well. More changes to what the window draws, for
|
|
3787
|
+
configs outside the fixtures:
|
|
3788
|
+
|
|
3789
|
+
- On an `'area-stacked'` subplot, overlays are stacked too, in a group of their
|
|
3790
|
+
own per y-axis (`stackgroup: 'overlay-<y ref>'`, `groupnorm: 'fraction'`,
|
|
3791
|
+
`mode: 'lines'`, no fill), so they read on the same 0..1 scale as the stack.
|
|
3792
|
+
They used to be drawn unstacked, at their raw values.
|
|
3793
|
+
- A subplot with no traces draws an empty figure, not the empty message, only
|
|
3794
|
+
when **all of its own** series are hidden (its overlays and derived traces do
|
|
3795
|
+
not count). It used to do that for every subplot as soon as any series
|
|
3796
|
+
anywhere in the window was hidden. Null results win over that rule: with no
|
|
3797
|
+
results every configured subplot shows `emptyMessage`, hidden series or not.
|
|
3798
|
+
- While `getResults()` answers null and nothing has been drawn yet, every cell
|
|
3799
|
+
shows `emptyMessage`; the cells used to stay blank. Figures already drawn
|
|
3800
|
+
stay while the results are briefly null, as before.
|
|
3801
|
+
- A y axis's positive `step` sets the Plotly `dtick` of that axis. The window
|
|
3802
|
+
used to ignore `step`, so Plotly chose the tick spacing.
|
|
3803
|
+
- A subplot with `xAxis: { useTime: false, variable }` is drawn against that
|
|
3804
|
+
variable, with an x-axis title (`xAxis.label`, else the variable's name) and
|
|
3805
|
+
no clip of the x axis to the time range. The window used to ignore `xAxis`
|
|
3806
|
+
and always drew against time, clipped to the first and last time point.
|
|
3807
|
+
|
|
3808
|
+
Its zoom and pan buttons and Download PNG act on `grid.plotted`, so a figure the
|
|
3809
|
+
grid drew on a resize (the window opened while its plot area had no size) is
|
|
3810
|
+
zoomable and exportable; Download PNG exports the first subplot that shows a
|
|
3811
|
+
figure, where it used to try the first slot whatever it held.
|
|
3812
|
+
`tests/plot_grid.test.mjs` pins all of it,
|
|
3813
|
+
including that the window draws the same traces, names and axes for the
|
|
3814
|
+
fixture configs (`tests/fixtures/plot_grid_fixtures.mjs`) as it did before.
|
|
3815
|
+
|
|
3816
|
+
## Editing a plot config: `renderPlotConfigEditor` (0.17.0)
|
|
3817
|
+
|
|
3818
|
+
`renderPlotGrid` draws a plot config; `renderPlotConfigEditor` edits one, in
|
|
3819
|
+
place, so an embedder no longer writes its own panel for it:
|
|
3820
|
+
|
|
3821
|
+
```js
|
|
3822
|
+
import { renderPlotConfigEditor, renderPlotGrid, migrateData } from '@flexdesk/charts';
|
|
3823
|
+
|
|
3824
|
+
const plotConfig = migrateData(savedConfig);
|
|
3825
|
+
const editor = renderPlotConfigEditor(el, plotConfig, {
|
|
3826
|
+
onChange: () => grid.update({ plotConfig }), // after every edit
|
|
3827
|
+
seriesProvider: ({ value, role, subplot }) => names(value), // items, or a promise of them
|
|
3828
|
+
seriesTransforms, // their toggles, per subplot
|
|
3829
|
+
overlaySources: () => mySources(), // optional
|
|
3830
|
+
extraSeriesFields: (el, series, { subplot, axis, onChange }) => { /* your fields */ },
|
|
3831
|
+
});
|
|
3832
|
+
editor.dispose();
|
|
3833
|
+
```
|
|
3834
|
+
|
|
3835
|
+
It draws a layout picker (every `PLOT_LAYOUTS` entry), a tab per subplot slot
|
|
3836
|
+
when the layout has more than one, and for the subplot in front: its title;
|
|
3837
|
+
its chart type, legend, interpolation, NaN handling and data points; its x
|
|
3838
|
+
axis (time, or a series); its y axes (label, scale, position, min, max, step)
|
|
3839
|
+
and, under each, its series (a picker, legend label, colour, width, dash and
|
|
3840
|
+
stack group); its z axis while the chart type is 3D; its reference lines and
|
|
3841
|
+
shaded bands; and the toggles of any `seriesTransforms`. Every edit writes the
|
|
3842
|
+
config it was given and then calls `onChange(config)`.
|
|
3843
|
+
|
|
3844
|
+
- A layout with more slots than there are subplots adds default subplots
|
|
3845
|
+
(`makeDefaultSubplot`); one with fewer keeps the extra subplots, so switching
|
|
3846
|
+
back restores them.
|
|
3847
|
+
- **Add Y axis** adds `makeDefaultYAxis(index)` (left for an even index, right
|
|
3848
|
+
for an odd one) and **Add series** adds `makeDefaultSeries(index)` (coloured
|
|
3849
|
+
by its place among the subplot's series). Both come from `@flexdesk/charts`,
|
|
3850
|
+
and `makeDefaultSubplot` and `migrateData` build their axes and series from
|
|
3851
|
+
them, so an axis added in the editor matches a default subplot's.
|
|
3852
|
+
- A 3D chart type gives the subplot a z axis (label, scale, min, max, step and
|
|
3853
|
+
a series) and uses its first y axis only: the other y axes are shown disabled
|
|
3854
|
+
and **Add Y axis** is disabled. A 2D chart type sets `zAxis` back to `null`.
|
|
3855
|
+
- Every series picker is an `AutocompleteField` fed by `seriesProvider`, asked
|
|
3856
|
+
`{ value, role, subplot }`, where `role` is `'series'`, `'x'` or `'z'`. It
|
|
3857
|
+
answers AutocompleteField items (`{ label, insertText?, type? }`) or a promise
|
|
3858
|
+
of them. A provider that throws or rejects shows no suggestions and leaves
|
|
3859
|
+
the typed text as it is (`logger.warn` hears it when you pass `logger`).
|
|
3860
|
+
Without `seriesProvider` the pickers suggest nothing and take typed text.
|
|
3861
|
+
- `overlaySources()` answers choice options (`{ value, label }`, or
|
|
3862
|
+
`{ group, options }`), or a promise of them, asked once per draw. With it,
|
|
3863
|
+
every series row gets an overlay source select; choosing one writes
|
|
3864
|
+
`series.overlay = { source, ... }` and shows the overlay's label, colour,
|
|
3865
|
+
dash, width and mode. `renderPlotGrid` draws it from the data you pass under
|
|
3866
|
+
the series' id in `overlays`. **None** removes `series.overlay`. Without
|
|
3867
|
+
`overlaySources` there are no overlay controls.
|
|
3868
|
+
- `extraSeriesFields(el, series, { subplot, axis, onChange })` puts fields of
|
|
3869
|
+
your own in each series row; call `onChange()` after you edit the series. What
|
|
3870
|
+
it returns is disposed (`dispose()`) when the row is redrawn or the editor
|
|
3871
|
+
is disposed. A `dispose()` that throws is reported to `logger.warn` and does
|
|
3872
|
+
not stop the rest of the editor being disposed.
|
|
3873
|
+
|
|
3874
|
+
It returns `{ dispose, refresh, selectSubplot, activeSubplot }`: `dispose()`
|
|
3875
|
+
disposes every field it created (every `AutocompleteField` among them) and
|
|
3876
|
+
removes the editor; `refresh()` redraws it after the config was changed from
|
|
3877
|
+
outside; `selectSubplot(index)` brings a subplot to the front. It fits
|
|
3878
|
+
`PlotPopoutWindow`'s `configRenderer` slot, `(container) => { dispose() } | void`:
|
|
3879
|
+
the window disposes what the renderer returns when it closes, so return the
|
|
3880
|
+
editor and its dropdowns leave `document.body` with the window.
|
|
3881
|
+
`openPlotPopoutWindow` is async; await it:
|
|
3882
|
+
|
|
3883
|
+
```js
|
|
3884
|
+
const win = await openPlotPopoutWindow({ id, title, plotConfig, getResults, services,
|
|
3885
|
+
configRenderer: (el) => renderPlotConfigEditor(el, plotConfig,
|
|
3886
|
+
{ onChange: () => win.updateConfig(plotConfig) }) });
|
|
3887
|
+
```
|
|
3888
|
+
|
|
3889
|
+
Its classes are under `.twm-plot-config` in `flexdesk.css`.
|
|
3890
|
+
`tests/plot_config_editor.test.mjs` pins all of it.
|
|
3891
|
+
|
|
1839
3892
|
## Layouts are keyed by whatever you key them by
|
|
1840
3893
|
|
|
1841
3894
|
`@flexdesk/tiles` stores layouts under keys **you** choose and resolves a starting
|
|
@@ -1895,15 +3948,181 @@ A chart tile's **Download PNG** (0.14.1) goes through the grid's `host`
|
|
|
1895
3948
|
toasts where the file went, a cancel says nothing — and with no host, or a
|
|
1896
3949
|
`saveFile` that answers null, the browser download it always was.
|
|
1897
3950
|
|
|
3951
|
+
### Figure tiles: one override, the draw is the base's (0.17.0)
|
|
3952
|
+
|
|
3953
|
+
`FigureTile` is the base every Plotly tile draws through — `ChartTile` is one.
|
|
3954
|
+
A subclass overrides **one** method, `figure(data)`, answering
|
|
3955
|
+
`{traces, layout?, config?, frames?}` or `null`, synchronously:
|
|
3956
|
+
|
|
3957
|
+
```js
|
|
3958
|
+
import { FigureTile, registerWidget } from '@flexdesk/tiles';
|
|
3959
|
+
import { buildHistogramTrace } from '@flexdesk/charts';
|
|
3960
|
+
|
|
3961
|
+
class SpreadTile extends FigureTile {
|
|
3962
|
+
static TYPE = 'myapp.spread';
|
|
3963
|
+
static TITLE = 'Spread';
|
|
3964
|
+
figure(data) {
|
|
3965
|
+
if (!data?.values?.length) return null; // the empty state
|
|
3966
|
+
return { traces: [buildHistogramTrace({ x: data.values })], layout: { margin: { t: 8 } } };
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
registerWidget(SpreadTile);
|
|
3970
|
+
```
|
|
3971
|
+
|
|
3972
|
+
The base awaits `ensurePlotly()` (so a page using `setPlotlySource` draws on
|
|
3973
|
+
first view, and data that changes while Plotly loads draws once), draws through
|
|
3974
|
+
`createChart` with `scrollZoom` and the mode bar off (`config` goes over that),
|
|
3975
|
+
adds `frames`, and discards a draw that lands after a newer one. `null` is the
|
|
3976
|
+
empty state with `static EMPTY_MESSAGE` — call `this.showEmpty(why)` yourself
|
|
3977
|
+
first to say why. A `figure()` that throws, or a draw that fails, is the error
|
|
3978
|
+
state plus `tile:render-failed {tileId, message}`. Loading, empty, error and
|
|
3979
|
+
`dispose()` purge the figure; a resize that settles relayouts it once. The ⋯
|
|
3980
|
+
menu is `TileBase`'s entries plus **Download PNG** (through `host.dialogs`, as
|
|
3981
|
+
above); extend it with `[...super.menuItems(), …]` and route your actions
|
|
3982
|
+
through `_onMenuAction`, calling `super` for the rest. **Expand** opens
|
|
3983
|
+
`openRawTracesWindow` with the last figure drawn (`getExpandData()`), and
|
|
3984
|
+
`onPointClick({customdata, point, tile})` — an option or a property — hears a
|
|
3985
|
+
click on a mark. `setData(data)` feeds one tile; `null` clears it.
|
|
3986
|
+
|
|
3987
|
+
`ChartTile` is now a `FigureTile`: its menu stays Download PNG alone, and its
|
|
3988
|
+
header gains the Expand button.
|
|
3989
|
+
|
|
3990
|
+
### List and people tiles, and one scope for the board (0.16.1)
|
|
3991
|
+
|
|
3992
|
+
Two more widgets, registered by `registerListTiles()` (idempotent, nothing at
|
|
3993
|
+
import). Like the chart tile they take **rows you have already worked out**;
|
|
3994
|
+
what they own is what every list on a board needs: the count ("7 of 12"), the
|
|
3995
|
+
line that says what empty means, a button in the bar, a click, the keyboard and
|
|
3996
|
+
a right-click menu per row.
|
|
3997
|
+
|
|
3998
|
+
```js
|
|
3999
|
+
import { registerListTiles, createScopePicker } from '@flexdesk/tiles';
|
|
4000
|
+
registerListTiles();
|
|
4001
|
+
const late = grid.addTile('flexdesk.list', { x: 0, y: 0, w: 12, h: 4 },
|
|
4002
|
+
{ title: 'Overdue', empty_text: 'Nothing is late.', max_rows: 0 });
|
|
4003
|
+
late.onRowActivate = ({ row, event }) => open(row.key, { modified: event.ctrlKey });
|
|
4004
|
+
late.rowMenu = ({ row }) => [{ label: 'Open', icon: 'open_in_new', action: 'open' }];
|
|
4005
|
+
late.onRowMenuAction = ({ action, row }) => { /* … */ };
|
|
4006
|
+
late.onAction = () => openAsList();
|
|
4007
|
+
late.setData({
|
|
4008
|
+
rows: [{ key: '7', icon: 'task', lead: 'TASK-0007', title: 'Sign-off',
|
|
4009
|
+
cells: [{ text: 'priya' }, { text: '9 days late', tone: 'danger' }] }],
|
|
4010
|
+
action: { label: 'Open as a list', icon: 'list' },
|
|
4011
|
+
});
|
|
4012
|
+
grid.addTile('flexdesk.people', { x: 0, y: 4, w: 6, h: 4 }, { title: 'Who has what' })
|
|
4013
|
+
.setData({ people: [{ key: '', name: 'Nobody', muted: true, count: 2,
|
|
4014
|
+
pills: [{ text: '2 undated', tone: 'muted' }] }] });
|
|
4015
|
+
```
|
|
4016
|
+
|
|
4017
|
+
- A row is `role="button"`: click, Enter and Space call `onRowActivate` with the
|
|
4018
|
+
event; Up/Down/Home/End move. Right-click, the context-menu key and Shift+F10
|
|
4019
|
+
open the row's menu (`showContextMenu`'s items; none means no menu). A row
|
|
4020
|
+
carries `data-row-key`; `rowsDraggable` makes rows draggable.
|
|
4021
|
+
- `{rows: []}` keeps the bar and says `empty` (or `config.empty_text`) where the
|
|
4022
|
+
rows would be; `setData(null)` is the empty state. Text is always text.
|
|
4023
|
+
- `tone` (`danger`, `warning`, `ok`, `muted`) picks a colour only; `className`
|
|
4024
|
+
on a row or a cell is yours, added as it is.
|
|
4025
|
+
- `groupByPerson(items, personOf)` groups for a people tile, most first.
|
|
4026
|
+
|
|
4027
|
+
**One scope for the whole board.** `createScopePicker({options, value, onChange})`
|
|
4028
|
+
is a row of buttons, one pressed. `grid.setScope(value)` (or
|
|
4029
|
+
`new TileGrid({ scope, onScopeChange })`) hands the value to every tile as
|
|
4030
|
+
`tile.scope` and `tile.onScopeChange(scope)`, a tile added later starts with
|
|
4031
|
+
it, a grid with a data source reloads its tiles, and the grid's `onScopeChange`
|
|
4032
|
+
is told. What a scope means is yours; it is not part of the layout.
|
|
4033
|
+
|
|
4034
|
+
### A tile's settings as a form: `mountTileConfigForm` (0.17.0)
|
|
4035
|
+
|
|
4036
|
+
`getConfigSchema()` answers `{fields}`; `mountTileConfigForm` draws them, so a
|
|
4037
|
+
config panel is no longer a control written by hand per field:
|
|
4038
|
+
|
|
4039
|
+
```js
|
|
4040
|
+
import { mountTileConfigForm, openTileConfig } from '@flexdesk/tiles';
|
|
4041
|
+
const form = mountTileConfigForm(host, {
|
|
4042
|
+
schema: tile.getConfigSchema(),
|
|
4043
|
+
config: tile.config, // defaults filled by mergeWithDefaults
|
|
4044
|
+
data, // what getFieldOptions reads for dynamic options
|
|
4045
|
+
onChange: (key, value, config) => tile.update(undefined, config),
|
|
4046
|
+
renderers: { variable: ({ field, value, data, readonly, commit }) => myPicker(...) },
|
|
4047
|
+
});
|
|
4048
|
+
form.getValues(); form.setValues(next); form.isValid(); form.focusFirstInvalid();
|
|
4049
|
+
form.flush(); form.dispose();
|
|
4050
|
+
```
|
|
4051
|
+
|
|
4052
|
+
- A field is `{key, type, label?, description?, default?, required?, readonly?,
|
|
4053
|
+
min?, max?, step?, options?, visibleWhen?}`. Without a `label` the field is
|
|
4054
|
+
called by its `key`, on its label and in its error messages. Every `FieldTypes` entry has a
|
|
4055
|
+
control (text, number, checkbox, select, multiselect, color, range); any other
|
|
4056
|
+
`type` needs a renderer, which answers an element or
|
|
4057
|
+
`{element, setValue?, dispose?}` and calls `commit(value)` on an edit.
|
|
4058
|
+
- An edit is checked by `validateField`. An invalid one shows its error under the
|
|
4059
|
+
field and is **not applied**; a valid one calls `onChange` with the field's key.
|
|
4060
|
+
`isValid()` is false while a shown row has an error; `focusFirstInvalid()`
|
|
4061
|
+
focuses the first such row's control (a renderer's element, or the first
|
|
4062
|
+
focusable element inside it) and answers whether there was one.
|
|
4063
|
+
- `commitOn: 'input'` (the default) checks text, number, range and colour
|
|
4064
|
+
controls on every keystroke; `commitOn: 'change'` checks them once the edit is
|
|
4065
|
+
finished (blur, Enter, a released slider), for an `onChange` that is expensive.
|
|
4066
|
+
`flush()` commits an edit typed but not yet finished.
|
|
4067
|
+
- `visibleWhen: {field, in: [...]}` (or `{field, equals}`) hides the row, never
|
|
4068
|
+
rebuilds the form, so the control being typed into keeps its focus.
|
|
4069
|
+
- A field with no `key` throws. **The built-in tiles' schemas now use `key` and
|
|
4070
|
+
`description`** (they said `name` and `help`, which `validateConfig` and
|
|
4071
|
+
`mergeWithDefaults` never read: BUG-0021). A consumer that read `field.name`
|
|
4072
|
+
or `field.help` from them reads `field.key` and `field.description`.
|
|
4073
|
+
|
|
4074
|
+
The grid's cogwheel emits `grid:config-modal-show`, which now also carries the
|
|
4075
|
+
grid's `data`. Nothing listens unless you ask:
|
|
4076
|
+
`openTileConfig(eventBus, {mode: 'slide-out', host})` shows the form in a
|
|
4077
|
+
`SlideOutPanel` over `host` and saves every finished, valid edit (it runs the
|
|
4078
|
+
form with `commitOn: 'change'`, so typing a title is one save, not one per
|
|
4079
|
+
keystroke; closing the panel saves an edit still being typed);
|
|
4080
|
+
`{mode: 'modal'}` (the default) saves on Save, and refuses Save while a field
|
|
4081
|
+
shows an error (the modal stays open and the first invalid field is focused). A request that arrives while a
|
|
4082
|
+
modal is open is ignored. It answers a function that unsubscribes and closes the
|
|
4083
|
+
slide-out; an open modal is left to the user, keeps its form and still saves on
|
|
4084
|
+
Save. A renderer's `commit` on a `readonly` field applies nothing and answers
|
|
4085
|
+
`{valid: false, error}`.
|
|
4086
|
+
|
|
1898
4087
|
## Styling
|
|
1899
4088
|
|
|
1900
4089
|
Three stylesheets, and that is the whole CSS contract:
|
|
1901
4090
|
|
|
1902
|
-
| | |
|
|
1903
|
-
|
|
1904
|
-
| `dist/tokens.css` | Every rule reads `var(--…)` from here. Swap this one file and the shell restyles. |
|
|
1905
|
-
| `dist/reset.css` | The reset. |
|
|
1906
|
-
| `dist/flexdesk.css` | The library. Every class is `twm-` prefixed (the original working name; the prefix is stable and not worth a breaking rename). |
|
|
4091
|
+
| | Specifier | Resolves to | |
|
|
4092
|
+
|---|---|---|---|
|
|
4093
|
+
| `dist/tokens.css` | `flexdesk/css/tokens` | `css/tokens.css` | Every rule reads `var(--…)` from here. Swap this one file and the shell restyles. |
|
|
4094
|
+
| `dist/reset.css` | `flexdesk/css/reset` | `dist/reset.css` | The reset. |
|
|
4095
|
+
| `dist/flexdesk.css` | `flexdesk/css` | `dist/flexdesk.css` | The library. Every class is `twm-` prefixed (the original working name; the prefix is stable and not worth a breaking rename). |
|
|
4096
|
+
|
|
4097
|
+
A bundler, an import-map generator or `require.resolve` reaches each one by its
|
|
4098
|
+
specifier, through the package's `exports` map, which closes every other path
|
|
4099
|
+
into the package. The map sends `flexdesk/css/tokens` to the source
|
|
4100
|
+
`css/tokens.css`, not `dist/tokens.css`. The two are identical in a release,
|
|
4101
|
+
since `dist/` is rebuilt in the release commit, but can differ on a working
|
|
4102
|
+
checkout. `flexdesk/css/reset` is missing from 0.16.1 and earlier,
|
|
4103
|
+
where it fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`.
|
|
4104
|
+
|
|
4105
|
+
`createDetailHeader` follows the prefix too: a badge is a `.twm-detail-header__badge`
|
|
4106
|
+
pill (a badge's `className` is added beside it, to recolour it), and an action is a
|
|
4107
|
+
`.twm-btn` — `variant: 'primary'` adds `.twm-btn--primary`, `'danger'` adds
|
|
4108
|
+
`.twm-btn--danger` — that also carries `.twm-detail-header__btn` and
|
|
4109
|
+
`.twm-detail-header__btn--<variant>`. The unprefixed `detail-header__badge` and
|
|
4110
|
+
`detail-header__btn--<variant>` it emitted before, which no FlexDesk rule styled, are
|
|
4111
|
+
gone.
|
|
4112
|
+
|
|
4113
|
+
`@flexdesk/editor`'s components keep the class names they were extracted with, and
|
|
4114
|
+
`flexdesk.css` styles them: `SplitPaneContainer` (`.split-pane-container`,
|
|
4115
|
+
`.split-pane`, a 4px `.split-pane-divider` with a 10px hit target),
|
|
4116
|
+
`EditorPane` (`.editor-pane`, `.editor-pane__body`, `.notebook-editor-container`)
|
|
4117
|
+
and `NotebookSearchBar` (`.nb-search-bar*`). `setPanes()` adds `.split-pane` to the
|
|
4118
|
+
elements you hand it and leaves their own classes alone (it used to replace them,
|
|
4119
|
+
which stripped `.editor-pane` off an `EditorPane`).
|
|
4120
|
+
|
|
4121
|
+
One exception to the prefix: `HelpModal` (`@flexdesk/widgets`) keeps its older
|
|
4122
|
+
`help-` classes (`help-modal-overlay`, `help-modal`, `help-index`, …), and
|
|
4123
|
+
`flexdesk.css` styles them — a centred dialog on a dimmed overlay, drawn over
|
|
4124
|
+
managed windows. `.help-article` holds your provider's rendered HTML and styles
|
|
4125
|
+
its plain elements (headings, code, tables, lists).
|
|
1907
4126
|
|
|
1908
4127
|
Icons are rendered by applying the `material-symbols-outlined` class. FlexDesk does
|
|
1909
4128
|
not ship the font — supply it from Google Fonts or self-host. Without it, icons fall
|