flexdesk 0.17.2 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -63
  2. package/README.md +27 -4517
  3. package/css/base.css +18 -1
  4. package/css/flexdesk.css +18 -1
  5. package/dist/{chunk-4A7SJC3T.js → chunk-RKNFBX5K.js} +15 -4
  6. package/dist/{chunk-4A7SJC3T.js.map → chunk-RKNFBX5K.js.map} +2 -2
  7. package/dist/flexdesk.css +18 -1
  8. package/dist/tiles.js +1 -1
  9. package/dist/widgets.js +59 -26
  10. package/dist/widgets.js.map +2 -2
  11. package/docs/README.md +97 -0
  12. package/docs/best-practices.md +45 -0
  13. package/docs/components/charts.md +211 -0
  14. package/docs/components/command-palette.md +102 -0
  15. package/docs/components/conversation.md +72 -0
  16. package/docs/components/data-table-grid.md +328 -0
  17. package/docs/components/data-table.md +596 -0
  18. package/docs/components/editor.md +22 -0
  19. package/docs/components/flow-canvas-editor.md +482 -0
  20. package/docs/components/flow-editors.md +176 -0
  21. package/docs/components/flow-lane-editor.md +165 -0
  22. package/docs/components/flow-outline-editor.md +176 -0
  23. package/docs/components/forms-and-dialogs.md +294 -0
  24. package/docs/components/input-fields.md +72 -0
  25. package/docs/components/keymap.md +143 -0
  26. package/docs/components/layout-widgets.md +163 -0
  27. package/docs/components/markdown-and-outline.md +106 -0
  28. package/docs/components/menus.md +86 -0
  29. package/docs/components/notifications.md +194 -0
  30. package/docs/components/panels-and-desktops.md +127 -0
  31. package/docs/components/record-list-and-fact-table.md +128 -0
  32. package/docs/components/settings-view.md +112 -0
  33. package/docs/components/shell.md +189 -0
  34. package/docs/components/slide-out-panel.md +68 -0
  35. package/docs/components/tiles.md +210 -0
  36. package/docs/components/wizard.md +54 -0
  37. package/docs/concepts/building-and-packaging.md +33 -0
  38. package/docs/concepts/domain-neutrality.md +110 -0
  39. package/docs/concepts/host-port.md +137 -0
  40. package/docs/concepts/packages-and-entry-points.md +32 -0
  41. package/docs/concepts/peer-dependencies.md +42 -0
  42. package/docs/concepts/state-guard.md +58 -0
  43. package/docs/concepts/styling.md +47 -0
  44. package/docs/quickstart.md +179 -0
  45. package/package.json +2 -1
  46. package/src/ui/components/notification_history.js +17 -24
  47. package/src/ui/components/slide_out_panel.js +14 -3
  48. package/src/ui/notification_center.js +41 -3
  49. package/src/ui/utils/auto_scrollbars.js +16 -2
package/README.md CHANGED
@@ -2,4539 +2,49 @@
2
2
 
3
3
  A tiling window manager and widget set for desktop-class web applications.
4
4
 
5
- Tiles that split and tab, a command palette, dockable panels, virtual desktops, a
6
- DataTable with sticky headers and CSV export, managed windows, modals, toasts,
7
- trees and context menus.
5
+ Tiles that split and tab, a command palette, dockable panels, virtual desktops, a DataTable with sticky headers and CSV export, managed windows, modals, toasts, trees and context menus. Charts, a code editor, a dashboard grid and flow editors are separate entry points you add when you need them.
8
6
 
9
- **Zero runtime dependencies.** No React, no framework, nothing. If you install
10
- FlexDesk, you install FlexDesk.
7
+ - **Zero runtime dependencies.** No React, no framework, nothing. If you install FlexDesk, you install FlexDesk.
8
+ - **No application vocabulary.** FlexDesk was extracted from [EcoAgent](https://github.com/nor-os/EcoAgent), a macroeconomic simulation platform, and it knows nothing about economics. You bring the ontology, the host and the storage keys.
9
+ - **No build step needed.** The package ships a prebuilt `dist/`, so a plain import map is enough.
11
10
 
12
- FlexDesk was extracted from [EcoAgent](https://github.com/nor-os/EcoAgent), a
13
- macroeconomic simulation platform, and it knows nothing about economics: no
14
- application vocabulary, no import of anything application-specific, in any
15
- shipped file.
11
+ ## Install
16
12
 
17
13
  ```bash
18
14
  npm install flexdesk
19
15
  ```
20
16
 
21
- Or vendor `dist/` and load it through an import map — no bundler, no build step.
22
- That is exactly what EcoAgent does:
17
+ Then follow the [quickstart](docs/quickstart.md): a page, a shell and a table.
23
18
 
24
- ```html
25
- <script type="importmap">
26
- { "imports": {
27
- "@flexdesk/core": "./vendor/flexdesk/core.js",
28
- "@flexdesk/host": "./vendor/flexdesk/host.js",
29
- "@flexdesk/wm": "./vendor/flexdesk/wm.js",
30
- "@flexdesk/widgets": "./vendor/flexdesk/widgets.js"
31
- } }
32
- </script>
33
- <link href="./vendor/flexdesk/flexdesk.css" rel="stylesheet" />
34
- ```
35
-
36
- ## Nine entry points
37
-
38
- Because someone who wants a DataTable should not have to download a window
39
- manager, and neither of them should drag in a plotting engine or a code editor.
40
-
41
- | Entry | Size | What is in it |
42
- |---|---|---|
43
- | `@flexdesk/core` | 30 kB | Event bus, settings store, logging, state machine, state guard |
44
- | `@flexdesk/host` | 5 kB | The host port (below) |
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, 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
- | `@flexdesk/editor` | 62 kB | Monaco loader + factory, multi-file tab bar, search, split panes, undo manager |
50
- | `@flexdesk/canvas` | 14 kB | The canvas kernel: a pannable, zoomable surface, the edge router, a generic graph layer ([Flow editors](#flow-editors)) |
51
- | `@flexdesk/flow` | 381 kB | The flow-editor kit and the canvas, outline and lane editors ([Flow editors](#flow-editors)) |
52
-
53
- The sizes of `@flexdesk/canvas` and `@flexdesk/flow` are the entry files as
54
- `node build.mjs` reported them for the flow editors' integration build; like
55
- every entry they also import shared chunks. The other rows are older figures.
56
-
57
- They share code through chunks, so importing two of them does **not** give you two
58
- event buses — and therefore not two copies of every module-level singleton. A test
59
- asserts each kernel symbol is defined exactly once across the whole `dist/`.
60
-
61
- Two different things here are called "tiles", which is unfortunate but honest to
62
- the code: `@flexdesk/wm` tiles the *window*; `@flexdesk/tiles` is a dashboard
63
- *widget grid*.
64
-
65
- ## Hello, shell
66
-
67
- ```js
68
- import { createShell, createTaxonomy, createEntityCatalog } from '@flexdesk/wm';
69
- import { createHost } from '@flexdesk/host';
70
- import { DataTable } from '@flexdesk/widgets';
71
-
72
- const store = new Map();
73
-
74
- const shell = await createShell({
75
- root: document.getElementById('app'),
76
-
77
- // YOUR ontology. FlexDesk has none.
78
- taxonomy: createTaxonomy({
79
- root: 'cosmos',
80
- kinds: {
81
- cosmos: { label: 'Cosmos', isTopNav: true, sources: ['planet'] },
82
- planet: { label: 'Planet', icon: 'circle' },
83
- moon: {
84
- label: 'Moon',
85
- // "jupiter/europa" — your naming convention, not the library's.
86
- ancestors: ({ id }) => {
87
- const i = String(id).indexOf('/');
88
- return i > 0 ? [{ kind: 'planet', id: String(id).slice(0, i) }] : [];
89
- },
90
- },
91
- },
92
- }),
93
-
94
- entities: createEntityCatalog({ sources: [/* what the palette searches */] }),
95
- content: { planet: (hostEl, props) => { /* render a tile */ } },
96
-
97
- // Where persistence goes. An in-memory Map is a perfectly good host.
98
- host: createHost({
99
- state: {
100
- read: async (k) => store.get(k) ?? null,
101
- write: async (k, v) => (store.set(k, v), true),
102
- },
103
- }),
104
-
105
- // Chrome is ELEMENTS, not selectors. The library never guesses at your markup.
106
- chrome: { topNav: document.getElementById('nav') },
107
- });
108
- ```
109
-
110
- A working version of exactly this — planets and moons, a mock host, no backend —
111
- lives in `demo/`.
112
-
113
- ### Opt-in shell features
114
-
115
- These change what the user sees, so all of them are off until you ask:
116
-
117
- ```js
118
- await createShell({
119
- // …everything above…
120
- snapPromotion: true, // drag a floating window onto a tile: it snaps, and docks back in
121
- promoteInPlace: true, // a window floated out of a tile stays inside that tile's pane
122
- backToOpenList: true, // Back in a record opened beside its list closes onto that list
123
- floatActiveTab: true, // floating a tile takes the tab on screen, not the whole pane
124
- tabLayout: 'header', // tabs in the tile header, in place of its title, with a + (0.8.0)
125
- newTabButton: false, // …and without the + (0.8.1)
126
- chrome: {
127
- topNav: document.getElementById('nav'),
128
- zoom: document.getElementById('status-bar'), // content zoom, 50–200%
129
- },
130
- });
131
- ```
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
-
240
- ### An open single instance takes new props: `navigate(props)` (0.17.2)
241
-
242
- Revealing a `single` instance used to drop the props the open asked for: the
243
- observatory kept showing the planet it was opened with. A content factory that
244
- returns a `navigate(props)` hook from its mount is now handed them:
245
-
246
- ```js
247
- content: {
248
- observatory: (el, props) => {
249
- const view = mountObservatory(el, props);
250
- return {
251
- destroy: () => view.destroy(),
252
- navigate: (next) => view.focusPlanet(next.planet), // already open: go there
253
- };
254
- },
255
- },
256
-
257
- wm.navigate('observatory', { planet: 'mars' }); // open: revealed, then navigate({ planet: 'mars' })
258
- ```
259
-
260
- - **Every open path that reveals the instance calls it once**, after the
261
- reveal, with a copy of the new props — `navigate`, the top nav,
262
- `openInPrimary`, `openInTab*`, `splitLeafWith`, Back. The same props twice
263
- are two calls; nothing is deduplicated, and no second instance is mounted.
264
- - **The props are recorded on the instance's tab** (or its window's), so a
265
- top-nav page restore or a reload mounts it with the latest. The tile is not
266
- rebuilt by the write.
267
- - **An open that names no props only reveals** — the top-nav button,
268
- `openInPrimary(kind)`, `navigate(kind)`: it asks to see the instance, not to
269
- empty it, so the hook is not called and the tab keeps its props.
270
- - **A content without the hook is revealed as before**: its tab keeps its props
271
- and nothing is called. A hook that throws is a console warning.
272
-
273
- ### A link to a kind nobody registered (0.17.2)
274
-
275
- `wm.navigate(kind, props, opts)` returns `true` once it has routed the
276
- navigation. A `kind` the shell's `content` map has no factory for is refused
277
- instead, on every destination (`main`, `origin`, a new tab, a window, a split,
278
- the legacy `target`):
279
-
280
- ```js
281
- if (!wm.navigate(link.kind, link.props)) {
282
- // nothing opened: tell the user the link is dead
283
- }
284
- ```
285
-
286
- - **One `console.warn` naming the kind**, per call, and `false` back.
287
- - **Nothing changes**: no tile, tab, window or desktop, no per-tab history, no
288
- `onChange` — and no "no factory registered yet" placeholder tile, which is
289
- what such a link used to leave behind.
290
- - **No exception reaches the caller**, whatever `kind` is (`undefined`, `null`
291
- and `''` included).
292
-
293
- A shell that only navigates to kinds it registered sees no change; the return
294
- value is new, so code that ignored it is unaffected. A split destination used
295
- to hand back the new leaf's id; it now returns `true` like the others — call
296
- `wm.splitLeafWith(...)` for the id. A restored layout that names an
297
- unregistered kind still draws its placeholder: only `navigate` refuses.
298
-
299
- ### A pane that names what it shows: `ctx.setTitle` (0.12.1)
300
-
301
- A content factory receives `(hostEl, props, ctx)`, and `ctx` carries, per pane,
302
- `setTitle(text)`. It renames the pane's chrome title, at mount or any time
303
- after, and the title survives repaints — including a panel's, whose tree title
304
- the shell resets to Navigator / Inspector / Console on every layout pass:
305
-
306
- ```js
307
- content: {
308
- 'panel:bottom': (hostEl, props, ctx) => {
309
- // …a panel with tabs of its own…
310
- const show = (tab) => { /* swap the body */ ctx.setTitle(tab.label); };
311
- show(tabs[0]);
312
- return { destroy() {} };
313
- },
314
- },
315
- ```
316
-
317
- `setTitle(null)` or `''` gives the title back to the tree. It changes what the
318
- chrome shows only — the tree, the tab labels and the saved layout are left
319
- alone — and a call from content whose pane has since been rebuilt does nothing.
320
-
321
- ### Panels a page does not offer (0.17.0)
322
-
323
- A kind says which panels it offers while it is the primary tile. Each side is
324
- `true`, `false` or `{ reason }`; a side it does not name is offered, and a kind
325
- that does not name a side takes it from its top-nav kind, so one declaration on
326
- a page covers every record opened under it:
327
-
328
- ```js
329
- kinds: {
330
- paper: { label: 'Paper', isTopNav: true, panels: { bottom: { reason: 'No console here' } } },
331
- sheet: { label: 'Sheet', topNav: 'paper' }, // withholds it too
332
- slide: { label: 'Slide', topNav: 'paper', panels: { bottom: true } }, // offers it
333
- },
334
- ```
335
-
336
- While a panel is withheld it is not in the layout, `wm.togglePanel(side)`
337
- answers `false` and changes nothing (nor what is saved), `wm.isPanelOpen(side)`
338
- is `false`, and the shell's toggle button and the palette chip carry
339
- `aria-disabled="true"` with the reason as their tooltip (`data-tooltip` on a
340
- `twm-has-tooltip` button, `title` otherwise; the button's own comes back after).
341
- The desktop still remembers the panel was open, and it comes back open when the
342
- user returns to a page that offers it. With no declarations every panel is
343
- offered, exactly as before.
344
-
345
- Content overrides its kind at runtime, until it clears the override with `null`:
346
-
347
- ```js
348
- ctx.wm.setPanelAvailability('bottom', { reason: 'Run the model first' });
349
- ctx.wm.setPanelAvailability('bottom', null); // e.g. in destroy()
350
- ```
351
-
352
- `wm.isPanelAvailable(side)` and `wm.getPanelAvailability(side)` (`{ available,
353
- reason }`) read it, and a change is announced once — whatever caused it and
354
- however many sides it moved — to `wm.onPanelAvailabilityChange(fn)` (which
355
- returns the unsubscribe) and on the bus as `wm:panels:availability`, both with
356
- `{ panels, changed }`. `togglePanel` now answers `true` when it flipped a panel.
357
-
358
- `PanelStateMachine`, `PANEL_STATES` and `PANEL_TRANSITIONS` are gone from
359
- `@flexdesk/widgets`: nothing in the library used them, and which panels a page
360
- offers is now said once, in the taxonomy.
361
-
362
- `tests/panel_availability.test.mjs` drives all of it against a real shell.
363
-
364
- ### Panels keep the size you dragged them to (0.17.0)
365
-
366
- Each desktop remembers every panel's share of the window-manager area along
367
- its axis (width for left and right, height for bottom), taken when a splitter
368
- drag ends, and saves it with the layout as `panelShares`. A reload, a switch to
369
- another desktop and back, and toggling one panel all rebuild the panels at
370
- those shares, and a closed panel comes back at the size it had. Only a panel
371
- that has never been sized on that desktop opens at the default 1:4. No split is
372
- built tighter than a drag allows (`TileRenderer.MIN_SLOT_FRACTION` of it per
373
- slot). A `TileRenderer` built by hand takes `onSplitResized(splitId)`, called
374
- once when a drag that changed a split's sizes ends.
375
-
376
- `tests/panel_sizes_kept.test.mjs` covers each of these.
377
-
378
- ### A navigation panel you drive yourself: `mountNavPanel` (0.17.0)
379
-
380
- `mountNavPanel(hostEl, { modes })` (from `@flexdesk/wm`) builds a mode strip, a
381
- shared filter box and one lazily-mounted pane per mode. A strip click is no
382
- longer the only way to switch: the handle's `select(modeId)` takes the same
383
- path — it mounts the mode on first use, runs its `activate()`, restores that
384
- mode's filter text — and its promise resolves once `activate()` has run. An
385
- unknown id is ignored. `active()` names the mode shown.
386
-
387
- ```js
388
- const nav = mountNavPanel(hostEl, {
389
- modes: [
390
- { id: 'files', icon: 'folder', label: 'Files', mount: mountFiles },
391
- { id: 'runs', icon: 'history', label: 'Runs', mount: mountRuns,
392
- filter: false }, // a pane with controls of its own
393
- ],
394
- showModeStrip: false, // no strip: the app switches with select()
395
- initialMode: 'runs', // the mode shown at mount (default: the first)
396
- });
397
- await nav.select('files'); // mounted and activated
398
- nav.active(); // 'files'
399
- ```
400
-
401
- A mode with `filter: false` hides the filter box while it is active, and its
402
- pane is never handed a query; the next mode with a filter shows its own saved
403
- text again. `showFilter: false` renders no filter box at all. All three options
404
- default to the old behaviour, so a panel built without them renders exactly as
405
- before.
406
-
407
- ### Buttons that change with the pane: `ctx.setChromeActions` (0.17.0)
408
-
409
- A factory may return `chromeActions` — `[{icon, title, onClick}]`, drawn in the
410
- pane's chrome left of the split/float/close buttons — but those are read once.
411
- `ctx.setChromeActions(specs)` replaces them at any time, the way `setTitle`
412
- replaces the title: called during the mount it wins over the returned list, the
413
- later call wins, the list survives repaints, `null` or `[]` clears it, and a
414
- call from content whose pane has since been rebuilt does nothing. The content is
415
- never re-mounted.
416
-
417
- ```js
418
- content: {
419
- page: (hostEl, props, ctx) => {
420
- let wide = false;
421
- const paint = () => ctx.setChromeActions([
422
- { icon: wide ? 'width_normal' : 'width_wide', title: 'Page width',
423
- pressed: wide, onClick: () => { wide = !wide; paint(); } },
424
- { separator: true },
425
- { icon: 'add', label: 'New', title: 'Add a page', onClick: addPage },
426
- { icon: 'publish', title: 'Nothing to publish yet', disabled: true },
427
- ]);
428
- paint();
429
- return { destroy() {} };
430
- },
431
- },
432
- ```
433
-
434
- A spec may add **`label`** (text beside the glyph, or instead of it),
435
- **`pressed`** (`aria-pressed`, plus a pressed style; leave it out for a button
436
- that is not a toggle), **`disabled`** (greyed and inert, with its tooltip kept —
437
- it is `aria-disabled`, not the `disabled` attribute) and **`{separator: true}`**
438
- (a divider). A spec with none of them renders exactly as before.
439
-
440
- ### Your desktops beside the app's (0.10.0)
441
-
442
- Pass `chrome.desktops` and the shell paints a desktop bar: a chip per desktop
443
- and a `+`. An app that keeps desktops of its own (one per page, say) marks them
444
- on the desktop objects themselves:
445
-
446
- ```js
447
- const shell = await createShell({ /* … */ eventBus,
448
- chrome: { desktops: barEl }, desktopBar: { switchOnAdd: true } });
449
- shell.wm.desktops.ensureCount(pages.length);
450
- shell.wm.desktops.desktops.slice(0, pages.length)
451
- .forEach((d) => { d.group = 'Workbench'; d.fixed = true; });
452
- eventBus.emit('wm:changed', {}); // repaint the bar
453
- ```
454
-
455
- Desktops that share a **`group`** are one chip, labelled by the group, and one
456
- stop for Ctrl+Left/Right; the chip goes back to the member that was on screen
457
- last (`wm.desktops.chips()` says what the bar draws). A **`fixed`** desktop is
458
- the app's: the bar offers no rename and no delete, no menu offers it as a
459
- *Move to desktop* target, and `removeDesktop`, `moveFocusedToDesktop` and
460
- `moveWindowToDesktop` refuse it. Both are persisted. A desktop the user adds is
461
- numbered among the ungrouped ones, and `desktopBar.switchOnAdd` puts it on
462
- screen. With neither set anywhere, the bar is what it always was.
463
-
464
- *Move to desktop* now carries every tab of the pane and adds them to the target's
465
- main pane; it used to replace that pane with the active tab alone. A target still
466
- holding only its seed is replaced, as before. `dispose()` removes the bar, so a
467
- rebuilt shell paints a fresh one.
468
-
469
- ### The palette that finds anything (0.9.0; matching tightened in 0.9.1)
470
-
471
- Ctrl+K's grouped layout. Every option is off unless you pass it, so a shell that
472
- passes none of them keeps the chip strip, the panel toggles and the flat list:
473
-
474
- ```js
475
- await createShell({
476
- // …everything above…
477
- palette: {
478
- placeholder: 'Search everything…',
479
- layout: 'grouped', // a header per kind, matches marked
480
- chrome: { pages: false, panels: false }, // nothing between the input and the results
481
- groupCap: 4, // then "Show all N", which scopes to the kind
482
- cache: true, // a re-open draws the last catalogue at once
483
- empty: async () => recentRows(), // shown before anything is typed, under "Recent"
484
- commands: () => commandRows(), // `{label, action}`; `>` restricts to them
485
- drill: (pick) => pick.kind === 'planet' // Tab on a row enters a scope
486
- ? { label: `${pick.label} › moons`, search: (q, signal) => findMoons(pick.id, q, signal) }
487
- : null,
488
- onPick: (pick, { mode, scope }) => openSomewhere(pick, mode), // 'open' | 'split' | 'window'
489
- },
490
- });
491
- ```
492
-
493
- A row may carry `context` (drawn, not searched), `keywords` (searched, not
494
- drawn), `modes` (which of `split`/`window` its keys offer), `drillLabel` (the Tab
495
- hint), `boost`, `group`/`groupLabel`, `kindLabel`, `shortcut` and `verb`. A group's
496
- header is the taxonomy's `plural` for the kind, else its `label`. Matching ranks
497
- exact, prefix, word starts (`ord li` finds *Order lines*), substring, then an
498
- abbreviation: it starts at a word, and the letters it skips outside word starts are
499
- at most half the query (`cstmr` finds *customer*; `cour` does not find *Scroll up
500
- to*). A drill's `search` is debounced, and a stale answer is aborted and
501
- never drawn. `palette.invalidate()` drops the cache.
502
-
503
- **The built-in *Keyboard shortcuts* command** opens the help topic
504
- `keyboard-shortcuts`. If your `HelpProvider` defines that id, your topic is
505
- shown. If it does not, or you register no help at all, the modal shows the window
506
- manager's own bindings, `KEYMAP_SHORTCUTS` from `@flexdesk/wm`, as a table
507
- (`keyboardShortcutsTopic()`). Before this the modal showed *Failed to load help
508
- topic.* The fallback comes from `HelpModal.open(id, { fallback })`
509
- (`@flexdesk/widgets`), and you can pass one yourself. A topic id that nobody
510
- provides now shows *Help topic not found.* where it used to raise a load error.
511
-
512
- ### A palette row drawn disabled, with a reason (0.17.2)
513
-
514
- A palette row (a command from `commands`, or any other row) may carry
515
- `enabled: () => boolean` and `disabledReason`. While `enabled()` returns false
516
- the row is still listed, so it can say why it does nothing:
517
-
518
- ```js
519
- palette: {
520
- commands: () => [
521
- { id: 'save', label: 'Save', action: save,
522
- enabled: () => dirty, disabledReason: 'Nothing to save' },
523
- ],
524
- },
525
- ```
526
-
527
- - The row gets the class `twm-cmdpal__item--disabled` and `aria-disabled="true"`,
528
- and `disabledReason`, when given, is its `title` (the tooltip). The stylesheet
529
- greys it.
530
- - Picking it with a click, Enter, Ctrl+Enter or Shift+Enter runs nothing: not
531
- its `action`, not `onPick`. The palette stays open on it.
532
- - The arrow keys still move onto it and past it.
533
- - `enabled()` is called again on every draw and every pick, so the row follows
534
- your state while the palette is open. An `enabled()` that throws counts as
535
- false.
536
- - A row without `enabled` is drawn and picked as before. Both layouts (flat and
537
- `grouped`) behave the same way.
538
-
539
- `tests/command_palette_disabled.test.mjs` drives it in jsdom.
540
-
541
- ### A catalog source only behind its prefix: `scopedOnly` (0.17.2)
542
-
543
- A source can be too broad to compete with everything else in the palette: help
544
- topics, say, which would crowd every query. Mark it `scopedOnly` and its rows are
545
- listed only once its prefix has made the type chip:
546
-
547
- ```js
548
- createEntityCatalog({
549
- sources: [
550
- { navKind: 'model', list: listModels, shape: toRow },
551
- { navKind: 'topic', list: listTopics, shape: toRow, scopedOnly: true },
552
- ],
553
- aliases: { help: ['topic'] },
554
- });
555
- ```
556
-
557
- A plain typed query (`solver`) lists no `topic` row, in the flat and the grouped
558
- layout alike: no row, no group header, no *Show all*. `help: solver` or
559
- `topic: solver` lists its rows as any source's, and `help:` alone lists them all.
560
- The mark is the palette's business: `loadAll`, `loadGrouped` and the tile menu
561
- still return every source, a picked row reaches `onPick` as its `shape` made it,
562
- and commands and `empty()` rows of the same kind are not held back. A source
563
- without the option, or with `scopedOnly: false`, is searched exactly as in
564
- 0.17.1. A value that is not a boolean is refused with a `TypeError`.
565
-
566
- ### Your keys, not the window manager's: `keymap` (0.17.0)
567
-
568
- The window manager binds F1..F12, Backspace, Ctrl+K, Ctrl+Tab, Ctrl/Shift+Arrows,
569
- the Alt chords, `?` and Esc, and swallows any other bare Alt+letter. Each of
570
- those chords can be taken back or rebound:
571
-
572
- ```js
573
- await createShell({
574
- // …everything above…
575
- keymap: {
576
- bindings: {
577
- 'F1': false, // F1 is the page's: not prevented, nothing clicked
578
- 'F2': 'nav:0', // F2 opens the first top-nav kind
579
- 'Backspace': false, // Backspace never calls navigateBack
580
- 'Alt+W': (e, { wm }) => { /* yours; return false to leave e unprevented */ },
581
- },
582
- },
583
- });
584
- ```
585
-
586
- A value is `false`, an action name, or a function. The actions are `palette`,
587
- `palette:close`, `help`, `back`, `nav:N`, `desktop:N`, `toDesktop:N` (all
588
- counted from 0), `desktop:next|prev`, `tab:next|prev`, `panelTab:next|prev`,
589
- `split:h|v`, `splitWith:h|v`, `openInTab`, `openInWindow`, `close`, `float`,
590
- `focus:left|right|up|down`, `move:left|right|up|down` and `suppress`. The chord
591
- ids are the defaults in `src/tiling/keymap.js`: `Ctrl+K`, `Meta+K`, `?`,
592
- `Escape`, `F1`..`F12`, `Backspace`, `Alt+H`, `Alt+Shift+H`, `Alt+ArrowLeft`,
593
- `Alt+Shift+1`, `Ctrl+Tab`, `Shift+ArrowRight` and so on, with `Alt+Letter` for
594
- the Alt+letter suppression. Modifiers may come in any order and case. A chord id
595
- or action the keymap does not know is warned about once, through `logger` (else
596
- `console`), when the shell is built, and is then ignored. Only the window
597
- manager's own chords can be rebound. To add a new key, put your own listener on
598
- the page. `keymap` itself must be an object whose only key is `bindings`:
599
- anything else (a string, a typo such as `{ binding: … }`) makes `createShell`
600
- throw a `TypeError`, so a binding you meant to change never stays in force
601
- unnoticed.
602
-
603
- **A key press is read as loosely as before.** With no bindings every key does
604
- what it did in 0.16, and a binding takes over every key press its chord was
605
- read as. Backspace ignores Shift and Alt, so `{ Backspace: false }` also hands
606
- Shift+Backspace and Alt+Backspace to the page. Alt+T/N/W/F ignore Shift. With
607
- Alt held, Ctrl and Meta are ignored, so Alt+Ctrl+H is `Alt+H`. Ctrl+K and
608
- Ctrl+Tab also match with Meta held, and Ctrl wins when both are held:
609
- Ctrl+Meta+K is `Ctrl+K`, and Meta+K is `Meta+K` only without Ctrl. Esc and `?`
610
- ignore every modifier. F1..F12, Shift+Left/Right and Ctrl+Left/Right match only
611
- with exactly those modifiers.
612
-
613
- **A key a component handled is the component's.** The keymap skips every
614
- keydown that is already `defaultPrevented`. A list that deletes a row on
615
- Backspace calls `preventDefault()`, and the window manager does not navigate
616
- back as well.
617
-
618
- **`?` is installed by the shell.** Importing `HelpModal` no longer adds a
619
- `document` listener as a side effect. A shell binds `?` as one of its chords,
620
- and `shell.dispose()` removes it with the rest. A page that uses the help modal
621
- without a shell calls `installHelpShortcut()` from `@flexdesk/widgets`, which
622
- returns its disposer. `HelpModal.toggle()` is what `?` does.
623
-
624
- **The *Keyboard shortcuts* command shows the bindings in effect.** It lists
625
- `keymapShortcuts(bindings)` from `@flexdesk/wm`. That is `KEYMAP_SHORTCUTS`
626
- with each changed row replaced by one line per chord still bound, and no line
627
- for a chord you disabled. `createCommandPalette({ shortcuts })` takes that table.
628
- The command's hint names the keys that open help in it: `?` by default, `F9`
629
- after `{ '?': false, F9: 'help' }`, and none when no key opens help (for
630
- example after `{ '?': 'palette' }`).
631
-
632
- **`chrome.zoom`** paints a − / track / + / readout control into the element you
633
- pass, restores the saved zoom through the host's `state` capability, and scales
634
- the content of tiles and floating windows. It deliberately scales *content only*
635
- — never the root, a tile's frame or a window's frame — because CSS `zoom`
636
- establishes a scaled coordinate space and every window drag, resize and snap
637
- measures in real pixels. `shell.chrome.zoom` exposes `get()` and `set(percent)`
638
- for a settings pane. An embedder that builds its own `WindowManager` instead of
639
- calling `createShell` mounts the same control with `mountZoomControl(el, { root,
640
- host })`.
641
-
642
- **`backToOpenList`** is for apps that open a record from a list in a new tab.
643
- Without it, Back in that record has no history to pop, so it walks the taxonomy
644
- up and rewrites the record into its parent, and the tile now shows the list
645
- twice. With it, Back closes the record tab and activates the list it came from:
646
- the nearest tab to the left, else to the right, that is not a record (no
647
- `props.id`) and belongs to the same top-nav section. Per-tab history still comes
648
- first, and a record with no such list beside it still walks up in place.
649
-
650
- **`floatActiveTab`** is for apps whose tabs are separate records rather than
651
- views of one pane. The float button, Alt+F and the tile menu take only the tab
652
- on screen and leave its siblings in the tile, so the window holds one tab and
653
- draws no strip. Without it the whole pane floats, strip and all.
654
-
655
- **`tabLayout`** says where a tile draws its tabs. `'bottom'` (the default) is
656
- the spreadsheet strip under the body, shown from two tabs. `'top'` is the
657
- editor tab bar between the header and the body, also from two tabs.
658
- `'header'` (0.8.0) puts that tab bar inside the header in place of the title,
659
- on every tile, one tab included, with a `+` after the last tab that opens the
660
- taxonomy root in a new tab (`wm.newTab(leafId)`, the same seed a split uses).
661
- Pass `newTabButton: false` (0.8.1) to leave the `+` out.
662
- A panel tile has no tabs and keeps its title. Under `'header'` the tab, not
663
- the header, is what you drag to move a single-tab pane; the empty part of the
664
- header still floats the pane on a pull-down or a double-click. Set
665
- `data-twm-tabs` on the shell's root, or call `wm.setTabLayout()`, to switch
666
- live; nothing mounted in a tile is unmounted.
667
-
668
- **Back and the breadcrumb are one rule.** `wm.navigateBack()` (Backspace) and
669
- `wm.navigateUp(kind, props, { ctx })` (every breadcrumb crumb) share it. With
670
- `backToOpenList`, a record whose section has a list open closes onto that list;
671
- otherwise Back walks the taxonomy up and a crumb navigates to its level, in
672
- place. A crumb naming an entity, such as an epic above a story, opens that
673
- entity. `navigate` stays the verb for going to exactly a target; use
674
- `navigateUp` for anything that means "up to this level".
675
-
676
- A filter or other props passed to `openInPrimary` for a list that is already
677
- showing are applied to it. A bare open, such as a top-nav click, keeps the tab
678
- as you left it.
679
-
680
- **Back in a floating window** always acts on that window, whichever option is
681
- set. The last click decides: inside a window, Backspace walks that window up;
682
- anywhere else, it walks the focused tile as before. With `backToOpenList`, a
683
- record in a window closes onto an open list of its section, first among the
684
- window's own tabs and then in any tile of its desktop.
685
-
686
- **A page's own levels come first** (0.4.7). A page can have levels that are not
687
- tabs, such as a section whose list opens one member in place of itself. Return
688
- `navigateBack()` from the content factory's mount, and Backspace asks it before
689
- anything above: it climbs one of the page's own levels and returns `true`. Only
690
- a strict `true` counts. `false`, any other value, a missing function or a throw
691
- (logged as a warning) all let Back walk the tab history, the open list and the
692
- taxonomy exactly as before. It is asked in the same place as the rest of Back:
693
- the window you last clicked in, else the focused tile. Return
694
- `canNavigateBack()` as well, and `wm.canNavigateBack()` reports the page's own
695
- levels, for that same window or tile. `TileRenderer.contentOf(leafId)` reads
696
- what a tile's factory returned. To draw the page's levels, call
697
- `renderBreadcrumb(container, segments)` from `@flexdesk/wm`, with
698
- `segments = [{ label, icon?, onClick? }]`. It uses the tile breadcrumb's
699
- markup, so it looks the same, and each `onClick` is yours: the last segment,
700
- and any without `onClick`, is drawn as plain text. The current segment's item
701
- carries `aria-current="page"`, and every label has a `title` with its full text,
702
- so a label your CSS truncates still reads whole (0.15.1).
703
-
704
- ### Column sizing
705
-
706
- `fitColumns(columns, avail)` (from `@flexdesk/widgets`) turns per-column
707
- measurements into widths. Pass each column's widest cell (`natural`), the width
708
- most cells fit (`typical`), the header label (`header`) and any user-dragged
709
- width (`pinned`). Short columns such as ids, dates and statuses keep their full
710
- values, and free-text columns (`text`, or anything 240px and wider) take the
711
- rest. When space runs short, short columns lose their rare outliers first,
712
- then text shrinks to a floor. The widths are fractional and add up to `avail`
713
- exactly whenever the floors fit. `DataTable` does not use it yet; an embedder
714
- that measures its own columns can.
715
-
716
- ### Sorting a paged DataTable
717
-
718
- A client-paged `DataTable` (no `onPageChange`) keeps its page when it is
719
- sorted, so a sort on page 3 shows rows 201–300 of the new order. Pass
720
- `resetPageOnSort: true` (0.4.7) to start every new order at the first page, a
721
- header click included, the way a new filter already does. It is off by default
722
- because keeping the page is what every earlier release did. Server-side paging
723
- is not affected: its page is your `offset`, which a sort never changes.
724
-
725
- ### DataTable options in 0.5.0: lists that need no help
726
-
727
- 0.5.0 adds options for the ordinary list in an application: one that sits in a
728
- page, opens a row, marks the open one, and sorts dates as dates. Every option
729
- is off until you set it. A table that sets none of them looks and behaves as it
730
- did in 0.4, apart from the few changes listed at the end of this section. This
731
- was measured in headless Edge: 64 cells in 7 default tables, an empty and a
732
- loading one among them, computed the same under 0.4.7 and 0.5.0.
733
-
734
- ```js
735
- import { DataTable } from '@flexdesk/widgets';
736
-
737
- // House rules once, then one call per list: build AND draw.
738
- const ListTable = DataTable.withDefaults({
739
- mode: 'compact', sortable: true, pagination: false,
740
- fitContent: true, firstColumn: 'plain', autoDispose: true,
741
- });
742
-
743
- const table = ListTable.mount(host, {
744
- headers: ['Name', 'Role', 'Last seen', 'Took'],
745
- rows, // values, not formatted text
746
- columnTypes: [null, null, 'datetime', 'duration'],
747
- getRowKey: (row) => row[0],
748
- activeRow: openName,
749
- onRowActivate: (i, row) => open(row), // one click opens
750
- rowClass: (row) => (row[1] === 'Suspended' ? 'is-off' : null),
751
- emptyState: 'Nobody yet.',
752
- });
753
- ```
754
-
755
- **Layout**
756
-
757
- | Option | What it does |
758
- |---|---|
759
- | `fitContent: true` | Makes the table as tall as its rows. By default the wrapper is `height: 100%`. In a parent without a height (an ordinary block in a page that scrolls) that resolves to nothing, so the header draws and the rows do not. |
760
- | `maxHeight: 240` | Implies `fitContent` and caps the table at this height. A number is px; a string is used as CSS. Past the cap, the body scrolls under its header. |
761
- | `firstColumn: 'plain'` | Styles the first column like the others. By default it is styled as a time column: 120–150px wide, grey, weight 500. |
762
- | `--twm-dt-cell-pad-block`, `--twm-dt-cell-pad-inline`, `--twm-dt-cell-line-height` | Three CSS tokens. Set them on `:root` or on any ancestor to give both densities one row height. They are unset by default, and then each density keeps its own 0.4 values. In Edge, with the tokens set to `2px`, `6px` and `1.35`, normal and compact rows both measured 21.19px. Without them, normal rows measured 27px. The normal density reads the line-height token on the table's `thead`/`tbody` and the cells inherit it, so your own line-height for the cells, the rows or the table still wins, from a cascade layer too. |
763
-
764
- **Opening a row, and which one is open**
765
-
766
- | Option | What it does |
767
- |---|---|
768
- | `onRowActivate(rowIdx, row, ev)` | Opens a row. It fires after the selection has been updated (`onRowClick` still fires before it, as in 0.4). It does not fire for a press on a control in the row (a button, link, field, label, `<summary>`, editable text, or `[data-twm-action]`), for a click that ends a drag selecting text in the table, for the second click of a double-click, or for the Ctrl click that takes a `selectable: 'single'` table's row off the selection. `ev.type` tells you which gesture fired it. If your `onSelectionChange` or `onRowClick` redraws the table (`setData`, `render`), the row that was pressed still opens; `row` is that row as it was drawn. |
769
- | `activateOn` | Which gestures open a row: `'click'` (the default), `'dblclick'`, `'enter'`, or an array of them. A pick list, where one click selects, uses `['dblclick', 'enter']`. Enter opens the selected row (the one row selected, or the selection's anchor), which is the row a double-click on it would open. If nothing is selected, Enter opens the active row. |
770
- | `clickable` | Adds `twm-dt--clickable` to the table, which gives rows a pointer cursor and a hover, readonly tables included. It defaults to on exactly when `onRowActivate` is set. |
771
- | `selectable: 'single'` | Lets a click select exactly one row. Shift and Ctrl do not extend the selection, Ctrl+A selects nothing, and `setSelection` keeps the last index it is given. |
772
- | `getRowKey(row, idx)`, `activeRow`, `setActiveRow(key)`, `getActiveRow()` | Mark the open row: `twm-dt-row--active` and `aria-current="true"`, drawn as an accent on the left edge. The mark is not the selection. It survives `setData`, sorting, filtering and paging, and a right-click does not move it. |
773
-
774
- The landing helper `attachLandingTableBehavior` now uses the same rule as
775
- `onRowActivate` to decide whether a click opens a row. That changes three
776
- things, with no option (see the list at the end of this section).
777
-
778
- **Row state**
779
-
780
- | Option | What it does |
781
- |---|---|
782
- | `rowClass(row, idx)`, `rowAttrs(row, idx)` | Add classes and attributes to the row's `<tr>`. They follow the row through sorts and filters, and `updateRow` asks again, so a class the row no longer earns is removed. |
783
- | `cellClass(value, colIdx, row, idx)` | Adds classes to a cell, after its type class. |
784
- | `rowIcon(row, idx)` | Draws an icon (`'name'` or `{ icon, title, tone }`) at the start of the first cell. It is drawn from an attribute, so the icon's name is not part of the cell's text, its tooltip or a copy. |
785
- | `emptyState` | Text, a node, or a function, shown in place of `emptyMessage`. It is hidden while `setLoading(true)` is on. |
786
- | `setError(message \| node \| Error \| null)` | Shows a failure inside the table. If there are rows on screen, they stay, under a `role="alert"` banner. If there are none, the failure takes the place of the empty row. `setData({ rows })` and `setError(null)` clear it. |
787
- | `showRowNumbers: 'position'` | Numbers rows 1, 2, 3 in the order shown, after a sort, a filter, or on a later page. `true` still numbers rows by their original index. |
788
- | `nullDisplay: 'NULL'` | Draws `null` and `undefined` as this text, adds `twm-dt-cell--null` to the cell, and copies the text as well. |
789
-
790
- **Values, not text**
791
-
792
- | Option | What it does |
793
- |---|---|
794
- | `columnTypes: [..., 'date' \| 'datetime' \| 'duration']` | Sorts and filters the column by its value: an ISO 8601 string, a `Date` or epoch ms for dates, and milliseconds or text such as `'3.2 s'` for durations. A date-only value such as `2026-10-05` is a calendar date, so the time zone never moves it. Any other text, and a date that does not exist (`2026-02-31`), is shown as it came and sorts last. Non-ISO text is not passed to `Date.parse`, because browsers read it differently (V8 reads `'12'` as a date in 2001). Only `columnTypes` makes a column a typed column. A `getColumnType` that returns `'date'` still sets only the cell's class name, as in 0.4. |
795
- | `dateFormat`, `dateTimeFormat` | **Default ISO: `YYYY-MM-DD` and `YYYY-MM-DD HH:mm`.** Pass a pattern (`DD.MM.YYYY`, `D MMM YYYY, h:mm a`, with `[literal]` text) or a function `(date) => string`, for every column or per column index. |
796
- | `durationFormat` | `'auto'` (`850 ms`, `3.2 s`, `2m 5s`, `1h 2m`), `'clock'` (`1:02:05`), or a function. |
797
- | `dateTimeZone: 'UTC'` | Reads and draws typed dates in UTC instead of the local zone. |
798
- | `sortValue(value, colIdx, row)` | Sets what a column sorts by, for every column or (as an object or array) per column. Return `undefined` to fall back to the column's own rule. |
799
-
800
- The column filters work on values. On a date column, `2026-10` matches October,
801
- `>2026-10` matches after October, `<=2026-10-05` includes the 5th, and
802
- `2026-01..2026-03` matches January through the end of March. A date operand is
803
- ISO with two-digit parts, so an operator over a half-typed one (`>20`,
804
- `>2026-1`) filters nothing until it is complete. Text without an operator that
805
- is not such a date (`5 Oct`, `Oct`, `2026-1`) is matched against the cell as
806
- drawn, so a filter narrows as you type it. On a duration column, `>1s`,
807
- `<500ms` and `1s..1m` work. `formatDate`, `formatDuration`, `parseDateValue`
808
- and `parseDuration` are exported, so text drawn elsewhere can use the same
809
- words as the table.
810
-
811
- **Housekeeping**
812
-
813
- | Option | What it does |
814
- |---|---|
815
- | `DataTable.mount(host, config)` | Builds the table and draws it. The constructor still draws nothing until `render()`. |
816
- | `DataTable.withDefaults(defaults)` | Returns a subclass whose `defaults` sit under every config it is given. `instanceof DataTable` holds, and it can be narrowed again. |
817
- | `autoDispose: true` | Disposes the table once it has been taken out of the document and is still out a task later. It lets go of everything `dispose()` does, but it removes only the table's own elements and never empties the host. By then you may have mounted the next list into the host or written your own message there, and that stays. Do not set it on a table you detach and attach again later, such as a cached tab, because a disposed table comes back empty. |
818
- | `resetColumnWidths()` | Forgets every dragged, fitted or restored width, the persisted ones included, and measures the columns again. Use it after a zoom or a font change. |
819
-
820
- **Changes that need no option.** A table that sets none of the options above
821
- behaves as it did in 0.4, except for these:
822
-
823
- - The copy menu's document listeners (click, scroll, Escape, resize) now exist
824
- only while the menu is open. Before 0.5.0, a table that had ever been
825
- right-clicked kept them until `dispose()`.
826
- - `attachLandingTableBehavior` judges a click by the `onRowActivate` rule:
827
- - A double-click opens the row once, not twice.
828
- - A click while text in the host is selected does not open the row. That is
829
- the click that ends a drag across a cell.
830
- - A click on a `<label>`, a `<summary>` or editable (`contenteditable`) text
831
- in a row does not open the row either. 0.4.7 skipped only buttons, links
832
- and form fields. A `[data-twm-action]` control still runs its action.
833
- - The CSS reads the new tokens with the 0.4 values as fallbacks, so the
834
- computed styles are the same while the tokens are unset. One exception: the
835
- normal density's line-height token is declared on the `thead` and `tbody`.
836
- A line-height rule of yours aimed at those two elements themselves now loses
837
- to it if it is in a cascade layer, or has zero specificity and loads before
838
- `flexdesk.css`. A rule for the cells, the rows or the table does not.
839
-
840
- ### Comparing two tables: `diff` and `createScrollLock`
841
-
842
- A side-by-side comparison is two `DataTable`s drawn from ONE aligned row list:
843
- the rows as they are on one side, as they were on the other, and a gap where
844
- a side has no row. Both options are off until you set them.
845
-
846
- ```js
847
- import { DataTable, createScrollLock } from '@flexdesk/widgets';
848
-
849
- const lock = createScrollLock({ axis: 'y' }); // 'x', 'y' or 'both'
850
- const side = (host, pick) => DataTable.mount(host, {
851
- headers, rows: aligned.map((r) => pick(r) ?? blank), pagination: false,
852
- scrollLock: lock,
853
- diff: (_row, i) => ({ status: statusOf(aligned[i], pick), cells: changedCols(aligned[i]) }),
854
- });
855
- side(left, (r) => r.now);
856
- side(right, (r) => r.then);
857
- lock.setEnabled(false); // scroll each side on its own; true brings them level
858
- ```
859
-
860
- - `diff: (row, rowIdx) => {status, cells?}` draws the row
861
- `twm-dt-diff twm-dt-diff--<status>`, for `added`, `removed`, `changed`,
862
- `same` or `gap`, and each column index in `cells` (an array or a Set)
863
- `twm-dt-diff__cell--changed`. `null` or any other status draws the row as
864
- without `diff`. A `gap` row's cells are drawn empty, whatever its values and
865
- `renderCell`, so the two sides keep their rows level. `updateRow` asks again.
866
- - `scrollLock` takes a group from `createScrollLock()`. The table's body box
867
- joins it on every render and leaves on the next render and on `dispose()`.
868
- Any element can join with `lock.attach(el)`, which returns its detach. A
869
- box that joins starts at the group's last position, and the echo of a
870
- position the lock itself wrote moves nobody back. The lock copies pixels, so
871
- it lines rows up only when both sides draw rows of the same height.
872
-
873
- ### A server's rows in pages or as one list: `paging`
874
-
875
- `paging` (off unless you set it) shows rows that your code fetches from a
876
- server either one page at a time or as one list that loads more as the reader
877
- scrolls. A strip under the body shows which rows are loaded. It has the page
878
- buttons or a *Load all rows* button, and a Pages / Scroll switch if you pass
879
- `onModeChange`. Set `paging` and the old pager (`pagination`) is not drawn.
880
-
881
- ```js
882
- new DataTable(host, {
883
- headers, rows, // the rows you hold: one page, or all scrolled so far
884
- virtualize: true,
885
- paging: {
886
- mode: 'scroll', // what was asked for: 'pages' or 'scroll'
887
- pageSize: 1000,
888
- softLimit: 10000, // above this many rows the table is always paged
889
- total: 22521, // exact count, or null if unknown
890
- atLeast: 0, // a lower bound when the count is not known
891
- offset: 0, // in pages: where rows[0] is in the source
892
- hasMore: true,
893
- onPage: (offset, limit) => fetchPage(offset),
894
- onLoadMore: () => fetchNext(), // called near the end of the scroll
895
- onLoadAll: () => fetchRest(),
896
- onModeChange: (mode) => remember(mode),
897
- },
898
- });
899
- ```
900
-
901
- Your code holds the rows and fetches them. The table decides how to show them
902
- with `resolvePaging`, which is exported so your code gets the same answer when
903
- it decides what to fetch. A source with more rows than `softLimit` is shown in
904
- pages even if scroll was asked for. The strip says so, and Scroll is disabled
905
- with the reason. `softLimit` can't be more than `hardLimit` (default and
906
- ceiling `PAGING_HARD_LIMIT`, 100,000), and a scrolling table never asks for rows
907
- past it. If no count is known, the rows already loaded count as evidence: a
908
- table that has loaded more than `softLimit` rows and still has more is paged.
909
- While scrolling, `onLoadMore` is called within 400px of the end, or after
910
- render when the rows don't fill the box. It is never called when the box has
911
- no height, for example in a hidden tab. A callback that returns a Promise
912
- blocks the next call until it settles, and the strip shows the table busy.
913
- `setPaging(patch)` redraws the strip only, for example when a count arrives.
914
- In pages, `showRowNumbers: 'position'` numbers rows from `offset`. Row indexes
915
- (`__rowIndex`, the selection, `onRowClick`) still refer to positions in `rows`.
916
- In 0.7.0 the table can fetch the rows for you: see `dataSource` with `paging`
917
- below.
918
-
919
- ### Record lists and fact sheets (0.7.0)
920
-
921
- Two read-only components for the parts of an application page that are not a
922
- table of rows. Both are new: nothing that exists changes, and a page that does
923
- not call them looks as it did. Neither needs a height: each is exactly as tall
924
- as its contents, in any parent.
925
-
926
- **`createRecordList(host, config)`** draws a list of records, each as a name
927
- with a quiet line of facts under it, pills and verbs beside them, and a state.
928
- It is for the administration list of a few records with their own actions
929
- (keys, connections, deliveries, members), where a table would be the wrong
930
- shape.
931
-
932
- ```js
933
- import { createRecordList } from '@flexdesk/widgets';
934
-
935
- const list = createRecordList(host, {
936
- records: keys,
937
- describe: (k) => ({
938
- name: k.name,
939
- facts: [k.key_id, k.role, k.expires ? `expires ${k.expires}` : 'never expires'],
940
- pills: [k.paused ? { text: 'paused', tone: 'warn' } : { text: 'live', tone: 'success' }],
941
- state: k.revoked ? 'off' : k.paused ? 'paused' : 'active',
942
- verbs: [
943
- { label: k.paused ? 'Resume' : 'Pause', onClick: (key) => toggle(key),
944
- refusal: k.revoked ? 'This key is revoked.' : null },
945
- { label: 'Revoke', danger: true, onClick: (key) => revoke(key) },
946
- ],
947
- }),
948
- getKey: (k) => k.key_id,
949
- rowAttrs: (k) => ({ 'data-key-id': k.key_id }),
950
- onActivate: (k) => open(k), // one click, or Enter
951
- activeKey: openKeyId,
952
- emptyState: 'No keys yet.',
953
- });
954
- list.setRecords(next); // focus stays on the same record's same verb
955
- ```
956
-
957
- What `describe(record, idx)` returns (without `describe`, each record is
958
- this object itself):
959
-
960
- | Field | What it is |
961
- |---|---|
962
- | `name` | The first line: text or a node. `mono: true` draws it in the monospace face. |
963
- | `facts` | The quiet line: text, a node, or an array whose `null`, `false` and `''` parts are left out and the rest joined with `factSeparator` (`' — '`). A part may be a node. |
964
- | `pills` | Strings or `{ text, tone, title }`. The tones drawn are `muted`, `info`, `success`, `warn` and `danger`. |
965
- | `verbs` | `{ label, onClick(record, ev), refusal, danger, primary, icon, title, name }`. A verb with a `refusal` is a disabled button with the reason on its `title` and `data-refusal`, **and the reason is a line of text under the facts** (`twm-record__refusal`, named by `data-refusal-for`, which the button is `aria-describedby`). A reason shared by every verb is said once. `null` and `false` verbs are left out. |
966
- | `state` | A word: `data-state` and `twm-record--state-<word>` on the row. `paused`, `off` and `dead` dim the words and pills but never the verbs; `dead` and `error` mark the left edge in the danger colour. |
967
- | `error` | A line in the error colour under the facts. |
968
- | `key` | The record's key, over `getKey`. |
969
-
970
- | Option | What it does |
971
- |---|---|
972
- | `getKey(record, idx)` | The key (`data-record-key`). Defaults to `spec.key`, then `record.key`, then `record.id`, then the index. Keys compare as strings. |
973
- | `onActivate(record, idx, ev)` | Opens a record. It follows `DataTable`'s `onRowActivate` rule (`row_activation.js`): not for a press on a verb or any control, not for the click that ends a drag selecting text in the list, not for the second click of a double-click. Rows get a pointer and a hover. |
974
- | `activateOn` | `'click'`, `'dblclick'`, `'enter'`, or an array. Default `['click', 'enter']`. |
975
- | `keyboard` | Defaults to on exactly when `onActivate` is set. The records are then one tab stop (the last focused, else the open one, else the first), each enabled verb keeps its own, ArrowUp/ArrowDown move between records (from a record or one of its verbs), Home/End go to the ends, and Enter on a record opens it. |
976
- | `activeKey`, `setActiveKey(key)`, `getActiveKey()` | The record that is open: `twm-record--active` and `aria-current="true"`, drawn as an accent on the left edge. It survives `setRecords`. |
977
- | `rowClass(record, idx)`, `rowAttrs(record, idx)` | Classes and attributes for the record's `<li>`, as `DataTable`'s (`null`/`false` leaves an attribute off, `true` sets it empty). |
978
- | `emptyState` | Text, a node, or a function returning either, shown when there are no records. |
979
- | `facts: 'ellipsis'` | Keeps every fact line to one line and puts the whole line on its `title`. By default a long fact line wraps. |
980
- | `verbClass` | A class string, or `(verb) => string`, for a consumer with its own buttons. By default verbs are FlexDesk's `twm-btn`, with `--primary` and `--danger`. |
981
- | `label`, `className` | The list's `aria-label`; a class on the root. |
982
-
983
- Methods: `setRecords(records)` (keeps the focus on the same record's same verb,
984
- by key), `getRecords()`, `refresh(key)` (asks `describe` again for that record
985
- only; with no key, all), `getItem(key)`, `focusRecord(key)`, `dispose()`, and
986
- `element` (the root).
987
-
988
- **`createFactTable(host, config)`** draws a sheet of facts: a name and its
989
- value, row after row. A `DataTable` is the wrong tool for this, because it
990
- draws a header row, clips a long value to one line and needs a box with a
991
- height. A fact sheet has no header row, never truncates (a value wraps at
992
- spaces, inside a long word or URL when it must, and keeps its own line breaks),
993
- and is as tall as its rows. The label column stays on one line.
994
-
995
- ```js
996
- import { createFactTable } from '@flexdesk/widgets';
997
-
998
- createFactTable(host, {
999
- rows: [
1000
- { label: 'Server', value: 'db-main-01.internal' },
1001
- { label: 'Connection string', value: dsn, mono: true },
1002
- { label: 'Options', value: { pool: { min: 2, max: 16 } } }, // drawn as indented JSON
1003
- { label: 'Replica', value: null },
1004
- ],
1005
- nullDisplay: '—',
1006
- });
1007
-
1008
- // Before and after:
1009
- createFactTable(host, {
1010
- compare: true,
1011
- headers: ['Field', 'Now', 'After the restore'],
1012
- rows: [{ label: 'Amount', before: 1200, after: 950 }, { label: 'Reviewer', after: 'Bea Beta' }],
1013
- });
1014
- ```
1015
-
1016
- | Option | What it does |
1017
- |---|---|
1018
- | `rows` | Objects `{ label, value }` (or `{ label, before, after }` with `compare`), with optional `mono`, `numeric`, `labelTitle` (a tooltip on the label), `key` (`data-key`) and `status`; or arrays of cells, `[label, value, …]`. A value is text, a number, a node, or an object (drawn as indented JSON). |
1019
- | `compare: true` | Each row draws its label, its before, an arrow and its after. A row is `twm-facts__row--changed`, `--same`, `--added` (no `before`) or `--removed` (no `after`), worked out from the two sides (objects by their JSON, a node always changed) unless the row's `status` says otherwise; `data-status` says the same. An absent side reads as `missing` (`'—'`). A row with a plain `value` spans both sides. |
1020
- | `headers` | Adds a header row (there is none by default). With `compare`, three names stand over the label, before and after columns. |
1021
- | `numeric` | Column indices drawn in tabular numerals (`twm-facts__num`), header included. |
1022
- | `mono` | Every value in the monospace face. A row's own `mono` does it for that row. |
1023
- | `nullDisplay` | What `null` and `undefined` read as (`twm-facts__null`). By default the cell is empty. |
1024
- | `labelColumn: false` | The first column is ordinary data, not a label. |
1025
- | `wrapLabels: true` | Lets a long label wrap. |
1026
- | `wrap` | How a value wraps. `'anywhere'` (the default) wraps at spaces, and inside a long word or URL when it must. `'words'` wraps at spaces only, so a long word stays whole and the sheet grows wider; use it inside a sideways scroller. `'none'` keeps only the value's own line breaks. |
1027
- | `rowClass(row, idx)`, `rowAttrs(row, idx)` | Classes and attributes for each `<tr>`. |
1028
- | `emptyState`, `label`, `className` | Shown (spanning the sheet) when there are no rows; the `aria-label`; a class on the `<table>`. |
1029
-
1030
- Methods: `setRows(rows)`, `getRows()`, `getRow(idx)` (the `<tr>`), `dispose()`,
1031
- and `element` (the `<table>`).
1032
-
1033
- Both were measured in headless Edge with real input (`demo/records.html`,
1034
- driven by `demo/records_probe.mjs`): no list, record, sheet or row has zero
1035
- height and none overflows its card; a long fact line, a long URL, a connection
1036
- string and a JSON value wrap inside their column while labels stay on one line;
1037
- every verb, refused ones included, is what `elementFromPoint` finds at its
1038
- centre, and a real press runs it without opening the record; real Tab,
1039
- ArrowDown and Enter open a record; at 300px the verbs go under the record's
1040
- words, inside the card.
1041
-
1042
- ### DataTable options in 0.7.0: details, rows from a server, and filters that are not text
1043
-
1044
- 0.7.0 adds what a log viewer needs: a row's detail folded under it, rows read
1045
- page by page from a server, a pager that does not need a total, a live tail,
1046
- and column filters that are not text boxes. Every option is off until you set
1047
- it, and a table that sets none of them draws and behaves as in 0.6.
1048
-
1049
- ```js
1050
- import { DataTable } from '@flexdesk/widgets';
1051
-
1052
- const LEVELS = { kind: 'chips', options: [
1053
- { value: '', label: 'All', icon: 'list_alt' },
1054
- { value: 'warn', label: 'Warn', icon: 'warning' },
1055
- { value: 'error', label: 'Error', icon: 'error' },
1056
- ] };
1057
- const entryOf = new WeakMap(); // each drawn row -> the entry it came from
1058
-
1059
- const log = DataTable.mount(host, {
1060
- headers: ['When', 'Level', 'Who', 'What'],
1061
- columnTypes: ['datetime', null, null, null],
1062
- getRowKey: (row) => entryOf.get(row)?.id,
1063
- pageSize: 100,
1064
- sortable: true, filterable: true,
1065
- filterKinds: { 1: LEVELS },
1066
- // Rows from the server. Only the newest answer is drawn; `signal` aborts the rest.
1067
- dataSource: async (query, { signal }) => {
1068
- const [page, facets] = await Promise.all([api.audit(query, { signal }), api.facets(query)]);
1069
- const rows = page.data.map((e) => {
1070
- const row = [e.at, e.level, e.who, e.action];
1071
- entryOf.set(row, e);
1072
- return row;
1073
- });
1074
- return { rows, total: facets.total ?? null, hasMore: page.meta.has_more,
1075
- filterCounts: { 1: { '': facets.total, ...facets.levels } } };
1076
- },
1077
- pagerLabels: { first: 'Newest', prev: 'Newer', next: 'Older', last: 'Oldest' },
1078
- pagerPosition: 'bottom',
1079
- maxJumpOffset: 50_000,
1080
- follow: { interval: 10_000 },
1081
- onFollowChange: (on) => followButton.setAttribute('aria-pressed', String(on)),
1082
- rowDetail: (row) => {
1083
- const pre = document.createElement('pre');
1084
- pre.textContent = JSON.stringify(entryOf.get(row).detail, null, 2);
1085
- return pre;
1086
- },
1087
- hasRowDetail: (row) => !!entryOf.get(row)?.detail,
1088
- });
1089
- // Your own filters, outside the table, ask again from the first page:
1090
- searchBox.oninput = () => log.reload();
1091
- ```
1092
-
1093
- **A row's detail**
1094
-
1095
- | Option | What it does |
1096
- |---|---|
1097
- | `rowDetail(row, idx, { host, key })` | Gives each row a toggle at the start of its first cell. Opening it draws a row under the row, one cell across every column; the function fills `host` or returns a node or text for it. The detail is drawn when opened and dropped when closed. Its content does not change the column widths, wraps instead of being clipped, and is not part of a copy. A click in it selects nothing, and a right-click there opens the browser's own menu. |
1098
- | `hasRowDetail(row, idx)` | Which rows have a detail. A row without one gets no toggle. |
1099
- | `expandedRows`, `setRowExpanded(key, open)`, `isRowExpanded(key)`, `getExpandedRows()`, `collapseAllRows()` | Which details are open, by key (`getRowKey`, or the row's index). The state survives a sort, a filter, a page, `setData` and `refresh()`. A key that is not on screen opens when its row is drawn. Under `dataSource`, give `getRowKey`: without it a row's index is only its place in the page drawn, so a detail stays open across a new answer only where that answer has the same row (equal values) at the same place. |
1100
- | `onRowDetailToggle(idx, row, open, key)` | Called after a detail opens or closes. |
1101
- | `rowDetailLabel` | The toggle's accessible name (default `'Details'`). Its state is `aria-expanded`, and `aria-controls` names the detail. |
1102
-
1103
- The toggle is a button, so Tab reaches it and Enter or Space opens it. It
1104
- neither selects the row nor opens it (`onRowActivate`). On the focused table,
1105
- ArrowRight opens the current row's detail and ArrowLeft closes it; the same
1106
- keys work on a toggle. Rows under an open detail keep their stripes. Under
1107
- `virtualize`, an open detail counts in its row's height, and it leaves and
1108
- re-enters the window with its row. It is measured when it is drawn and again
1109
- whenever the rows are (0.6.3: on every window update, on a resize and once the
1110
- column widths are fixed), so a detail that wraps differently at the final
1111
- widths does not leave the offsets wrong. A detail is never one of the rows the
1112
- row height is measured from.
1113
-
1114
- **Rows from a server**
1115
-
1116
- | Option | What it does |
1117
- |---|---|
1118
- | `dataSource(query, { signal })` | Asks for a page whenever the sort, a column filter or the page changes. `query` is `{ offset, limit, sort: { column, header, ascending } \| null, filters: [{ column, header, value }], reason }`, and `reason` is `'initial'`, `'sort'`, `'filter'`, `'page'`, `'reload'` or `'refresh'` (and, with `paging`, `'more'`, `'all'` or `'mode'`). Return `{ rows, total, hasMore, filterCounts }`, or just the rows. The answer is drawn as given, with no sort, filter or slice in the browser. |
1119
- | Only the newest answer is drawn | A request sent while another is out aborts the older one (`signal`), and an older answer that still arrives is dropped. So is an older request's failure. |
1120
- | A failure keeps the rows | The rows on screen stay, under `setError`'s banner with the error's message, and `onLoadError(error, query)` is called. The next answer clears it. |
1121
- | `onLoad(result, query)` | Called after each answer that was drawn. |
1122
- | `reload()` | Asks again from the first page. Call it when a filter of your own, outside the table, changes. It resolves with the answer, or with `undefined` if a newer request replaced it. |
1123
- | `getQuery()`, `getPageInfo()` | The last query sent, and `{ offset, total, hasMore }` for the page drawn. |
1124
- | `goToPage(page)`, `goToRow(idx)` | Ask the source for that page (the one holding row `idx`), up to the last page when the total is known, and resolve when it is drawn. |
1125
-
1126
- While a request is out, the loading overlay shows and the empty state stays
1127
- hidden. An empty answer under a filter keeps the filter row, so the filter can
1128
- be cleared. A filter that is still being typed when an answer arrives keeps its
1129
- text, its focus and its pending request. Row indices (`onRowActivate`,
1130
- `getSelection`, `updateRow`) are positions in the page drawn, so they always
1131
- point at a row on screen. `showRowNumbers: 'position'` numbers rows from the
1132
- page's offset.
1133
-
1134
- **A pager for an unknown total**
1135
-
1136
- | Option | What it does |
1137
- |---|---|
1138
- | `total: null` in the answer | The total is unknown. The pager says `Rows 101–200` and `Page 2`, with no total. Next is enabled while `hasMore` is true or, without `hasMore`, while pages come back full. Last is disabled, and the reason is on the button (`data-refusal`, and its tooltip). |
1139
- | `hasMore` (with `onPageChange`) | The same pager for a table that pages through `onPageChange`: with `totalCount: null`, set `hasMore` to `true` or `false`. |
1140
- | `pagerLabels` | `{ first, prev, next, last }` draw a word beside each button's icon, such as `Newest`, `Newer`, `Older` and `Oldest` for a log read newest first; a labelled button is never narrower than its word. `noLast` is the reason given when there is no total, and `tooDeep(max)` the reason given past `maxJumpOffset`. |
1141
- | `maxJumpOffset` | The deepest offset that Last and Go-to-row may jump to. Past it, Last is disabled with its reason, and Go asks for nothing and marks the box `aria-invalid`, with the reason on it. Use it where a deep `OFFSET` would be slow. |
1142
- | `pagerPosition: 'bottom'` | Puts the pager under the rows. |
1143
-
1144
- An empty page after the first says `Page 4 · no rows` and keeps the pager, so
1145
- you can go back. A table without these options keeps the 0.6 pager.
1146
-
1147
- **Pages or one scrolling list from a server: `dataSource` with `paging`**
1148
-
1149
- 0.6.2's `paging` and 0.7.0's `dataSource` answer two different questions, and
1150
- they combine. `paging` decides how the rows are shown: in pages, or as one list
1151
- that loads more near the end, with its strip under the body and its soft and
1152
- hard limits. `dataSource` decides who fetches them: the table. Without
1153
- `dataSource`, your code fetches, exactly as in 0.6.2. With it, the table is its
1154
- own `paging` consumer:
1155
-
1156
- ```js
1157
- DataTable.mount(host, {
1158
- headers, virtualize: true,
1159
- dataSource: (query, { signal }) => api.rows(query, { signal }), // { rows, total, hasMore }
1160
- paging: { mode: 'scroll', pageSize: 1000, softLimit: 10000, onModeChange: remember },
1161
- });
1162
- ```
1163
-
1164
- | `paging` key | Without `dataSource` (0.6.2) | With `dataSource` (0.7.0) |
1165
- |---|---|---|
1166
- | `mode`, `pageSize`, `softLimit`, `hardLimit`, `loadMorePx` | Yours | Yours. `pageSize` is also each question's `limit`. |
1167
- | `offset`, `total`, `atLeast`, `hasMore`, `held` | Yours, kept up to date with `setPaging` | Read from the source's answers (`atLeast` may be in the answer). What you pass is ignored. |
1168
- | `onPage(offset, limit)` | Yours: fetch the page | The table's: asks `{ offset, reason: 'page' }`. |
1169
- | `onLoadMore()` | Yours: fetch the next rows | The table's: asks `{ offset: rows held, limit: pageSize, reason: 'more' }` and draws the answer under the rows held. |
1170
- | `onLoadAll()` | Yours | The table's: one question, `{ limit: hardLimit − rows held, reason: 'all' }`. The source answers as many rows as it will, and `hasMore` says whether there are more. |
1171
- | `onModeChange(mode)` | Yours: show the rows the other way | Called after the table has switched and asked again from the first row (`reason: 'mode'`). Without it there is no switch, as in 0.6.2. |
1172
-
1173
- So there is one strip, one set of limits and one load trigger. With `paging`
1174
- the 0.7.0 pager is not drawn, and `pagerPosition` does nothing. `pagerLabels`
1175
- and `maxJumpOffset` apply to the strip's page buttons.
1176
-
1177
- - While the source answers a sort, a filter or a refresh, the table asks for no
1178
- more rows. A sort or filter sent while more rows are on their way aborts that
1179
- request, as any newer question does. More rows do not show the loading
1180
- overlay; the strip shows the table is busy.
1181
- - More rows keep the scroll position, the selection and the open details.
1182
- - When the rows scrolled in prove the source holds more than `softLimit`, the
1183
- table is shown in pages from then on (`resolvePaging`, as in 0.6.2). The page
1184
- shown is the rows just loaded, so nothing is fetched again and the reader
1185
- carries on where they were.
1186
- - `refresh()` and `follow` in scroll mode read the rows held again, in one
1187
- question from the first row. Scrolling down to load more turns following
1188
- off, the way paging away does. Rows loaded only because the first ones did not
1189
- fill the box do not, since the reader is still at the top.
1190
- - `setPaging({ mode })` that changes how the rows are shown asks again from the
1191
- first row. Other patches only redraw the strip, as in 0.6.2.
1192
- - `goToPage`, `goToRow` and `getPageInfo` work as they do without `paging`,
1193
- in `paging.pageSize` pages.
1194
-
1195
- **Refresh and follow**
1196
-
1197
- | Option | What it does |
1198
- |---|---|
1199
- | `refresh()` | Reads the current page again in place. A reader at the top sees the rows that arrived. A reader scrolled down keeps the rows they were reading at the same place on screen, because new rows above move the scroll position instead of the rows. The selection is kept by key, as are the active row and the open details. Rows that the last answer did not hold are drawn `twm-dt-row--new` (a short highlight); this needs `getRowKey`. |
1200
- | `follow: true` or `{ interval }` | Calls `refresh()` every `interval` ms (default 10 000) while the table is on its first page, in the document, and has no request out. A tick also waits while somebody is reading: while text inside the table is selected, or the focus is inside an open detail, it reads nothing, and following resumes at the next tick after. A `refresh()` you call yourself does not wait, and puts the focus back on the same place in a detail it draws again. Paging away turns following off and calls `onFollowChange(false)`. Coming back to the first page does not turn it on again. |
1201
- | `setFollow(on)`, `isFollowing()`, `onFollowChange(on)` | Turn following on and off from your own toggle, and hear when the table turns it off. |
1202
-
1203
- The follow timer holds the table weakly, so it never keeps a discarded table
1204
- alive, and it stops on `dispose()`.
1205
-
1206
- **Filters that are not text**
1207
-
1208
- | Option | What it does |
1209
- |---|---|
1210
- | `filterKinds` | A filter control per column, as an object or array keyed by column index, or a function `(colIdx, header) => spec`. `'text'` (or nothing) is the usual text box. `{ kind: 'chips', options: [{ value, label, icon, title }], multiple }` draws chips. `{ create(ctx) }`, or a function, is your own control. A column is never narrower than its control at its narrowest (for chips, the widest chip); above that the cell wraps the control. |
1211
- | The chips | One chip per option, each with a count. An option whose value is `''` or `null` means no filter, and it is pressed when nothing is chosen. Pressing the chosen chip again clears the filter. With `multiple`, each chip toggles its value in a list. A chip whose count is 0 is dimmed, not hidden. `createChipsFilter(ctx, spec)` builds the same control outside a table. |
1212
- | Your own control | `create(ctx)` receives `{ column, header, value, counts, setValue(v), table }` and returns `{ el, setValue(v), setCounts(c), destroy(), match(cell, value, row) }`. Only `el` is required. Call `ctx.setValue` with the new value, which can be anything, such as `{ from, to }` for a date range or a person's id. |
1213
- | `setFilterCounts(colIdx, counts)` or `setFilterCounts({ [colIdx]: counts })` | Gives a column's control its counts, keyed by option value. They are kept, so a control built later starts with them. A `dataSource` answer can carry them as `filterCounts`. `getFilterCounts(colIdx)` reads them back. |
1214
- | `setFilter(colIdx, value)`, `getFilters()`, `onFilterChange(filters)` | Set one column's filter from code (an empty value clears it), read the filters in force as `[{ column, header, value }]`, and hear every change. `clearFilters()` resets each control as well. |
1215
-
1216
- Under `dataSource`, a filter's value goes to the server as it is. Without a
1217
- server, the control's `match` decides which rows pass. If it has none, the
1218
- chips use equality (or membership, with `multiple`), a `{ from, to }` value is
1219
- an inclusive range over dates or numbers, a list means "one of these", and text
1220
- means "contains". A control is built once, and the same element is put back
1221
- after every render, so its own state, the focus and the text selection inside
1222
- it survive a redraw. It is destroyed when the columns change or the table is
1223
- disposed.
1224
-
1225
- **Housekeeping**
1226
-
1227
- | Option | What it does |
1228
- |---|---|
1229
- | `keepWhileDetached` | With `autoDispose`, how long a table may be out of the document and still come back drawn. A number is a grace period in ms. `true` keeps the table for as long as it is out: it is disposed only by `dispose()`, and while out it holds nothing on the document. A table put back within its grace is not disposed, however many tasks it was out, and the grace counts from its last departure, not its first. Use it for a tab whose content you detach and attach again later. Without it, `autoDispose` gives one task's grace, as in 0.5.0. |
1230
-
1231
- **One change that needs no option.** `autoDispose` now cancels a pending
1232
- dispose when the table is put back. Before 0.7.0, a table taken out, put back
1233
- and taken out again could be disposed by the first departure's timer.
1234
-
1235
- ### DataTable: your own right-click items, a render hook, the keyboard, a remembered page
1236
-
1237
- These options let an application drop a private copy of the DataTable kept for
1238
- them. Every one is off until you set it, and a table that sets none of them
1239
- draws and behaves as in 0.9.1.
1240
-
1241
- ```js
1242
- const queue = DataTable.mount(host, {
1243
- headers, rows, pagination: true, sortable: true, filterable: true,
1244
- stateStore, persistKey: `queue:${view}`,
1245
- persistPage: !!savedView, // an ad-hoc view shares its key: start it on page 1
1246
- getRowKey: (row) => row[0],
1247
- contextMenuItems: (row, rowIdx, ctx) => [
1248
- { label: ctx.selection.length > 1 ? `Open ${ctx.selection.length}` : 'Open',
1249
- icon: 'open_in_new', action: 'open' },
1250
- { separator: true },
1251
- { label: 'Delete…', icon: 'delete', action: 'delete', danger: true,
1252
- disabled: !canDelete(row), title: canDelete(row) ? '' : 'Closed records stay' },
1253
- ],
1254
- onContextMenuAction: (action, rowIdx, row, ctx) => run(action, ctx.selectedRows),
1255
- onRender: (t) => status.textContent = `${t.getDisplayedRows().length} of ${rows.length} shown`,
1256
- keyboardNav: true,
1257
- onRowKeyDown: (rowIdx, row, ev) => {
1258
- if (row && ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { openElsewhere(row); return true; }
1259
- if (row && ev.altKey && ev.key.toLowerCase() === 't') { openInTab(row); return true; }
1260
- },
1261
- onRowActivate: (i, row) => open(row),
1262
- activateOn: ['click', 'enter'],
1263
- });
1264
- queue.focus();
1265
- ```
1266
-
1267
- **The right-click menu**
1268
-
1269
- | Option | What it does |
1270
- |---|---|
1271
- | `contextMenuItems` | Your items in the table's own right-click menu: an array, or `(row, rowIdx, ctx) => items`, asked on every right-click of a row. An item is `{ label, icon, action, disabled, danger, title }` or `{ separator: true }`, drawn like the copy items (`disabled` greys it out, `danger` draws it red, `title` says why). `ctx` is `{ rowIdx, row, colIdx, value, selection, selectedRows }`: the row and the cell under the pointer (`colIdx` is null on the row-number column) and the selection, which by then includes the row. Your items come first; with `copyable`, a separator and the two copy items follow. Set without `copyable`, the menu has your items only, and a row you give no items gets the browser's own menu. |
1272
- | `onContextMenuAction(action, rowIdx, row, ctx)` | Called when one of your items is chosen, after the menu has closed. The copy items never call it, so an action may be named anything. |
1273
-
1274
- **What the table shows**
1275
-
1276
- | Option | What it does |
1277
- |---|---|
1278
- | `onRender(table)` | Called at the end of every render: the first one, `setData`, a sort, a filter, a page. A hook that throws is reported on the console and the table still draws. |
1279
- | `getDisplayedRows()` | The rows the table shows, filtered and sorted, across every page and not only the one on screen. It returns a copy. With server-side paging it is the page held. |
1280
-
1281
- **The keyboard**
1282
-
1283
- | Option | What it does |
1284
- |---|---|
1285
- | `keyboardNav: true` | On the focused table, ArrowUp and ArrowDown move the selection one row through the page shown, in the order shown, and Home and End go to its first and last row. With Shift they select the range from the anchor, as Shift-click does (not on a `selectable: 'single'` table). The row is scrolled into view and `onSelectionChange` is told. Keys pressed in a control inside the table are left alone. Needs `selectable`. |
1286
- | `onRowKeyDown(rowIdx, row, ev)` | Called for every key pressed on the focused table, before the table's own keys, with the row Enter would open (the one row selected, the selection's anchor, or else the active row) or `null`. Return `true` to take the key: its default is prevented and nothing after it sees it, neither the table (Enter's `onRowActivate`, the arrows, copy) nor the page. Use it for the keys `activateOn` does not take, such as Ctrl+Enter or Alt+T. |
1287
- | `focus()` | Puts the keyboard focus on the table's body without scrolling the page. Returns `false` when nothing is drawn yet. |
1288
-
1289
- **A remembered page**
1290
-
1291
- | Option | What it does |
1292
- |---|---|
1293
- | `persistPage: true` | With `persistKey` and `stateStore`, saves the client page with the sort and filters, and opens the table on it again. If the rows have shrunk since, it opens on the last page. A new filter, and a sort under `resetPageOnSort`, go to the first page and save that. Rows given after mounting (`rows: []`, then `setData`) still open on the saved page. Server-side paging is not affected: its page is your `offset`. Off by default, because a key shared by different row sets, such as ad-hoc filters or search results, must not reopen on a page of the previous list. Without it, the saved state is exactly what 0.9.1 saves. |
1294
-
1295
- ### DataTable: columns hidden until somebody asks for them
1296
-
1297
- A column can be part of the row data without being drawn. People choose which
1298
- columns they see from the header's right-click menu, and the choice is
1299
- remembered. A table that sets none of these options draws every column, as in
1300
- 0.9.1.
1301
-
1302
- ```js
1303
- const list = DataTable.mount(host, {
1304
- headers: ['Id', 'Summary', 'Status', ...customFields.map((f) => f.label)],
1305
- rows,
1306
- hiddenColumns: customFields.map((_, i) => 3 + i), // not shown until picked
1307
- columnChooser: true,
1308
- stateStore, persistKey: 'bugs:queue',
1309
- onColumnsChange: (hidden) => console.log('hidden now', hidden),
1310
- });
1311
- list.setColumnVisible(3, true);
1312
- ```
1313
-
1314
- | Option | What it does |
1315
- |---|---|
1316
- | `hiddenColumns: [colIdx, ...]` | These columns are not drawn: no header, no filter box, no cell, no width in the fit, and they are left out of a copy and of `downloadCSV()`. They stay in the row data, so `renderCell`, `contextMenuItems`, `sortBy` and `setFilter` still see them. The last column shown can never be hidden; a list that hides all of them keeps the first. `setData({ hiddenColumns })` applies a new list. Column indices are positions, so a new `headers` list keeps the set by position. |
1317
- | `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. |
1318
- | `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. |
1319
- | `getHiddenColumns()` | The hidden columns' indices, ascending. |
1320
- | `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()`. |
1321
- | 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. |
1322
-
1323
- A column sorted on and then hidden keeps the order; its arrow comes back with
1324
- the column.
1325
-
1326
- ### DataTable: columns in the order somebody puts them (0.17.0)
1327
-
1328
- Columns can be dragged into a new order by their headers, or ordered from
1329
- code, and the order is remembered. The order is kept by data index, so
1330
- `hiddenColumns`, the sort, the filters, `renderCell`'s `colIdx` and cell
1331
- mode's `col` stay on their columns. A table that sets none of these options
1332
- draws its columns in their natural order and saves what 0.16 saves.
1333
-
1334
- ```js
1335
- const list = DataTable.mount(host, {
1336
- headers: ['Id', 'Summary', 'Status', 'Owner'],
1337
- rows,
1338
- reorderable: true,
1339
- columnOrder: [0, 3, 1, 2], // optional starting order
1340
- stateStore, persistKey: 'bugs:queue',
1341
- onColumnsChange: (hidden, { order }) => console.log('order now', order),
1342
- });
1343
- list.moveColumn(3, 1); // positions in getColumnOrder()
1344
- ```
1345
-
1346
- | Option | What it does |
1347
- |---|---|
1348
- | `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. |
1349
- | `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. |
1350
- | `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. |
1351
- | `getColumnOrder()` | Every data index, hidden ones included, in the order drawn (a copy). |
1352
- | 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. |
1353
- | Copies and the CSV | Ctrl+C, the copy menu and `downloadCSV()` write the columns in the order shown. |
1354
-
1355
- The drag is HTML5 drag-and-drop on the header cells. The cell being dragged is
1356
- `twm-dt-col--dragging`, the one under the pointer `twm-dt-col--drop` (plus
1357
- `twm-dt-col--drop-after` when the column would land to its right). The logic
1358
- lives in `column_order.js`, which is internal to the table.
1359
-
1360
- ### DataTable: the CSV button on every table (0.14.1)
1361
-
1362
- `showExportButton: true` draws "Download visible rows as CSV" in the pagination
1363
- strip, and now draws the strip for it on a table that fits on one page too (the
1364
- button alone, no page buttons). `exportFilename` — a string, or `(table) =>
1365
- string` — names the file (default `data.csv`). With a `host` that has `dialogs`
1366
- the button opens the native Save dialog, suggesting that name; without one it
1367
- is a browser download.
1368
-
1369
- ```js
1370
- DataTable.mount(el, { headers, rows, pagination: true, showExportButton: true,
1371
- exportFilename: 'bugs.csv', host });
1372
- ```
1373
-
1374
- ### DataTable: a select mode (0.12.0)
1375
-
1376
- A list whose click opens a row can still let people pick many rows: a toggle
1377
- in the table's own header turns on a select mode, with a checkbox per row and
1378
- a click that ticks instead of opening. A table that sets none of these options
1379
- draws and behaves as in 0.11.
1380
-
1381
- ```js
1382
- const list = DataTable.mount(host, {
1383
- headers, rows,
1384
- getRowKey: (row) => row[0], // keeps the ticks across new rows
1385
- onRowActivate: (i, row) => open(row), // not called while selecting
1386
- selectToggle: true,
1387
- onSelectModeChange: (on) => bar.hidden = !on,
1388
- onSelectionChange: (ticked) => bar.count = ticked.length,
1389
- });
1390
- list.setSelectMode(true);
1391
- ```
1392
-
1393
- | Option | What it does |
1394
- |---|---|
1395
- | `selectToggle: true` | Draws a narrow leading column whose header holds a toggle button. While the mode is on, every row shows a checkbox and the header a select-all box. Needs a multi-row `selectable` (on `false` or `'single'` it is ignored, with a console warning). |
1396
- | `selectMode: true` | Starts in the mode. Draws the column even without `selectToggle`; the consumer then leaves the mode with `setSelectMode(false)`. |
1397
- | `setSelectMode(on)` / `isSelectMode()` | Turns the mode on or off from code (returns whether it changed), and says whether it is on. The selection is kept either way. |
1398
- | `onSelectModeChange(on)` | Called after every change of the mode: the toggle, Escape, `setSelectMode`. |
1399
- | In the mode | A plain click or Space ticks or unticks one row; Shift-click adds the range from the last row ticked. The arrows move a cursor without changing the ticks (Shift ticks the rows passed). A right-click keeps the ticks. Escape clears them, and a second Escape leaves the mode. Nothing opens a row: `onRowActivate` is not called (`onRowClick` still is, so a consumer that opens rows from it checks `isSelectMode()`). New rows (`setData`) keep the ticks by `getRowKey`. |
1400
- | Select-all | Ticks every row the filters let through, on every page; when all are ticked it clears them. It shows checked, mixed or clear. |
1401
-
1402
- The select column is not a data column: it is not in a copy, a CSV,
1403
- `ctx.colIdx` or `hiddenColumns`, and it cannot be resized. It is drawn for the
1404
- table's whole life, so remembered widths stay on their columns when the mode
1405
- flips, and widths saved by a table without it land on the same columns.
1406
-
1407
- ### DataTable: a grid of cells you can edit (0.17.0)
1408
-
1409
- `selectionMode: 'cells'` turns a table into a grid: a click selects a cell, the
1410
- keyboard moves a cursor over cells, and with `editable` a cell opens an editor
1411
- in place. A table that sets none of these options selects rows and behaves as
1412
- in 0.16.
1413
-
1414
- ```js
1415
- const grid = DataTable.mount(host, {
1416
- headers: ['Item', 'Qty', 'Note'],
1417
- rows,
1418
- pagination: false,
1419
- virtualize: true,
1420
- selectionMode: 'cells',
1421
- editable: [1, 2], // or true, or (colIdx, row, rowIdx) => boolean
1422
- editors: { num: myNumberEditor }, // by column index or by type
1423
- onCellCommit: async ({ rowIdx, colIdx, value, previous }) => {
1424
- const r = await api.save(rowIdx, colIdx, value);
1425
- return r.ok ? { ok: true } : { ok: false, error: r.message };
1426
- },
1427
- headerMenu: (colIdx, ctx) => [{ label: `Sort by ${ctx.header}`, action: 'sort', icon: 'sort' }],
1428
- onHeaderMenuAction: (action, colIdx) => { if (action === 'sort') grid.sortBy(colIdx); },
1429
- columnChooser: true,
1430
- });
1431
- grid.getCellSelection(); // { anchor, focus, range }
1432
- ```
1433
-
1434
- | Option | What it does |
1435
- |---|---|
1436
- | `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. |
1437
- | 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. |
1438
- | `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. |
1439
- | `setCellSelection(focus, anchor?)` | Puts the cursor on `{row, col}`, or selects from `anchor` to it; `null` clears. The row is brought into view. |
1440
- | `getSelection()`, `setSelection()` | Still about rows: `getSelection()` returns the rows the range touches and `onSelectionChange` hears them. `setSelection(indices)` selects the cells of those rows, first to last as shown. Ctrl+C and the copy menu copy cells, not rows, from 0.17.1 (below). |
1441
- | `onCellSelectionChange(sel)` | Called with `getCellSelection()` after every change. |
1442
- | `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. |
1443
- | `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. Without one, a column gets the built-in editor for its type, else a text box (`createTextEditor`); see [Built-in cell editors](#built-in-cell-editors-0171) for the editors, the `editTypes`, `locale` and `editorMessages` options and what a factory is told since 0.17.1. |
1444
- | `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. |
1445
- | 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. |
1446
- | `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. |
1447
-
1448
- The two modules behind cell mode are exported for a consumer that builds its
1449
- own grid: `CellSelection` (the anchor, the cursor and every key's move, with no
1450
- DOM), `CellEditorHost` (one cell's editor, opened and closed exactly once) and
1451
- `createTextEditor` (the default text box). The table's own half of cell mode
1452
- is internal.
1453
-
1454
- #### Built-in cell editors (0.17.1)
1455
-
1456
- An editable column opens the editor for its type: `text`, `multitext`,
1457
- `number`, `bool`, `choice` or `datetime`. Each reads what is typed with a
1458
- parse, and a value that does not parse is refused on the cell. `editors`
1459
- replaces any of them, for its type or its column only.
1460
-
1461
- ```js
1462
- const grid = DataTable.mount(host, {
1463
- headers: ['Item', 'Qty', 'Colour', 'Note'],
1464
- rows,
1465
- selectionMode: 'cells',
1466
- editable: true,
1467
- editTypes: [null, 'number', { type: 'choice', options: ['red', 'green'] }, 'multitext'],
1468
- locale: 'de-DE', // '1.234,56' is 1234.56
1469
- editorMessages: { number: 'Keine Zahl' },
1470
- editors: { 3: myNoteEditor }, // by column index or by type
1471
- onCellCommit: ({ value }) => save(value), // Qty commits a Number
1472
- });
1473
- ```
1474
-
1475
- | Option | What it does |
1476
- |---|---|
1477
- | The built-in editors | `text` a text box; `multitext` a text area (Enter is a new line, Ctrl/Cmd+Enter commits); `number` a text box that commits a Number; `bool` a checkbox that commits `true`/`false` when ticked or unticked (a space that opens it ticks it over); `choice` an `AutocompleteField` over the column's `options` that commits the option's value; `datetime` a text box that commits a Date. A blank number, date or choice commits `null`. A value opened and left as it was commits nothing. |
1478
- | `editTypes` | Each column's editor type, by position (an array) or by data column index (an object): a name (`'text'`, `'multitext'`, `'number'`, `'bool'`, `'choice'`, `'datetime'`, or any name `editors` is keyed by) or `{type, options?, allowOther?, rows?}`. Unset, it is the column's `columnTypes` type, else the detected one, so a numeric column opens the number editor and a `date` column the datetime editor. `choice` offers `options` (values, or `{value, label}`), filtered as you type; a pick commits its value, and typed text matching an option's label or value (any case) commits that value. Other text is refused unless `allowOther`, when it is the value. With the list open, Escape closes the list only. `rows` is a `multitext` area's height in lines (3). Only editing reads `editTypes`: drawing, sorting and filtering are as without it. |
1479
- | `locale` | The locale the number and datetime editors read and show text in (Intl's; unset: the runtime's). `'1.234,56'` is 1234.56 under `de-DE` and `'1,234.56'` is under `en-US`, and `'1 234,5'` is 1234.5 under `fr-FR` (any space, a no-break one included, where the locale groups with a space); a group separator anywhere but between groups of three digits, or a space inside the number anywhere else, is refused, never misread. Spaces around the text are ignored. A number opens without groups, in the locale's decimal separator. A datetime reads ISO 8601 (`2026-12-31`, `2026-12-31T08:30`, with `Z` or an offset an exact instant) in any locale, and day, month and year in the locale's order with a four-digit year and an optional time (`31/12/2026 08:30` under `en-GB`); when the locale's order makes no date but the day-month swap does, the swap is read. A date with no zone is in `dateTimeZone`'s. It opens as ISO. |
1480
- | A value that does not parse | `abc` in a number, `31/02/2026` in a date, a choice outside the options: Enter and Tab are refused, the editor stays open, and the cell carries the message (`twm-dt-cell--error`, `title`, `data-error`) until the next keystroke. Nothing is committed. If the focus leaves (or the row scrolls out of the window) with it, the editor closes and the cell keeps the text, marked as a refused commit is, and `onCellCommit` is not called. Under `staged` nothing is staged; anything staged into the cell afterwards (a typed value, a fill, the value the row holds) ends the refusal. The words are `EDITOR_MESSAGES`, overridden by `editorMessages: {number, datetime, bool, choice}`. |
1481
- | `editors` (0.17.1) | The editor for a column, looked up by column index and then by type (`editTypes`, else `columnTypes`, else the detected `num`/`text`; as named, then as the built-in editors name it: `num` is `number`, `date` is `datetime`). A factory gets `{value, row, rowIdx, colIdx, column: {index, header, type, ...the editTypes entry}, locale, utc, messages, initialText, commit(value, move?), cancel()}` and returns an element or `{el, getValue?, parse?, multiline?, focus?, destroy?}`. An input, select or textarea commits its `value` on Enter and Tab; any other editor calls `commit(value)` itself. With `parse()` (`=> {ok: true, value} \| {ok: false, error}`) Enter, Tab and the focus leaving commit what it reads, and a refusal is handled as the built-in editors' is. `multiline` keeps Enter for a new line (Ctrl/Cmd+Enter commits). An injected editor replaces the built-in one for its type (or its column) only. Without one, a column gets the built-in editor for its type, else a text box (`createTextEditor`). |
1482
-
1483
- Exported from cell_editors.js for a consumer that builds its own grid or
1484
- reads pasted text: the six editors as `CELL_EDITORS` (`{text, multitext,
1485
- number, bool, choice, datetime}`, each `{create, parse}`: `create` a factory,
1486
- `parse(text, {locale, utc, options, allowOther, messages})` the same reading
1487
- of plain text, for a paste) with their parts: `parseNumber`, `parseDateTime`,
1488
- `parseBool`, `parseChoice`, `formatNumberForEdit`, `formatDateTimeForEdit`,
1489
- `numberSeparators`, `dateOrder`, `normalizeOptions`, `editorTypeOf`,
1490
- `EDITOR_MESSAGES` and `createNumberEditor`, `createMultiTextEditor`,
1491
- `createBoolEditor`, `createChoiceEditor`, `createDateTimeEditor`.
1492
- `CellEditorHost` takes an `onRefuse(session, error, text, reason)` beside
1493
- `onCommit` and `onCancel`, called when an editor closes holding a value that
1494
- does not parse (without it, such a close is a cancel).
1495
-
1496
- #### Copying cells as tab-separated values (0.17.1)
1497
-
1498
- In cell mode Ctrl+C copies the cells selected, as the tab-separated text Excel
1499
- and Google Sheets paste into cells.
1500
-
1501
- | What | What it does |
1502
- |---|---|
1503
- | Ctrl+C, the copy menu, `copyToClipboard()` | With `copyable` (the default), copy the CELLS selected, not their rows: a 3×4 range is 3 lines of 4 tab-separated values, in the order shown after sort and filter, its shown columns only (a hidden column inside it is left out), with no header line. With no range beyond the cursor, the cursor cell's value alone. A cell is its RAW value, not the text it shows: no `formatValue`, typed-column format, rounding or `nullDisplay` (a Price drawn `1,234.50`, value 1234.5, copies as `1234.5`); the value the cell holds now, a staged, pending or refused one included; `null` is empty. Dates copy as ISO 8601: a `Date` as `toISOString()`, in a column declared `'date'` as its day `YYYY-MM-DD` in the table's zone (`dateTimeZone`); a number in a declared `'date'`/`'datetime'` column is read as epoch ms the same way; text is copied as it is. A value holding a tab, a newline or a quote is quoted the way Excel quotes it (`"say ""hi"""`), so Excel and Google Sheets paste it back into one cell. Cell mode has no CSV copy: the right-click menu's copy item is "Copy Cells" alone (the same text as Ctrl+C), Ctrl+Shift+C is not bound (the key is left to the page), and `copyToClipboard('csv')` copies nothing and warns. Without cell mode, Ctrl+C, Ctrl+Shift+C and the copy menu ("Copy Row(s)", "Copy as CSV") copy the selected rows with a header line, as in 0.16. |
1504
-
1505
- `toTSV(matrix)` and `tsvCell(value)` write tab-separated text with Excel's
1506
- quoting (a cell holding a tab, a newline or a quote is quoted and its quotes
1507
- doubled; `null` is empty; no newline after the last row): the writer cell mode
1508
- copies with.
1509
-
1510
- #### Pasting a block from Excel (0.17.1)
1511
-
1512
- With `pasteable: true` beside `staged`, Ctrl+V on the focused table pastes the
1513
- tab-separated block Excel and Google Sheets copy at the CURSOR cell: it fills
1514
- right over the columns shown and down over the rows in the order shown, each
1515
- value read by its column's editor, and every cell is STAGED as one undo entry.
1516
-
1517
- ```js
1518
- const grid = DataTable.mount(host, {
1519
- headers: ['Item', 'Qty', 'When'],
1520
- rows,
1521
- selectionMode: 'cells',
1522
- editable: (colIdx, row) => colIdx !== 0 || !row.locked,
1523
- editTypes: [null, 'number', 'datetime'],
1524
- staged: true,
1525
- pasteable: true,
1526
- onPasteOverrun: ({ rows }) => confirm(`Add ${rows} row(s)?`),
1527
- onRowsAppend: ({ rows, errors }) => store.addRows(rows),
1528
- onCommit: ({ edits }) => api.saveBatch(edits),
1529
- });
1530
- await grid.paste('Bolt\t4\t2026-12-31\nNut\t9\t2027-01-02'); // what Ctrl+V does
1531
- ```
1532
-
1533
- | Option or method | What it does |
1534
- |---|---|
1535
- | `pasteable: true` | Needs `staged` (ignored with a warning without it). Ctrl+V on the focused table takes the browser's `paste` event (its `text/plain`, read by `fromTSV`); a paste inside an open editor or another field is that field's. The block lands at the cursor cell; a hidden column is stepped over. Each value is read by the parse of its column's editor: the built-in editor's for its type (`editTypes`, else `columnTypes`, else the detected one; `locale`, the zone and `editorMessages` as an editor reads them), or an injected `editors` factory's own `parse` property (`factory.parse = (text, ctx) => ({ok: true, value} \| {ok: false, error})`), else the text as it is. Every cell that reads is staged as ONE undo entry: one Ctrl+Z takes the whole paste back. A value equal to what the row holds is no change. The range then covers the block, the cursor on the cell it began at. Nothing is pasted while an editor is open, a commit is out or another paste waits. |
1536
- | Columns past the last | Dropped. A console warning and a `'warn'` toast (`toast:show` on `services.eventBus`) name how many: "3 column(s) past the last column were not pasted." |
1537
- | Rows past the last loaded row | The last loaded row is the last row in the order shown (filtered and sorted; for a server's page, the last row it sent). `onPasteOverrun({rows: n})` is asked first, `n` the rows past it. Anything but `true` (or a Promise of `true`) stages NOTHING; without the hook, such a paste stages nothing and warns. `true` hands the extra rows to `onRowsAppend({rows, errors})` and, once that settles, stages the rows that fit: `rows` are data rows (as long as `headers`, every pasted column read by its parse, the others `null`), and `errors` the values that did not read (`{row, colIdx, text, error}`, `row` an index into `rows`; that cell is `null`). Adding them is the consumer's: the table holds no row it was not given. A consumer that adds them with `setData({rows})` (the old rows first, the new ones after) keeps the paste's staged cells, because they are staged after it. Without `onRowsAppend` the extra rows are dropped with a warning. |
1538
- | A value that does not parse | `abc` in a number column: its cell is marked as an editor's refusal is (`twm-dt-cell--error`, the message as `title` and `data-error`, the pasted text kept in the cell) and nothing is staged in it; the rest of the block still stages. Anything staged into the cell afterwards ends the mark. |
1539
- | A cell `editable` refuses | Skipped: nothing is staged in it and its row is untouched. It is drawn `twm-dt-cell--paste-skipped` until the selection moves, the next paste or fill, or new rows. |
1540
- | `paste(text)` | What Ctrl+V does, from code. Resolves `{rows, cols, staged, skipped, refused, droppedColumns, appended}` (`staged` the NUMBER of cells that changed; `skipped` `[{row, col, rowIdx}]`; `refused` the same with `text` and `error`; `appended` the rows handed to `onRowsAppend`), or `null` when nothing was pasted: no `pasteable`, empty text, no cursor, an editor open, a commit or another paste out, an overrun not accepted, or new rows while `onPasteOverrun` was asked. |
1541
-
1542
- `fromTSV(text)` reads tab-separated text as a matrix of strings, as Excel
1543
- writes it: character by character, so a quoted cell holding a tab, a newline
1544
- or doubled quotes is one cell; a quote opens a quoted cell only at its start;
1545
- CRLF lines read as LF ones; the newline after the last row (and any empty
1546
- lines after it) is no row; empty text is `[]`. Exported beside `toTSV`.
1547
-
1548
- #### Staged edits, undo and redo, one commit (0.17.0)
1549
-
1550
- With `staged: true` beside `editable`, a commit from an editor is STAGED
1551
- rather than sent to `onCellCommit`: it stays in the table, drawn
1552
- `twm-dt-cell--staged`, until `commit()` hands every staged cell to the
1553
- consumer in one call. Ctrl+Z and Ctrl+Y walk the staged edits.
1554
-
1555
- ```js
1556
- const grid = DataTable.mount(host, {
1557
- headers, rows,
1558
- selectionMode: 'cells',
1559
- editable: true,
1560
- staged: true,
1561
- historyLimit: 100, // the default
1562
- onCommit: async ({ edits }) => { // [{rowIdx, colIdx, value, previous}, …]
1563
- const r = await api.saveBatch(edits);
1564
- return { errors: r.failed }; // [{rowIdx, colIdx, error}] — empty or absent: success
1565
- },
1566
- onStagedChange: ({ count, canUndo, canRedo, committing }) => paintSaveButton(count),
1567
- });
1568
- saveButton.onclick = () => grid.commit();
1569
- discardButton.onclick = () => grid.discard();
1570
- ```
1571
-
1572
- | Option or method | What it does |
1573
- |---|---|
1574
- | `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. |
1575
- | 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. |
1576
- | `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. |
1577
- | `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. |
1578
- | `discard()` | Drops every staged cell, its marks and the ring. |
1579
- | `getStagedEdits()` | The staged cells, as `onCommit` would get them. |
1580
- | `onStagedChange({count, canUndo, canRedo, committing})` | After every change of the staged cells. |
1581
- | 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. |
1582
- | New rows | `setData({rows})` drops the staged cells and the ring: the row indices they named are other rows now. |
1583
-
1584
- `StagedHistory` (the ring, with no DOM: `record`, `beginGroup`/`endGroup`,
1585
- `undo`, `redo`, `reset`, writing through an injected `apply(entry, direction)`)
1586
- is exported with `ACTIONS` and `DEFAULT_HISTORY_LIMIT` for a consumer that
1587
- builds its own grid.
1588
-
1589
- #### Fill handle (0.17.0)
1590
-
1591
- With `fillHandle: true` beside `staged`, the bottom-right cell of the range
1592
- carries a small square (`.twm-dt-fill-handle`). Dragging it up, down, left or
1593
- right over the cells beyond the range fills them as a spreadsheet does, and
1594
- stages the values: nothing is sent until `commit()`.
1595
-
1596
- ```js
1597
- const grid = DataTable.mount(host, {
1598
- headers, rows,
1599
- selectionMode: 'cells',
1600
- editable: (colIdx, row) => !row.locked, // a refused cell is skipped by a fill
1601
- staged: true,
1602
- fillHandle: true,
1603
- onCommit: ({ edits }) => api.saveBatch(edits),
1604
- });
1605
- grid.fill({ row: 20, col: 3 }); // what dragging the handle to (20, 3) does
1606
- ```
1607
-
1608
- | Option or method | What it does |
1609
- |---|---|
1610
- | `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. |
1611
- | 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. |
1612
- | 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. |
1613
- | `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. |
1614
-
1615
- `detectSeries(values)` (what a seed is: `{kind, step}` with kind `copy`,
1616
- `number`, `date`, `text-number` or `cycle`, or null for blanks) and
1617
- `projectSeries(values, count, {forceCopy})` (the next `count` values; a
1618
- series keeps its seed's type) are exported as pure functions for a consumer
1619
- that builds its own grid. `values` is the seed in fill order.
1620
-
1621
- ### DataTable: column groups and structural edits (0.17.0)
1622
-
1623
- `columnGroups` draws a second header row over the column headers, and a few
1624
- callbacks let a person add, rename and delete columns and rows from the
1625
- headers and the row menu. The consumer owns the data: every edit is a call,
1626
- and the consumer answers it with `setData`. A table that sets none of these
1627
- options draws exactly as in 0.16.
1628
-
1629
- ```js
1630
- const table = DataTable.mount(host, {
1631
- headers: ['Cash', 'Loans', 'Deposits'],
1632
- rows,
1633
- columnGroups: [
1634
- { key: 'assets', label: 'Assets', columns: [0, 1] },
1635
- { key: 'liabilities', label: 'Liabilities', columns: [2] },
1636
- { key: 'equity', label: 'Equity', columns: [] }, // drawn as a placeholder column
1637
- ],
1638
- onColumnAdd: (groupKey) => model.addColumn(groupKey),
1639
- onColumnRename: (colIdx, name) => model.renameColumn(colIdx, name),
1640
- onColumnDelete: (colIdx) => model.deleteColumn(colIdx),
1641
- onRowAdd: (afterRowIdx) => model.addRow(afterRowIdx), // null: from the header
1642
- onRowDelete: (rowIdx) => model.deleteRow(rowIdx),
1643
- });
1644
- ```
1645
-
1646
- | Option | What it does |
1647
- |---|---|
1648
- | `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. |
1649
- | 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. |
1650
- | `onColumnAdd(groupKey)` | An add button (`.twm-dt-group__add`) in every group's header cell and in a placeholder's header. |
1651
- | `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?}`. |
1652
- | `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. |
1653
- | `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. |
1654
- | `onColumnDelete(colIdx)` | "Delete column" in the header's right-click menu. |
1655
- | `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. |
1656
- | `onRowDelete(rowIdx)` | "Delete row" in a row's right-click menu. |
1657
-
1658
- The structural items follow the consumer's own: in the header menu after the
1659
- `headerMenu` items and before the column chooser, in a row's menu after the
1660
- `contextMenuItems` and before the copy items, each behind a separator.
1661
-
1662
- ### DataTable: a crosstab, with spanned header rows and pinned label columns (0.17.0)
1663
-
1664
- `crosstab` draws the table as a matrix: rows of spanned headers over the
1665
- column headers, the leading columns as row labels, and all of it in ONE table
1666
- in ONE scroller. The header rows stick to the top, the label columns (with the
1667
- select and row-number columns in front of them) stick to the left, and the
1668
- corner, the header cells over the label columns, sticks to both. Each header
1669
- row's `top` is the measured height of the rows above it, and each pinned
1670
- column's `left` the measured width of the pinned columns before it. Both are
1671
- read after layout and again whenever a header row or a pinned header cell
1672
- changes size (a label that wraps, a font, a zoom). The consumer supplies every
1673
- value and every label: rows and columns that add others up are ordinary rows
1674
- and columns, marked with `rowClass`, `cellClass` and a header cell's
1675
- `className`. A table without `crosstab` draws exactly as in 0.16.
1676
-
1677
- ```js
1678
- const table = DataTable.mount(host, {
1679
- headers: ['Region', 'City', 'Q1', 'Q2', 'Q1', 'Q2', 'All'],
1680
- rows, // ['North', 'Oslo', 1, 2, 3, 4, 10], …
1681
- pagination: false,
1682
- virtualize: true, // a large matrix: draw the rows in view
1683
- rowClass: (row) => (row[0] === 'All' ? 'my-sum-row' : null),
1684
- crosstab: {
1685
- labelColumns: 2, // 'Region' and 'City' are row labels
1686
- headerRows: [[ // one row of spans per level, top first
1687
- { label: '2024', columns: [2, 3] },
1688
- { label: '2025', columns: [4, 5] },
1689
- { label: 'All', columns: [6], className: 'my-sum-head' },
1690
- ]],
1691
- },
1692
- });
1693
- ```
1694
-
1695
- | `crosstab` key | What it does |
1696
- |---|---|
1697
- | `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. |
1698
- | `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. |
1699
- | `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. |
1700
-
1701
- The table carries `twm-dt-crosstab`, the header rows `tr.twm-dt-crosstab__band`
1702
- and the column headers `tr.twm-dt-crosstab__leaf`; the spanned header cells are
1703
- `columnGroups`' group cells (`th.twm-dt-group`) and are styled as those. There
1704
- is no separate header table, so the split layout's column fit, resize grips,
1705
- header right-click menu (`columnChooser`, `headerMenu`, and the header items of
1706
- `onColumnRename`, `onColumnDelete` and `onRowAdd`), `columnActions`,
1707
- `reorderable` and `columnGroups` row do not apply (`columnGroups` is ignored
1708
- with a warning).
1709
-
1710
- A row's background is the custom property `--twm-dt-row-bg` on its `<tr>`
1711
- (the zebra stripe sets it on even rows, at no specificity). Every crosstab row
1712
- paints it, and the pinned label cells lay it over their opaque surface, so a
1713
- coloured row keeps its colour across the label cells too. To colour a row, set
1714
- the property from its `rowClass`, not `background` on the `tr` (the crosstab's
1715
- row rule paints the property instead):
1716
-
1717
- ```css
1718
- .my-sum-row { --twm-dt-row-bg: rgba(255, 255, 255, 0.06); }
1719
- ```
1720
- The pinned cells' background is one opaque stack the crosstab owns (it is
1721
- `!important`, so neither a state rule nor an animation can make it see-through):
1722
- every tint FlexDesk paints on a cell — hover, the clickable hover, row and cell
1723
- selection, `staged`, the fill's skipped mark, placeholders, `diff` and the
1724
- new-row flash — is drawn as a layer over the row's `--twm-dt-row-bg` over the
1725
- surface (`--surface-0`), the same tint the unpinned cells of that row show.
1726
- Under `virtualize` the columns are held at the widths first measured, so they
1727
- do not shift as rows scroll in and out. With 1,000 rows and 50 body columns
1728
- under `virtualize`, the first draw stays well under 300 ms in jsdom (tests/crosstab.test.mjs §6).
1729
-
1730
- ### Scrollbars
1731
-
1732
- `installAutoScrollbars(root)` (from `@flexdesk/widgets`) replaces the browser's
1733
- scrollbars under `root` with thin overlay bars that show while an element is
1734
- hovered or scrolling and fade out afterwards. Native bars are hidden, so no
1735
- element reserves layout width for one. The overlay bars live in one fixed
1736
- layer, so a container that redraws its contents cannot lose its bar, and
1737
- textareas get one too. Containers that already use `installOverlayScrollbar`
1738
- keep their own.
1739
-
1740
- `installCollapsibleScrollbars(root = document.body)` gives every
1741
- `.twm-collapsible-content.visible` section under `root` a vertical overlay
1742
- bar: the ones already visible at once, and the ones mounted or expanded
1743
- later. It watches `root` only and returns a disconnect. (It used to take no
1744
- argument and look for a `#twm-fixed-200` that nothing created, so visible
1745
- sections got no bar until a class changed — BUG-0006.)
1746
-
1747
- ### SliderField: a number box, a readout, a default and an enable toggle (0.17.0)
1748
-
1749
- `new SliderField({ fieldId, field, onChange })` (from `@flexdesk/widgets`)
1750
- draws a range input. Its companions are opt-in through `field`; with none of
1751
- them the DOM is the range and the helper line, as it always was.
1752
-
1753
- ```js
1754
- import { SliderField } from '@flexdesk/widgets';
1755
-
1756
- const slider = new SliderField({
1757
- fieldId: 'rate',
1758
- field: {
1759
- min: 0, max: 1, step: 0.01, value: 0.25,
1760
- numberInput: true, // an editable box, kept in sync both ways
1761
- readout: (v) => `${Math.round(v * 100)}%`, // or true for the plain value
1762
- defaultValue: 0.25, // --modified state and a reset button
1763
- toggleable: true, disabledValue: null, // an enable checkbox before the slider
1764
- },
1765
- onChange: (value, { immediate }) => save(value),
1766
- });
1767
- host.appendChild(slider.render());
1768
- ```
1769
-
1770
- | Field / method | What it does |
1771
- | --- | --- |
1772
- | `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 |
1773
- | `readout` | A span after the slider: `true` shows the value, a function `(value: number) => string` formats it. It follows the drag live |
1774
- | `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 |
1775
- | `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`) |
1776
- | `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` |
1777
- | `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 |
1778
- | `getValue()`, `isEnabled()`, `isModified()` | What `onChange` would report now, the toggle state, and whether the value differs from `defaultValue` |
1779
-
1780
- ### A stack of collapsible sections: `createSectionStack` (0.17.0)
1781
-
1782
- Inspector-style panel content: titled sections in a column, each opened and
1783
- closed from its header. It is built from the tree's `.twm-collapsible-*`
1784
- pieces plus `.twm-section-stack*` (`@flexdesk/widgets`).
1785
-
1786
- ```js
1787
- const stack = createSectionStack(panel, {
1788
- sections: [
1789
- { id: 'notes', title: 'Notes', icon: 'edit_note', content: notesEl },
1790
- { id: 'chat', title: 'Assistant', fill: true,
1791
- actions: [{ icon: 'delete', label: 'Clear', onClick: (id, event) => clear() }] },
1792
- { id: 'params', title: 'Parameters', defaultExpanded: false },
1793
- ],
1794
- key: 'inspector.sections',
1795
- stateStore: { read: (key) => saved[key], write: (key, map) => { saved[key] = map; } },
1796
- });
1797
- stack.body('params').appendChild(paramsEl);
1798
- const off = stack.on('toggled', ({ id, expanded, source }) => { /* … */ });
1799
- ```
1800
-
1801
- - **The header toggles, its actions do not.** A click anywhere on a header
1802
- opens or closes the section and turns its arrow. A click on one of its
1803
- `actions` runs that action's `onClick(sectionId, event)` and nothing else.
1804
- The toggle is a `<button>`, so Enter and Space work.
1805
- - **`defaultExpanded`** is `true` unless a section says `false`.
1806
- - **One `fill` section** takes the height the others leave, and its body is
1807
- what scrolls (`min-height: 0`). A long conversation scrolls inside its
1808
- section and the panel stays the size of the window. The stack is
1809
- `height: 100%` of its container, so give the container a height. A closed
1810
- fill section is only its header.
1811
- - **Remembering is injected.** `stateStore` is `{ read(key), write(key, map) }`.
1812
- `map` is `{ [sectionId]: expanded }`, and it is written on every open and
1813
- close under `key`. `read` may return a promise. Until it settles the
1814
- sections show their defaults; a section somebody toggled in the meantime
1815
- keeps that choice, and that toggle is written once the read has settled,
1816
- over the stored states of the sections nobody touched. `stack.ready` resolves once the read has been applied. A
1817
- store that throws, rejects or returns anything other than a map leaves every
1818
- section on its `defaultExpanded`. Without a store nothing is remembered.
1819
- - **`setVisible(id, false)`** takes a section off the stack and keeps its
1820
- state, so `setVisible(id, true)` brings it back open or closed as it was.
1821
- `isVisible(id)` reads it.
1822
- - `expand(id)`, `collapse(id)`, `toggle(id)` and `isExpanded(id)` work on
1823
- any section, hidden or not. An unknown id throws.
1824
- - **`toggled`** fires on each change with `{ id, expanded, source }`, where
1825
- `source` is `'user'`, `'api'` or `'restore'` (a stored state that arrived
1826
- after mount). `on('toggled', fn)` returns the function that stops listening.
1827
- - **`dispose()`** removes the stack's listener and every node it made, along
1828
- with any `content` you passed in.
1829
-
1830
- `tests/section_stack.test.mjs` covers each of these in jsdom. The fill
1831
- layout was also measured in headless Edge.
1832
-
1833
- ## Flow editors
1834
-
1835
- *New in 0.6.0.* Both entries first ship in FlexDesk 0.6.0.
1836
-
1837
- `@flexdesk/flow` holds three editors for flows — a graph of steps, each with
1838
- settings — and the kit they share: a **canvas** (nodes and lines), an
1839
- **outline** (the graph drawn as a list of blocks) and **lanes** (data flows,
1840
- left to right). All three are domain-neutral. An editor never fetches, never
1841
- saves and never starts a timer that writes: the step types, the reference
1842
- grammar, every finding and every byte of I/O are the consumer's, handed in as
1843
- options and handed back through callbacks. `@flexdesk/canvas` is the kernel
1844
- under the canvas editor (and under any other diagram), on its own so that a
1845
- diagram does not download the editors.
1846
-
1847
- Nothing here changes an existing entry. Both are new entry points, and their
1848
- CSS is four new sections at the end of `flexdesk.css`, read only by the classes
1849
- these modules set.
1850
-
1851
- ```js
1852
- import {
1853
- createStepCatalogue, createWidgetRegistry, createSettingsPanel,
1854
- TEMPLATE_REFERENCES, FlowHistory, bindFlowKeys,
1855
- } from '@flexdesk/flow';
1856
- ```
1857
-
1858
- ### The kit
1859
-
1860
- The parts every editor stands on, exported for a consumer that builds its own.
1861
- A test (`tests/flow_kit_vocabulary.test.mjs`) fails when a file under
1862
- `src/flow/` or `src/canvas/` names a consumer's vocabulary, calls `fetch` or
1863
- touches browser storage — and is shown to fail on a planted one.
1864
-
1865
- | Part | What it does |
1866
- |---|---|
1867
- | `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 |
1868
- | `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 |
1869
- | `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 |
1870
- | `createSettingsPanel(options)` | One step's settings, or the flow's own, drawn from the schema (below) |
1871
- | `createReferenceSyntax({name, pattern, format, describe, stepScope})` | What a chip is. Three are ready: `TEMPLATE_REFERENCES` (`${a.b[0]}`), `FORMULA_REFERENCES` (`[a.b]`), `PARAMETER_REFERENCES` (`{{name}}`). `find`, `format`, `rename(text, map)` and the consumer's `describe(match, ctx) → {label, tone, known}` |
1872
- | `createChipInput({value, syntaxes, multiline, readOnly, onInput})` | Text with references drawn as chips. **The value is always the plain text**; a chip is a view of it |
1873
- | `openStepPicker({anchor, entries, categories, where, paste})` | What "+" opens: grouped, searchable, ↑ ↓ and Enter; a refused entry is shown greyed with its reason. Resolves `{entry}`, `{paste: true}` or `null` |
1874
- | `openValuePicker({anchor, groups, note, onPick})` | *Insert a value*: the consumer's groups of values, a level down for a value with children (`body ›`), searchable across levels |
1875
- | `alwaysBefore(graph, catalogue, stepId, {loopPorts, waitsForAll})` | The steps that run before this one on every path, nearest first: the only ones *Insert a value* should offer. A parallel whose merge waits for every branch counts each branch's steps; a loop's body never counts for a step after the loop. `enclosingLoops` lists the loops around a step |
1876
- | `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* |
1877
- | `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 |
1878
- | `bindFlowKeys(root, handlers)`, `ownsUndo(target)` | An editor's keys, bound on its root, never on `window` (below) |
1879
- | `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 |
1880
- | `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 |
1881
-
1882
- **The settings panel.**
1883
-
1884
- ```js
1885
- const panel = createSettingsPanel({
1886
- widgets, references: { template: TEMPLATE_REFERENCES },
1887
- onChange: (stepId, key, value) => apply(stepId, key, value), // you write it into your graph
1888
- onRename: (stepId, label) => rename(stepId, label),
1889
- onFocusLost: () => surface.focus(),
1890
- values: async ({ stepId, field }) => groupsFor(stepId), // Insert a value; absent, no "{ }"
1891
- columns: (step) => inputColumnsOf(step), // upstream-column(s); null in a logic flow
1892
- host,
1893
- });
1894
- panel.show({ step, type, title, typeLabel, where, description, idLine, icon, tone,
1895
- rename: true, extra: [...], actions: [...], slots: { before, after } });
1896
- panel.setFindings(findings); // under each field, the rest at the top; rebuilds nothing
1897
- panel.focusField('retry.max_attempts');
1898
- panel.repaintSlots(); // your areas only; no field is rebuilt
1899
- ```
1900
-
1901
- It never writes into the step: a change is reported to `onChange`, and the
1902
- consumer writes it. A redraw puts focus back on the rebuilt control of the same
1903
- field, with the caret where it was, or hands it to `onFocusLost`, never to
1904
- `<body>`. Findings and slot redraws rebuild no field, so a run overlay that
1905
- refreshes every second leaves a name being typed alone. A control that cannot
1906
- be used is disabled and its reason is shown next to it, not only in a tooltip.
1907
- `extra` holds the editor's own fields (a port shown as a setting) and notes,
1908
- placed with `after: '<key>'` or `at: 'start'`.
1909
-
1910
- **The widgets.** A schema type with no `x-ui-widget` draws its plain control: an
1911
- `enum` a select (writing the enum's own value, so a number stays a number), a
1912
- string a text box, a number a number box, a boolean a checkbox, an object with
1913
- properties a group of its own fields. A control never writes a value the reader
1914
- did not give it. An untouched setting stays absent, and emptying one removes it.
1915
- A list or map entry keeps its stored type when another entry is edited. A widget
1916
- name nobody registered is refused on screen, by name, and its value is kept.
1917
-
1918
- **A widget is built before it is in the document.** The panel calls the widget,
1919
- then attaches what it returns, and a field inside a folded (`x-ui-fold`) group
1920
- is built inside a closed `<details>`. A widget that measures itself — a Monaco
1921
- editor registered as `expression` — must create that editor once its element is
1922
- connected and has a size (a `ResizeObserver` on `el` will tell it), never inside
1923
- the widget call: an editor built into a box with no size measures a zero
1924
- character width and paints a viewport of no lines, and nothing repaints it.
1925
-
1926
- | Generic widget | Draws |
1927
- |---|---|
1928
- | `template` | Text with chips and *Insert a value*. A setting that may also be a list or an object is shown as JSON and written back as one when the text parses |
1929
- | `expression` | A formula: monospace, with chips. Replace it with your own editor (`{replace: true}`) |
1930
- | `json-body` | JSON when the text parses, text otherwise |
1931
- | `key-value-map` | Names to values |
1932
- | `key-value-list` | Ordered assignments: `{variable, value}` or `{variable, expression}` |
1933
- | `string-list` | Short strings |
1934
- | `enum-chips` | A multi-select of `items.enum`, as chips, in the enum's order |
1935
- | `choice-cards` | One of N as radio rows, each with its words |
1936
- | `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) |
1937
-
1938
- | Schema hint | Meaning |
1939
- |---|---|
1940
- | `x-ui-widget` | The widget that draws the field |
1941
- | `x-ui-references` | The syntaxes its chips are recognised in, by name. Without it, `template`, `json-body` and the two key-value widgets use `template`, and `expression` uses `formula` |
1942
- | `x-ui-when` | `{field, in}`: drawn only while that sibling holds one of those values (its stored value, else its default) |
1943
- | `x-ui-placeholder`, `x-ui-multiline` | A placeholder; a string as a growing textarea |
1944
- | `x-ui-enum-labels`, `x-ui-enum-descriptions` | An enum's words, and a sentence under each choice card |
1945
- | `x-ui-fold` | An object drawn folded, with a one-line summary of what is set in it |
1946
- | `x-ui-add-label` | The words on a list's *Add* button |
1947
- | `x-ui-formula-references` | `key-value-list`: the syntaxes of a formula row (default `formula`) |
1948
-
1949
- **References and chips.** A syntax recognises references in order to draw them.
1950
- It is not a grammar. A reference your server would refuse is still drawn as a chip
1951
- (`describe` can mark it `known: false`) and kept as text. Each syntax has a
1952
- default tone for its chips (`PARAMETER_REFERENCES` is violet, the others blue),
1953
- so two syntaxes in one field read as two kinds of chip. In the chip input the
1954
- browser does the typing, so an IME works. After every input the text is read
1955
- back and re-tokenised. Everything else the control does on the text itself:
1956
- ← and → step over a chip, one Backspace removes a whole chip, a paste is plain
1957
- text, a copy puts the exact text on the clipboard, and Enter adds a line only in
1958
- a multi-line field. It keeps its own undo, because a script that redraws a
1959
- `contenteditable` breaks the browser's. `rename(text, map)` rewrites only the
1960
- path segment that names a step, and only once the syntax is told which segment
1961
- that is (`stepScope: 'steps'`, or a `stepSegment` function). A syntax nobody
1962
- configured renames nothing.
1963
-
1964
- **Keys.** `bindFlowKeys(root, handlers)` binds Ctrl/⌘+Z (`undo`), Ctrl/⌘+Y and
1965
- Ctrl/⌘+Shift+Z (`redo`), Delete (`remove`), Ctrl/⌘+D, C, X and V (`duplicate`,
1966
- `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`,
1967
- `moveDown`, `moveOut`), the arrows (`up`, `down`, `left`, `right`), Enter
1968
- (`open`) and Escape (`escape`), and returns an unbind. A key whose handler is
1969
- absent, or returns `false`, is not taken. An editor's own row or card marks
1970
- itself `data-twm-flow-item`, so Enter on it opens it. Inside a text field Ctrl+Z is the field's own, but it is
1971
- still stopped at the editor's root, so a tile beside the editor never hears it.
1972
- In a field every other key belongs to the field, and Enter on a button belongs
1973
- to the button. **Backspace is never an editor key.** It is not handled and not
1974
- stopped, because a host may use it to go back a level.
1975
-
1976
- **Tones and strings.** A category names a tone (`violet`, `teal`, `amber`,
1977
- `grey`, `blue`, `indigo`), and `tokens.css` maps each tone to colours:
1978
- `--twm-flow-<tone>-bg`, `-fg`, `-line`, `-icon-bg`, `-text-bg` and `-dot`.
1979
-
1980
- **What it does not do.** It does not validate a value against its schema, decide
1981
- whether a reference is valid, save, fetch, or keep anything between page loads.
1982
- Those belong to the consumer.
1983
-
1984
- **Where each claim was checked.** jsdom computes no layout and does no editing,
1985
- so the suites (`tests/flow_kit_*.test.mjs`) assert the DOM, the events and the
1986
- text. The rest was checked in headless Edge with real input over the DevTools
1987
- protocol: `demo/flow_kit_probe.mjs` serves the repository, opens
1988
- `demo/flow_kit.html` and drives it (`demo/flow_cdp.mjs` is the small driver it
1989
- uses, for the editors' pages too). The demo pages load the source barrels;
1990
- `FLOW_DIST=1 node demo/flow_<kit|canvas|outline|lanes>_probe.mjs` redirects
1991
- `flow.js`, `canvas.js` and the three sheets to `dist/`, so the same checks run
1992
- against the built bundle. All four probes passed both ways on the integration
1993
- build. In the chip input, typing on either side of
1994
- a chip, ← and → over it, one Backspace removing it, a real paste of `${a.b}`
1995
- becoming a chip, a real copy giving the exact text, an IME composition leaving
1996
- the text alone until it committed, and the field's own Ctrl+Z all passed. The
1997
- same Backspace in a plain `contenteditable` whose chips are not
1998
- `contenteditable="false"` split its chip. A pointer drag across a chip started
1999
- no native drag. `elementFromPoint` at the centre of Undo, "+", the panel's
2000
- first field, its title, a field's "{ }", an action, a choice and the strip's
2001
- *Go to it* returned each one. Both pickers opened inside the viewport, their
2002
- search box focused, and gave the focus back to "+" on Escape.
2003
-
2004
- ### The canvas editor
2005
-
2006
- A flow drawn as nodes and lines: a palette of step types, a pannable canvas and
2007
- the settings of whatever is picked, under a toolbar. It stands on the
2008
- `@flexdesk/canvas` kernel, which is also usable on its own for any diagram.
2009
-
2010
- **The kernel** (`@flexdesk/canvas`).
2011
-
2012
- | Part | What it does |
2013
- |---|---|
2014
- | `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) |
2015
- | `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 |
2016
- | `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 |
2017
- | `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 |
2018
- | `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 |
2019
- | `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 |
2020
- | `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) |
2021
- | `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 |
2022
-
2023
- A diagram with its own rules subclasses the three and passes them in. For
2024
- example, a schema diagram's router answers the generic refusals in its own
2025
- words and keeps its foreign-key rules in the policy:
2026
-
2027
- ```js
2028
- class ConnectorRouter extends Base.ConnectorRouter {
2029
- constructor({ platform }) {
2030
- super({ platform, selfReference: true, disabledEnds: ['source'],
2031
- endsOf: (e) => ({ from: `col:${e.from_col}`, to: `col:${e.to_col}` }),
2032
- strings: { nodeGone: 'That table is no longer on the diagram.' },
2033
- policy: (ctx) => myForeignKeyRules(ctx) });
2034
- }
2035
- }
2036
- ```
2037
-
2038
- `tests/canvas_graph.test.mjs` §7 builds such a diagram on the generic layer.
2039
-
2040
- **The editor.**
2041
-
2042
- ```js
2043
- import { createCanvasEditor, createStepCatalogue } from '@flexdesk/flow';
2044
-
2045
- const editor = createCanvasEditor(host, {
2046
- catalogue: createStepCatalogue(types, { categories }),
2047
- flowSettings: { title: 'Sync', schema, value }, // the flow's own settings, shown when nothing is picked
2048
- actions: [{ id: 'publish', label: 'Publish', icon: 'publish', primary: true, run: (ed) => publish(ed) }],
2049
- onChange: ({ graph, flowSettings, text, action, key, via }) => autosave(text),
2050
- values: async ({ stepId, field, before, loops }) => groups, // Insert a value
2051
- dataPortRead: (node, port) => `\${steps.${node.id}.${port.name}}`,
2052
- summariseReads: (node) => `Later steps read it as \${steps.${node.id}.…}`,
2053
- });
2054
- editor.load({ graph }); // a baseline: there is nothing to undo after it
2055
- editor.setFindings(validatorResult); // on the nodes, under the fields, in the strip
2056
- editor.setRunOverlay({ steps, ports, banner });
2057
- ```
2058
-
2059
- | Option | Meaning |
2060
- |---|---|
2061
- | `catalogue` | `createStepCatalogue(...)`, or the raw list of types. The palette shows them by category, except start-role types (`paletteTypes(type)` decides). A type with `unavailable` is shown greyed, with its sentence under it |
2062
- | `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 |
2063
- | `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}` |
2064
- | `summarise(node, type)` | A line under a node's ports. `summariseReads(node, type)` is the panel's id line |
2065
- | `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'`) |
2066
- | `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` |
2067
- | `flowSettings` | `{title, schema, value}` — the flow's own settings, edited as `flow:settings` |
2068
- | `readOnly` | `false`, or `{reason}`, which is shown above the editor. Every verb is then refused with that reason, and no gesture writes |
2069
- | `actions` | Your toolbar verbs, `[{id, label, icon, primary, danger, title, run(editor)}]`, after Undo, Redo, Arrange and Fit (and the zoom buttons, with `zoomControls`) |
2070
- | `slots` | `toolbarEnd(el)`, `flowPanel(el, editor)` and `stepPanel(el, node, editor)`, drawn by you; `setRunOverlay` redraws the panel slots and nothing else |
2071
- | `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}` |
2072
- | `multiSelect` (0.17.0) | `true` makes the pick a set (below, *Many picked*). Without it none of it is bound or drawn |
2073
- | `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 |
2074
- | `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*) |
2075
- | `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` |
2076
- | `removable(node, type)` | Whether a step may be removed. By default every step except a start step |
2077
- | `node` | `{width: 208, header: 34, row: 22}` (`CANVAS_NODE`). Lines are anchored by arithmetic over these numbers, and the stylesheet is held to them |
2078
- | `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 |
2079
- | `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`) |
2080
- | `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 |
2081
- | `history`, `gridSize`, `label` | `{limit, mergeMs}`, the snap grid, and the canvas's accessible name |
2082
- | `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) |
2083
- | `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) |
2084
- | `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) |
2085
- | `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) |
2086
- | `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) |
2087
- | `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 |
2088
-
2089
- | Method | Does |
2090
- |---|---|
2091
- | `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 |
2092
- | `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) |
2093
- | `getGraph()`, `serialise()`, `snapshot()`, `getFlowSettings()` | A copy of the graph; its byte-stable text; the text the history holds (graph and settings); the settings |
2094
- | `setFindings(list)`, `setRunOverlay(overlay \| null)`, `setReadOnly(false \| {reason})` | — |
2095
- | `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) |
2096
- | `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) |
2097
- | `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 |
2098
- | `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 |
2099
- | `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}` |
2100
- | `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) |
2101
- | `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) |
2102
- | `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 |
2103
- | `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) |
2104
- | `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` |
2105
- | `history`, `canvas` | The editor's `FlowHistory` (`keep()`, `adopt(kept, editor.snapshot())`, `baseline(editor.snapshot())` for your publish) and its `CanvasAdapter` |
2106
- | `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 |
2107
-
2108
- Its edits are the closed list `CANVAS_ACTIONS`: `flow:node:add`,
2109
- `flow:node:remove`, `flow:node:move` (one per drag), `flow:edge:connect`,
2110
- `flow:edge:disconnect`, `flow:node:label`, `flow:node:config`,
2111
- `flow:settings`, `flow:arrange` (one entry for the whole layout), and with
2112
- `multiSelect` `flow:nodes:move`, `flow:nodes:remove` and `flow:nodes:align`
2113
- (0.17.0; one entry per gesture on several steps), with `clipboard`
2114
- `flow:paste` and `flow:node:duplicate` (0.17.0; one entry per paste or
2115
- duplicate, however many steps it makes), with `collapsible`
2116
- `flow:node:collapse` (0.17.0), and with `groups` and `notes`
2117
- `flow:group:add`, `flow:group:remove`, `flow:group:label`,
2118
- `flow:group:members`, `flow:note:add`, `flow:note:remove`, `flow:note:edit` and
2119
- `flow:note:move` (0.17.0). Typing a name
2120
- or a setting is one entry. The geometry under it is exported as well, all of it
2121
- pure: `CANVAS_NODE`, `arrangeCanvasGraph(graph, catalogue, {loopEntry})` (where
2122
- Arrange puts each step: a Map of id → `{x, y}` from the origin),
2123
- `canvasPortAnchor`, `canvasNodeBottom` (both fold a node with `collapsed: true`
2124
- to its header, or as the box's `folded(node)` says; 0.17.0), `canvasNodeWidth` (a type's own
2125
- `size.w`, else the box's; 0.17.0), `isCanvasReturnEdge`,
2126
- `canvasPathPoints(d, {steps})` (the points along a drawn line's path `d`:
2127
- absolute `M`, `L`, `C`, `S` and `Z`, each curve sampled at `steps` points) and
2128
- `canvasPathMidpoint(d)` (the point halfway along it by length, where a style's
2129
- `label` sits).
2130
-
2131
- #### Node bodies (0.17.0)
2132
-
2133
- A catalogue type can draw its own controls and values inside its node:
2134
-
2135
- ```js
2136
- const gauge = {
2137
- type_id: 'gauge', label: 'Gauge', ports, size: { w: 280, h: 170 }, accent: 'teal',
2138
- render(body, node, ctx) { // once, when the node is built
2139
- const range = document.createElement('input');
2140
- range.type = 'range';
2141
- range.addEventListener('change', () => ctx.setConfig({ level: Number(range.value) }));
2142
- body.append(range);
2143
- },
2144
- update(body, node, ctx) { // after render, and on every repaint
2145
- body.querySelector('input').value = node.config.level ?? 50;
2146
- body.querySelector('input').disabled = ctx.readOnly;
2147
- },
2148
- };
2149
- editor.setCatalogue(createStepCatalogue([...types, gauge], { categories })); // after load too
2150
- ```
2151
-
2152
- - **The body sits below the ports** and is built once with the node. `update`
2153
- runs on the same element on every repaint, so a control inside it keeps its
2154
- focus and what was typed into it. `node` is a copy: the body writes only
2155
- through `ctx`. A later `setCatalogue` keeps that body too, unless the type's
2156
- own definition changed (its ports, `label`, `icon`, category tone, `render`
2157
- or `update`): then the node is built again and `render` runs again. A new
2158
- `size` or `accent` is applied in place.
2159
- - **`ctx`** is `{setConfig(patch), readOnly, values, services}`. `values` is
2160
- what `setNodeValues` shows on this step now (`{badge, tone, ports}`, a copy),
2161
- or `null`. `setConfig` merges
2162
- `patch` into the node's config (an `undefined` value removes the key) as one
2163
- `flow:node:config` edit: one undo entry, and `onChange` is called. It is
2164
- refused (answers `false`) while the editor is read only, from inside
2165
- `render` or `update`, and when a value in `patch` cannot be cloned (a
2166
- function, a DOM node): then nothing is written. `services` is the editor's `services` option.
2167
- - **`size: {w, h}`** is the node's box in pixels; either may be left out. Lines
2168
- leave a node's outputs at its own width, and a node with `h` is exactly that
2169
- tall, its body taking what is left. A body node without `h` is as tall as
2170
- it is drawn. Arrange spaces its columns by the widest node.
2171
- - **`accent`** is a tone (`violet`, `teal`, `amber`, `grey`, `blue`, `indigo`),
2172
- drawn as a stripe over the node's header.
2173
- - **A `render` that throws** leaves that node drawn as a type without a body
2174
- is, and what it threw is one of the editor's own findings: on the node and
2175
- in the strip, beside yours from `setFindings`. An `update` that throws keeps
2176
- the body and says so the same way until it succeeds. No other node is
2177
- affected.
2178
- - A type without these keys is drawn exactly as before, with the same markup,
2179
- and `serialise()` is unchanged.
2180
-
2181
- #### Edited on the node (0.17.0)
2182
-
2183
- ```js
2184
- const counter = { type_id: 'counter', label: 'Counter', ports, inline: ['count'],
2185
- config_schema: { type: 'object', properties: { count: { type: 'number' } } } };
2186
- const editor = createCanvasEditor(host, { catalogue: [counter], renameInPlace: true });
2187
- ```
2188
-
2189
- - **`renameInPlace: true`.** A double-click on a step's title puts a text box
2190
- in its place, the name selected. Enter, or leaving the box, is one
2191
- `flow:node:label` edit, and none when the name is unchanged. Escape puts the
2192
- name back and records nothing. An empty name is refused with a sentence on
2193
- the gesture line and the name stays: on Enter the box stays open
2194
- (`aria-invalid`), on leaving it the box closes. Read only, a double-click
2195
- opens nothing, and going read only closes an open box without writing. The
2196
- settings panel, if it is showing that step, shows the new name.
2197
- - **A type's `inline` settings** are drawn under the node's ports, above its
2198
- own body, by the same widget registry as the panel (`widgets`), with the
2199
- same labels and controls. A change on the node is a `flow:node:config` edit,
2200
- and the panel shows it at once. A change in the panel, or an undo, redraws
2201
- the node's control. The control being typed in is never redrawn under the
2202
- reader; a focused control whose value changes elsewhere (an undo pressed
2203
- while it holds the focus) is redrawn with its focus and caret kept. Typing
2204
- on the node and then in the panel are two undo entries. Read only disables
2205
- the node's controls. A node with inline settings and no
2206
- `size.h` is as tall as it is drawn.
2207
- - Without the option and the key nothing is bound or drawn: the same markup,
2208
- and `serialise()` is unchanged.
2209
-
2210
- #### Live values and folded steps (0.17.0)
2211
-
2212
- ```js
2213
- const editor = createCanvasEditor(host, { catalogue, collapsible: true });
2214
- editor.setNodeValues({ mix: { badge: '0.42', tone: 'teal', ports: { out: '0.42' } } }); // as often as you like
2215
- editor.setNodeValues(null); // none shown
2216
- ```
2217
-
2218
- - **`setNodeValues`** paints what you are showing right now: `badge` is drawn
2219
- on the step's header, before its id tag, in a tone of `TONES` when `tone`
2220
- names one, and each `ports[name]` beside that port. It replaces what the
2221
- last call showed, and a step it leaves out shows nothing. It extends the run
2222
- overlay: nothing is rebuilt or recorded, `onChange` is not called, and the
2223
- settings panel is not touched, so a field being typed into keeps its focus
2224
- and its selection however often you call it. A type with its own body reads
2225
- them as `ctx.values`, and its `update` runs for each step whose values
2226
- changed (a `setConfig` from there is refused, as from any `update`). The
2227
- badge and port-value elements exist only while there is a value to show;
2228
- the editor keeps nothing of them past the next call.
2229
- - **`collapsible: true`** puts a fold button on each header. A folded step
2230
- shows its header only: its port rows, its body and what is drawn under them
2231
- are hidden, and its lines stay, meeting the middle of the header on its left
2232
- (lines in) and its right (lines out). Each fold or unfold is one
2233
- `flow:node:collapse` entry, so Ctrl+Z unfolds a step just folded. The graph
2234
- carries `collapsed: true` on a folded step, written last in its `serialise()`
2235
- text; an unfolded step has no `collapsed` key, so a graph that never folded
2236
- a step is the text it always was. Read only refuses it.
2237
-
2238
- **Gestures.** A node's header moves it. A connection is made by clicking an
2239
- output and then an input, and Escape cancels one in progress. Under the
2240
- `connections` option an output can also be dragged to an input, or onto the
2241
- empty canvas to pick a step that arrives connected. A line is picked
2242
- by clicking it, and Delete removes it or the picked step. A palette step is
2243
- added by clicking it (at the centre of the view) or by dragging it onto the
2244
- canvas (where it is dropped). The drag uses pointer events and a ghost on
2245
- `document.body`. It never uses `draggable`, which cancels the pointer after
2246
- about 5px, and never `setPointerCapture`, which would send the release, and so
2247
- a second click, to the palette button. No press rebuilds a node, so a click lands
2248
- on the node it went down on. Arrange lays the run out left to right, a branch's
2249
- arms in their declared order, from the graph's own top-left corner. Fit frames
2250
- the graph at natural size or smaller; pressing Fit frames all of it.
2251
-
2252
- **Many picked** (`multiSelect: true`, 0.17.0). A left drag on the empty canvas
2253
- draws a marquee and picks every step it touches; Shift-, Ctrl- or ⌘-click on
2254
- a step adds it to the pick or takes it away; Ctrl/⌘+A picks every step and
2255
- Escape lets go. `select([id, …])` picks several from code. Dragging any picked
2256
- step moves them all (`flow:nodes:move`), Delete removes every one that may be
2257
- removed, with their lines (`flow:nodes:remove`; a start step stays), and the
2258
- panel says how many are picked, with their Remove. On the canvas the arrow
2259
- keys nudge the pick one grid step (`gridSize`), ten with Shift; a held arrow is
2260
- one entry. Eight icon verbs after Fit align the pick's left edges, centres,
2261
- right edges, tops, middles and bottoms, and distribute it horizontally and
2262
- vertically, each refused in place with fewer than two (three) picked. Arrange
2263
- lays out only the pick and the lines between them, and nothing else moves. Tab and
2264
- Shift+Tab move the focus from step to step in graph order, and past either end
2265
- Tab is the browser's again, so the keyboard can leave the canvas. Every one of
2266
- these is a single undo entry. In read-only mode a marquee still picks, and no
2267
- gesture writes.
2268
-
2269
- **Groups and notes** (`groups: true`, `notes: true`, 0.17.0). A group is a
2270
- named frame, in one of `TONES` if it has a `tone`, drawn behind its steps and
2271
- round all of them wherever they are. Its title bar is its handle: dragging it
2272
- moves every member (one `flow:nodes:move`; with `multiSelect` the press picks
2273
- them), and a double-click renames it in place (Enter keeps, Escape puts it
2274
- back). Groups are flat and a step is in one at most: Ctrl/⌘+G (or Group on the
2275
- toolbar) groups the picked steps, taking each out of the group it was in, and
2276
- a group left empty goes; Ctrl/⌘+Shift+G (or Ungroup) removes the groups the
2277
- picked steps are in. Removing a step shrinks its group, and removing its last
2278
- member removes the group. Between the steps the frame takes no pointer, so a
2279
- marquee or a pan can start inside it. A note is free text on the canvas: Note
2280
- on the toolbar adds one at the centre of the view and opens it for typing; a
2281
- double-click, or Enter on a focused note, opens it again (Ctrl/⌘+Enter or
2282
- leaving the box keeps the text, Escape puts it back); dragging it moves it,
2283
- and Delete removes the picked one. With `multiSelect` a marquee picks notes
2284
- with the steps, Shift/Ctrl+click adds or takes one away, Ctrl/⌘+A picks them
2285
- too, and dragging or deleting the pick takes its notes along, one entry. A
2286
- graph keeps them as `groups` and `notes` (see `normalise` above) and
2287
- `serialise()` writes them after the connections; a graph without any is the
2288
- same text as before.
2289
-
2290
- **Copy and paste** (`clipboard`, 0.17.0). Ctrl/⌘+C copies the picked steps
2291
- and the lines among them; a line to a step left out of the pick is not copied,
2292
- nor is a step whose type the palette does not offer (a start step) or that is
2293
- `unavailable`. What is copied is the graph's own text, `{nodes, connections}`
2294
- as `serialise()` writes it, so a graph from elsewhere pastes too. Ctrl/⌘+V
2295
- gives every pasted step a fresh id (as a new step gets one), keeps the lines
2296
- between them (data lines too, under `connections` with `dataPorts: 'connect'`, from 0.17.1), sets the copy off two grid steps right and down (each further
2297
- paste of the same text one more), picks it, and is one `flow:paste` entry. A
2298
- paste right after Ctrl/⌘+X puts the steps back where they were; a cut is one
2299
- remove entry. A step whose title the flow already has gets a derived label,
2300
- `Title (copy)`, then `Title (copy 2)`, …, as the lanes editor's duplicate does.
2301
- Ctrl/⌘+D does the same beside the pick without touching the clipboard, as one
2302
- `flow:node:duplicate`. With nothing picked Ctrl/⌘+C, X and D are not the
2303
- editor's. What is not steps, or names a type the flow does not have, is refused
2304
- with its sentence on the message line. In read-only mode copying still works,
2305
- and Paste, Cut and Duplicate are refused with the read-only reason and change
2306
- nothing. With `notes` the picked notes are copied, cut and duplicated with the
2307
- steps (a note alone too), and pasted under fresh ids, set off as the steps
2308
- are; with `groups` a group whose every member is copied is in the copy, and a
2309
- paste makes it again round the copies of its members (a fresh id, its name and
2310
- tone kept), while a group with a member left out is not copied. `copy()` and
2311
- `cut()` answer `notes: [id, …]` and `paste()` and `duplicate()` answer `notes`
2312
- and `groups`, each only when there are some.
2313
-
2314
- **Keys** (bound on the editor's root): Ctrl/⌘+Z and Ctrl/⌘+Y or Ctrl/⌘+Shift+Z,
2315
- stopped at the root even inside a text field; Delete; F2 renames the picked
2316
- step; Enter on a focused node opens its settings; Escape; with `groups`,
2317
- Ctrl/⌘+G and Ctrl/⌘+Shift+G. Backspace is never the editor's.
2318
-
2319
- **What it does not do.** It does not load, save, validate, publish or run
2320
- anything, and it does not keep the stacks across page loads. Those belong to
2321
- the consumer. Nor does it add anything a flow must have: a consumer whose new
2322
- flow opens on a start step adds that step to the graph before `load`, so it is
2323
- part of the baseline and the first Ctrl+Z does not take it away.
2324
-
2325
- **Where each claim was checked.** `tests/canvas_adapter.test.mjs`,
2326
- `canvas_edge_router`, `canvas_graph`, `flow_canvas_layout`, `flow_canvas_editor`,
2327
- `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
2328
- geometry and the stylesheet as text. In headless Edge, `demo/flow_canvas_probe.mjs` drives
2329
- `demo/flow_canvas.html` with real input:
2330
- - `elementFromPoint` at the centre of Undo, Arrange, Fit, a consumer verb, a
2331
- palette item, a node's header, a port, and the panel's name box, first field
2332
- and Remove step returned each one, and the panel was not covered.
2333
- - Every line began and ended within 1px of its ports' dots, measured.
2334
- - The loop's line back ran below both of its steps.
2335
- - A real pointer drag from the palette added exactly one step, under the
2336
- pointer, with a `dragstart` counter of 0. A drag ending off the canvas added
2337
- nothing.
2338
- - A real click-then-click connected two steps.
2339
- - A header drag moved its node, and a real Ctrl+Z put it back. Backspace removed
2340
- nothing, Delete removed the step, and the focus stayed on the canvas.
2341
- - On a 300-step graph with `multiSelect`, a real marquee picked three steps, a
2342
- real drag of one moved all three and one real Ctrl+Z put them back; a move
2343
- of 50 picked steps and its undo each finished in under 100 ms.
2344
- - With `groups` and `notes`, a real Ctrl+G on two picked steps drew one frame
2345
- round both and not round the third. Its title bar, and a step inside it, were
2346
- each the element at their own centre, and between the steps the canvas was
2347
- (the frame takes no pointer). A real drag of the title bar moved both by the
2348
- pointer's travel as one entry, and one real Ctrl+Z put them back. A real
2349
- double-click opened a note for typing, real keys and Ctrl+Enter kept the
2350
- text, a real drag moved it, and a real marquee picked the steps and the note
2351
- together.
2352
- - With `collapsible`, ten `setNodeValues` calls 100 ms apart each put their
2353
- value on the step's badge, inside its header, while real keys typed into a
2354
- settings field: the field kept the focus and every key, and the values made
2355
- no undo entry and no `onChange`. A real click on a header's fold button hid
2356
- every port row and left the step as tall as its header, with its lines
2357
- ending on the header's middle at its edges, within 1px; a real Ctrl+Z
2358
- unfolded it and its lines met their port dots again.
2359
- - A type registered with `setCatalogue` after the page opened was in the
2360
- palette. Its node was drawn at its 280×170 size, with its body below its
2361
- ports and its accent. Every line into and out of it began and ended on its
2362
- port dots, within 1px.
2363
- - At 700px the canvas took the editor's whole width.
2364
-
2365
- `tests/flow_canvas_edge_style.test.mjs` checks `edgeStyle`, `routing` and
2366
- `hoverHighlight` in the DOM, and `demo/flow_canvas_edges_probe.mjs` in headless
2367
- Edge:
2368
- - A dashed line had a computed dash pattern, and an unstyled line kept its old
2369
- stroke and width.
2370
- - An arrowhead's tip sat on the input port's centre line, and the line ended at
2371
- its base.
2372
- - A label sat on its line's midpoint over a step, and `elementFromPoint` there
2373
- returned the label.
2374
- - With `routing: 'obstacles'`, no point of a line lay inside the drawn box of
2375
- the step between its ends. Above the node limit the strip said so.
2376
- - A real pointer over a step highlighted its lines, and moving off cleared them.
2377
-
2378
- #### Building a modeler (0.17.1)
2379
-
2380
- A typed node modeler (a dataflow, a signal chain, a diagram of blocks) is the
2381
- canvas editor with its modeler options turned on. Every one of them is a
2382
- `createCanvasEditor` option or a key on a catalogue type, off by default: an
2383
- editor that sets none of them draws, binds and serialises exactly as before.
2384
- None of them stores anything: the graph, the viewport and the values shown are
2385
- the host's, handed in and handed back. Each edit they add is on the closed list
2386
- `CANVAS_ACTIONS`, so one gesture is one undo entry. `demo/flow_modeler.html`
2387
- (linked from `demo/index.html`) uses all of them together on a signal-flow
2388
- diagram of sources, gains, a sum and displays.
2389
-
2390
- | Option | What it gives | Details |
2391
- |---|---|---|
2392
- | `render`, `update`, `size`, `accent` (type keys) | A node that draws its own controls and values, at its own size, marked in a tone; `setCatalogue` adds a type while the editor is open | [Node bodies](#node-bodies-0170) |
2393
- | `connections` (`dataPorts`, `policy`) with `accepts` / `provides` (port keys) | Data ports that connect, typed, under the host's rules, by drag or click; an empty drop picks a step that arrives connected | `connections` above |
2394
- | `multiSelect` | Marquee, bulk move and delete, nudge, align and distribute | *Many picked* above |
2395
- | `clipboard`, `prepareCopy` | Copy, cut, paste and duplicate of the picked steps and the lines among them | *Copy and paste* above |
2396
- | `groups`, `notes` | Flat named frames round steps, and free-text notes | *Groups and notes* above |
2397
- | `layout`, `rank` (type key) | Arrange by the kernel's layered layout, sources first and sinks last, or your own | `layout` above |
2398
- | `edgeStyle`, `routing`, `hoverHighlight` | Lines in a tone, dashed, wider, with an arrow or a crow's foot and a label; bent round steps; lit while pointed at | `edgeStyle` above |
2399
- | `viewport`, `onViewportChange`, `zoomControls`, `minimap` | Where the reader looks, kept by you; zoom buttons; an overview | `viewport` above |
2400
- | `renameInPlace`, `inline` (type key) | A step's name and chosen settings edited on the node | [Edited on the node](#edited-on-the-node-0170) |
2401
- | `setNodeValues`, `collapsible` | Values painted on the steps as often as you like; steps folded to their header | [Live values and folded steps](#live-values-and-folded-steps-0170) |
2402
-
2403
- A type draws its own body (`render` once, `update` on every repaint), with its
2404
- own `size` and `accent`; `inline` draws chosen settings on the node, and
2405
- `rank` places it when Arrange runs the layered layout:
2406
-
2407
- ```js
2408
- const IN = { name: 'in', direction: 'input', port_type: 'DATA', accepts: ['number'], maxConnections: 1 };
2409
- const OUT = { name: 'out', direction: 'output', port_type: 'DATA', provides: ['number'] };
2410
- const types = [
2411
- { type_id: 'source', label: 'Source', rank: 'source', inline: ['amplitude'], ports: [OUT],
2412
- config_schema: { type: 'object', properties: { amplitude: { type: 'number', title: 'Amplitude' } } } },
2413
- { type_id: 'gain', label: 'Gain', size: { w: 224, h: 132 }, accent: 'amber', ports: [IN, OUT],
2414
- render(body, node, ctx) {
2415
- const range = Object.assign(document.createElement('input'), { type: 'range', min: -2, max: 2, step: 0.1 });
2416
- range.addEventListener('change', () => ctx.setConfig({ k: Number(range.value) })); // one undo entry
2417
- body.append(range);
2418
- },
2419
- update(body, node, ctx) { body.querySelector('input').value = node.config.k ?? 1; } },
2420
- { type_id: 'display', label: 'Display', rank: 'sink', ports: [IN],
2421
- render: (body) => body.append(document.createElement('output')),
2422
- update: (body, node, ctx) => { body.querySelector('output').textContent = ctx.values?.badge ?? '—'; } },
2423
- ];
2424
- editor.setCatalogue(createStepCatalogue([...types, laterType], { categories })); // a type added after load
2425
- ```
2426
-
2427
- The editor's options, each one independent of the others:
2428
-
2429
- ```js
2430
- const editor = createCanvasEditor(host, {
2431
- catalogue: createStepCatalogue(types, { categories }),
2432
- connections: { // data ports connect, typed by accepts/provides
2433
- dataPorts: 'connect',
2434
- policy: ({ connection, graph }) => (closesLoop(graph, connection)
2435
- ? { ok: false, reason: 'That line would feed a block its own output.' } : null),
2436
- },
2437
- multiSelect: true, // marquee, bulk move, align, distribute
2438
- clipboard: true, // or {read(), write(text)} to hold it yourself
2439
- prepareCopy: (copy, { from, via }) => { copy.config.phase = 0; },
2440
- groups: true, notes: true, // Ctrl+G frames, free-text notes
2441
- layout: 'layered', // Arrange: columns by the lines, `rank` hints
2442
- edgeStyle: (c, { source, target }) => ({ tone: 'teal', marker: 'arrow', width: 2,
2443
- dashed: c.targetPort === 'b', label: source.label }),
2444
- routing: 'obstacles', hoverHighlight: true,
2445
- viewport: savedView, // opens there instead of fitting
2446
- onViewportChange: (view) => { savedView = view; }, // once per finished pan or zoom
2447
- zoomControls: true, minimap: true,
2448
- renameInPlace: true, // double-click a title to rename it
2449
- collapsible: true, // a fold button on every header
2450
- });
2451
- editor.load({ graph }, { viewport: savedView });
2452
- setInterval(() => editor.setNodeValues({ mix: { badge: '0.42', tone: 'teal', ports: { out: '0.42' } } }), 250);
2453
- ```
2454
-
2455
- `setNodeValues` records nothing and leaves the focus where it is, so a host
2456
- can paint as often as its values change.
2457
-
2458
- **Where it was checked.** `tests/flow_modeler_demo.test.mjs` mounts the demo
2459
- under jsdom: what it shows, its live badges, and this section's list against
2460
- the options. `tests/flow_kit_vocabulary.test.mjs` §4 holds every flow and
2461
- canvas source the demo imports, and the demo itself, to the vocabulary gate.
2462
- In headless Edge, `demo/flow_modeler_probe.mjs` opens the page by a real click
2463
- on its link in `demo/index.html` and drives it with real input:
2464
- - The bodies were drawn at their own size below their ports; every line left
2465
- its output dot and its arrowhead ended on its input dot, within 1px; the
2466
- group frame was round both sources and nothing else; a dashed line had a
2467
- dash pattern and a label was the element at its own centre.
2468
- - The badges changed in each of three seconds while a settings field, focused
2469
- by a real click, kept the focus, and nothing was recorded.
2470
- - A real drag from an output to an input added one line, dot to dot, as one
2471
- undo entry. The gesture line appears above the canvas when a drag starts and
2472
- moves the canvas down by its height, so the probe aims at the input where it
2473
- is drawn once the line has started, as a reader would.
2474
- - A real click and Shift+click and a real Ctrl+G drew a second frame round
2475
- exactly the two steps; real Ctrl+C and Ctrl+V put two copies two grid steps
2476
- right of and below the originals, with the line between them; a real click
2477
- on Arrange ran every line left to right, the sources first and the displays
2478
- last, with no two steps overlapping. Each was one undo entry.
2479
- - A real Ctrl+Z after each, last first, put the steps back where they were on
2480
- the canvas, took the copies, the second frame and the dragged line away, and
2481
- left the graph the text it was before each step.
2482
-
2483
-
2484
- ### The outline editor
2485
-
2486
- `createOutlineEditor` draws a logic flow as a list of blocks: *If … otherwise*,
2487
- *For each row* with its *Next row* foot, *At the same time* with its *Then wait
2488
- for every branch* foot, and a step's other ports (an error port, an empty-result
2489
- port) as arms under it. **The outline is the graph.** It reads the same
2490
- `{nodes, connections}` JSON the canvas edits, holds no second model, and every
2491
- edit is an operation on that graph. A graph opened and saved with no edit is the
2492
- same text, byte for byte, and node positions are kept and never used. A graph
2493
- that is not block-shaped is refused, never repaired: it opens read-only, as a
2494
- flat list in the order it runs, with the reason above it.
2495
-
2496
- ```js
2497
- import { createOutlineEditor, createStepCatalogue, createWidgetRegistry, TEMPLATE_REFERENCES } from '@flexdesk/flow';
2498
-
2499
- const editor = createOutlineEditor(host, {
2500
- catalogue, widgets: createWidgetRegistry(), references: { template: TEMPLATE_REFERENCES },
2501
- blocks, // which roles are blocks, and their words (below)
2502
- values: async ({ stepId, field, before, loops }) => groupsFor(before, loops), // Insert a value
2503
- valuesNote: 'Only steps that always run before this one are listed.',
2504
- summarise: (node, type) => oneLine(node), // a row's line; {text, mono: true} for code
2505
- summariseReads: (node, type) => howLaterStepsRead(node), // the panel's id line
2506
- flowSettings: { schema, value, title, typeLabel, where, description, idLine },
2507
- actions: [{ id: 'publish', label: 'Publish', icon: 'publish', primary: true, run: () => publish() }],
2508
- slots: { toolbarEnd: (el) => {}, flowPanel: (el) => {}, stepPanel: (el, node) => {} },
2509
- initialFolds: saved, onFoldChange: (ids) => keep(ids),
2510
- onChange: ({ graph, flowSettings, text, action, key }) => autosave(text),
2511
- onSelect: ({ kind, id }) => {}, // kind: 'flow' or 'step'
2512
- readOnly: false, // or {reason}
2513
- history: { limit: 100, mergeMs: 1000 },
2514
- });
2515
- editor.load({ graph, flowSettings }); // a baseline: nothing to undo
2516
- ```
2517
-
2518
- | Method | Does |
2519
- |---|---|
2520
- | `load({graph, flowSettings}, {baseline = true})` | Replaces the content; a baseline clears both undo stacks and emits nothing |
2521
- | `getGraph()`, `serialise()`, `getFlowSettings()` | A copy of the graph; its byte-stable text; the flow's own settings |
2522
- | `setFindings(list)` | A finding with a `node_id` replaces that row's line with its message, in the error or warning tone, marks the row, and is drawn under its field; the others go to the strip, with *Go to it* |
2523
- | `setRunOverlay({steps, ports, banner} \| null)` | A run drawn on the outline (below) |
2524
- | `setReadOnly(false \| {reason})`, `setActionState(id, {disabled, reason, busy})`, `setStatus(text)` | A refused verb is disabled with its reason beside it |
2525
- | `select(id \| 'flow' \| null)`, `focus()`, `undo()`, `redo()`, `history`, `folds`, `setFolds(ids)` | `select` unfolds the blocks around the step |
2526
- | `describe()`, `refusal` | The outline as text (`describeOutline`), or why the graph could not be drawn as one |
2527
- | `destroy()` | Removes every listener, popover and ghost. It flushes nothing, because it holds nothing |
2528
-
2529
- **The block mapping.** The consumer says which catalogue roles are blocks and
2530
- what their parts are called. Nothing here guesses a domain:
2531
-
2532
- ```js
2533
- const blocks = {
2534
- start: { role: 'start', label: 'When it runs' }, // the flow row
2535
- end: { role: 'end', entry: { label: 'End the run', sub: 'End' } },
2536
- step: { input: 'in', continue: 'out' }, // an ordinary step's two ports
2537
- branch: { role: 'branch', arms: { true: 'Then', false: 'Otherwise' },
2538
- entry: { label: 'If … otherwise', sub: 'Condition' }, note: 'Either arm may be empty.' },
2539
- fanout: { role: 'fanout', join: { role: 'join', type: 'merge', field: 'join',
2540
- foot: { all: 'Then wait for every branch', any: 'Then go on when the first finishes' } },
2541
- arm: 'Branch {n}', addArm: 'Add a branch', entry: { label: 'At the same time', sub: 'Parallel + Merge' } },
2542
- loop: { role: 'loop', ports: { entry: 'in', next: 'next', body: 'body', done: 'done' },
2543
- foot: 'Next row', skip: 'Go on with the next row', addInside: 'Add a step to the loop',
2544
- entry: { label: 'For each row', sub: 'Loop over rows' } },
2545
- arms: { error: { label: (step) => `If ${step.label} fails`, unconnected: 'fail',
2546
- setting: { label: 'If it fails', unconnected: 'Fail the run', after: 'timeout_seconds',
2547
- connected: (arm) => `Run the steps under “${arm}”` } } },
2548
- types: { 'http-request': { arms: { error: { label: 'If the request fails' } } } },
2549
- };
2550
- ```
2551
-
2552
- `unconnected` is what your runtime does with a port nothing is connected to,
2553
- `'fail'` or `'stop'`, and only you can say it. An ordinary step's other flow
2554
- outputs are its arms, in the order the type declares them. **Ports become
2555
- settings** in the panel. An arm is a choice between "Fail the run" (or "Stop
2556
- here") and "Run the steps under …", which connects or disconnects the port. An
2557
- arm inside a loop also chooses between carrying on with the next step and going
2558
- on with the next row. A parallel step carries its merge's *When the branches
2559
- finish*, written to the merge step's config.
2560
-
2561
- **What "block-shaped" means.** From the start step, every step is placed exactly
2562
- once: as a step, a branch, a parallel closed by one merge, a loop whose body
2563
- ends at its Next, or an end. An arm ends by rejoining what follows its block,
2564
- by going on with the loop's next row, by ending, or by leaving its port
2565
- unconnected. Anything else is refused with a code and the step it names:
2566
- `outline_no_start`, `outline_two_starts`, `outline_unknown_type`,
2567
- `outline_unknown_port`, `outline_broken_line`, `outline_unreachable`,
2568
- `outline_shared_step`, `outline_jump_out`, `outline_two_continuations`,
2569
- `outline_parallel_unjoined`, `outline_merge_unpaired`, `outline_loop_shape`,
2570
- `outline_after_end`, `outline_cycle`. Where a branch's arms meet nowhere
2571
- (every arm but one ends), the arm that goes on holds the rest of its list. The
2572
- lines cannot say more than that, and the outline does not pretend they do.
2573
-
2574
- **Edits.** "+" between rows (shown on hover and on focus) and the add rows open
2575
- the kit's step picker. It says where the step will land (*"inside For each new
2576
- application, after Check eligibility"*) and offers the blocks as blocks: *At
2577
- the same time* is one entry and two steps. A step's menu (its "…", a
2578
- right-click, or the ContextMenu key) moves it up, down or out, wraps it in a
2579
- block, duplicates, copies, cuts, pastes after, renames and removes it. An entry
2580
- or a verb that would leave a graph the outline cannot draw is shown greyed with
2581
- its reason: *"Move down — already last in For each new application"*. A step is
2582
- dragged by its grip, with pointer events and a ghost after 4 px, never
2583
- `draggable` and never `setPointerCapture`. Its drop line names the place
2584
- (*"Into Branch 2 · after Write a summary"*) or says why the place is refused.
2585
- Each edit is one history entry from a closed list: `flow:step:add`, `remove`,
2586
- `move`, `wrap`, `duplicate`, `paste`, `label` and `config`, `flow:arm`,
2587
- `flow:branch:add`, `flow:branch:remove` and `flow:settings`.
2588
-
2589
- **Keys.** ↑ and ↓ move the selection, ← and → fold and unfold, and Enter opens
2590
- the settings at their first field. Alt+↑, Alt+↓ and Alt+← move a step, Ctrl+D
2591
- duplicates, Ctrl+C, Ctrl+X and Ctrl+V copy, cut and paste after the selection,
2592
- F2 renames, Delete removes, and Ctrl+Z and Ctrl+Y undo and redo. All of them are
2593
- bound on the editor's root. Backspace is never the editor's.
2594
-
2595
- ***Insert a value*.** A field's "{ }" calls your `values` provider with
2596
- `before`, the steps that always run before this one, nearest first
2597
- (`alwaysBefore`), and `loops`, the loops the step is inside, innermost first. A
2598
- step on the other arm of a condition is never in `before`, and neither is a
2599
- loop's body for a step after the loop.
2600
-
2601
- **A run drawn on the outline.** `setRunOverlay({steps: {[id]: {state, line,
2602
- tone}}, ports: {[id]: {[port]: {taken, count}}}, banner})` puts a pill beside
2603
- each step. The pill shows `line` as you give it, toned by `state`
2604
- (`completed`, `failed`, `skipped`, `running`, `cancelled`, `not-reached`). Each
2605
- arm says *taken*, *not taken* or a count, an untaken arm and its steps are
2606
- dimmed, a loop's *Next row* shows its count (`ports[loop].next.count`), and
2607
- your banner is shown above the list. It draws only what you pass, and it
2608
- rebuilds no field in the panel. A redraw you did not ask for (findings, a run
2609
- refresh, a read-only switch) waits while a press is held in the editor, because
2610
- a browser fires a click only when the press and the release land on the same
2611
- element.
2612
-
2613
- **The pure parts** are exported for a consumer that reads or checks an outline
2614
- without drawing one. `outlineFromGraph(graph, catalogue, blocks)` returns
2615
- `{ok: true, tree}` or `{ok: false, code, message, node_id}`, and
2616
- `graphFromOutline(tree)` is its inverse (`outlineGraphsEqual(a, b)` compares
2617
- two graphs as sets). `describeOutline(tree)` gives the tree as text.
2618
- `createOutlineOperations({catalogue, blocks, references})` gives every edit as
2619
- a function from a graph to `{ok, graph, select}` or `{ok: false, reason}`. Also
2620
- exported: `createOutlineBlocks`, `outlineRunOrder`, `outlineFlatOrder`,
2621
- `indexOutline`, `openOutlineStepMenu`, `OUTLINE_STRINGS`, `OUTLINE_ACTIONS` and
2622
- `OUTLINE_INDENT` (22 px a level, which the stylesheet also uses).
2623
-
2624
- **What it does not do.** It does not fetch, save, validate or start a timer
2625
- that writes. Your validator's findings are drawn, never made. The folds are
2626
- yours to keep (`initialFolds`, `onFoldChange`).
2627
-
2628
- **Where each claim was checked.** The round trip is tested over a corpus
2629
- (`tests/fixtures/flow_outline_corpus.json`). Every block-shaped graph is
2630
- recognised as the tree the corpus states and written back equal as sets (I1).
2631
- Opened and saved with no edit, it is the same text (I2). Random sequences of
2632
- operations keep it block-shaped (I3). Every refusal names a code and a step and
2633
- leaves the graph untouched (I4). `tests/flow_outline_editor.test.mjs` mounts
2634
- the editor in jsdom, and `tests/flow_outline_css.test.mjs` checks that every
2635
- class the editor sets has a rule and every rule names a class it sets. jsdom
2636
- computes no layout and starts no drag, so `demo/flow_outline_probe.mjs` drives
2637
- `demo/flow_outline.html` in headless Edge with real input. `elementFromPoint`
2638
- at the centre of Undo, an action, Fold all, the find box, the flow row, a row,
2639
- its menu, its "+" (with its row hovered), an add row, the panel's first field,
2640
- a port drawn as a setting and the strip's *Go to it* returned each one. A "+"
2641
- whose row was not hovered caught no press, and a level measured 22 px. The step
2642
- picker and the step menu opened inside the viewport on a real press, real keys
2643
- filtered and chose, and Escape gave the focus back. Real Alt+↑, Ctrl+Z, Ctrl+D
2644
- and Delete edited the flow, and Backspace reached the window unprevented. A real
2645
- grip drag drew its ghost and a drop line naming the place, moved the step as
2646
- one edit and started no native drag (a `dragstart` counter at 0). A refused
2647
- place said why and moved nothing, and Escape cancelled the drag.
2648
-
2649
- **In a narrow editor** (under 1000px the list, the settings and the aside are
2650
- stacked and the body scrolls as one), a step picked with the mouse, with Enter,
2651
- with *Go to it* or with `select()` scrolls its settings on screen (`'nearest'`)
2652
- when they lie outside the body's visible box. ↑ and ↓ leave the list where it
2653
- is, and side by side nothing scrolls. `demo/flow_outline_narrow_probe.mjs`
2654
- pressed a step at a 900px viewport and found the panel's first field with
2655
- `elementFromPoint`. At 1440px the same press moved no scroll position.
2656
-
2657
- ### The lane editor
2658
-
2659
- `createLaneEditor(host, options)` draws a data flow as lanes, left to right.
2660
- Every source starts a lane, and each step follows the one before it. A join
2661
- brings another lane in from below, and its line is laid out for you. Cards sit
2662
- where the layout puts them: **nothing is dragged and no line is drawn by
2663
- hand.** A step is added with the "+" on a card's right tip, which splices it in
2664
- between the card and what the card led to (`A → new → B`). *Add a source* starts
2665
- a new lane. A join's other lanes are settings (*Joined with*), not lines. Under
2666
- the lanes are a tab per step and a dock: the kit's settings panel on the left,
2667
- and on the right what the step gives — **Preview**, **Columns** and, for a sink,
2668
- **Would be rejected**. A toolbar toggle, **Steps show: The preview | The last
2669
- run**, picks what each card's status line says.
2670
-
2671
- ```js
2672
- import { createLaneEditor, createStepCatalogue, createWidgetRegistry, PARAMETER_REFERENCES } from '@flexdesk/flow';
2673
-
2674
- const editor = createLaneEditor(host, {
2675
- catalogue: createStepCatalogue(types, { categories }), // roles: source | transform | operation | sink
2676
- widgets: createWidgetRegistry(),
2677
- references: { parameter: PARAMETER_REFERENCES },
2678
- summarise: (step, type) => '…', // a card's second line
2679
- preview: async ({ pipeline, text, parameters, signal }) => ({ nodes: { [id]: {
2680
- status, rows, total, columns: [{ name, type }], head: [[…]], caption, rejects, error } } }),
2681
- describe: async ({ pipeline, text, parameters, signal }) => ({ nodes: { [id]: {
2682
- columns: [{ name, type, origin, change }] } } }),
2683
- lastRun: async ({ pipeline, signal }) => runOverlay, // asked when Steps show turns to The last run
2684
- actions: [{ id: 'publish', label: 'Publish', primary: true, run }],
2685
- onChange: ({ pipeline, text, action, key }) => save(text), // after every edit, undo and redo
2686
- });
2687
- editor.load({ graph: pipeline });
2688
- ```
2689
-
2690
- It edits the data flow's JSON — `{nodes: [{id, type, label?, config, position?}],
2691
- connections: [{id, sourceId, sourcePort, targetId, targetPort}], parameters:
2692
- {name: {type, default?, description?}}}` — with two rules added. A step's name
2693
- is a top-level `label`, never a key inside `config`. Connection ids are
2694
- deterministic: `c1`, `c2`, … the lowest free number. `serialisePipeline` is
2695
- byte-stable, so a flow opened and saved with no edit is the same text. Keys the
2696
- editor does not know are kept, and a node's `position` is kept and never used.
2697
- **A step's ports keep their declared order**: the first FLOW input is the lane
2698
- input, and every further input takes another lane. The first output is the
2699
- continuation.
2700
-
2701
- **The layout** is `layoutLanes(pipeline, catalogue)` and `placeLanes(layout,
2702
- size)`. Both are pure, and the wires are computed from the grid, never measured
2703
- from the page. A step belongs to the lane that feeds its lane input, not to
2704
- whichever lane reaches it first. An output that feeds two steps' lane inputs
2705
- forks a new lane below. A feeder lane is moved right until its last card sits
2706
- one column before the join it feeds, and never so far that the join moves.
2707
- Under each lane come its feeders, then its forks. A wire that would run through a card that is not one of its ends (a feeder that goes on past its feed, a fork drawn under a feeder) goes round by the gutters, the gaps between columns and between lanes, which hold no card. A step two lines feed on one
2708
- input (`lanes_two_inputs`), a cycle (`lanes_cycle`) or two steps with one id
2709
- (`lanes_duplicate_step`) cannot be drawn honestly as lanes. Such a flow opens
2710
- read only with the sentence, is drawn as well as it can be, and is never
2711
- repaired. The sizes are `LANE_GEOMETRY`: `regular` is 200 × 62 (the editor),
2712
- `small` is 160 × 50 (two lines a card, for a narrower host) and `strip` is
2713
- 92 × 26 (`compact`). The pure edits — splice, add a source, remove, duplicate,
2714
- set an input, the input candidates, parameters — are `lanePipeline`.
2715
- `lanePipeline.duplicateStep(pipeline, catalogue, id)` (0.17.0) puts a copy right
2716
- after the step: a fresh id, a label derived from its title (`Title (copy)`, then
2717
- `(copy 2)`, …, words in `LANE_PIPELINE_STRINGS`), a deep copy of its config and
2718
- the same lines in, none out. It answers `{ok: true, node}`, or `{ok: false,
2719
- reason}` and changes nothing when there is no such step or its type is
2720
- `unavailable`.
2721
-
2722
- | Option | Meaning |
2723
- |---|---|
2724
- | `catalogue` | The kit's step catalogue (or a list of types and `categories`) |
2725
- | `widgets`, `references`, `services`, `strings` | As for the kit's panel. `references.parameter` decides how the parameters strip spells a reference |
2726
- | `values` | *Insert a value*: `({pipeline, stepId, field, key, upstream, parameters}) => groups`. `upstream` is every step whose output reaches this one, nearest first |
2727
- | `summarise(step, type)`, `summariseReads(step, type)` | A card's second line; the panel's id line |
2728
- | `preview`, `describe` | The providers (above). Absent, the dock has no Preview, or no Columns |
2729
- | `previewDelayMs` | The debounce, 800 by default |
2730
- | `lastRun` | Asked once, when *Steps show* first turns to *The last run* and no overlay is set |
2731
- | `flowSettings` | `{schema, value}`: the flow's own settings, under a first tab, *The flow* |
2732
- | `readOnly` | `false`, or `{reason}`, drawn above the editor |
2733
- | `actions` | Toolbar verbs after Undo and Redo: `[{id, label, icon?, primary?, run}]` |
2734
- | `slots` | `toolbarStart(el)`, `toolbarEnd(el)`, `flowPanel(el)`, `stepPanel(el, step)` |
2735
- | `size` | `'regular'` or `'small'` |
2736
- | `stepTabs`, `parameters` | `false` leaves out the step tabs or the parameters strip |
2737
- | `height` | `'fill'` (the default: the editor fills its host, which must have a height) or `'auto'` (the editor flows, and the dock is `dockHeight`, 360 by default) |
2738
- | `compact` | `true` (or `{size: 'small'}`): the lanes alone, read only, nothing to press. This is what a step that runs a data flow shows as *What it does, left to right* |
2739
- | `parameterTypes` | The parameter types a parameter may be: `text`, `number`, `integer`, `boolean`, `date` by default |
2740
- | `select` | The step chosen on load (the first step, left to right, by default) |
2741
- | `history` | `{limit, mergeMs}` |
2742
- | `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) |
2743
- | `onChange`, `onSelect({kind, id})`, `onStepsShow(mode)` | Callbacks |
2744
-
2745
- | Method | Does |
2746
- |---|---|
2747
- | `load({graph, flowSettings}, {baseline = true})` | Replaces the content. A baseline clears both undo stacks, and a preview is asked for at once |
2748
- | `getGraph()`, `serialise()`, `getFlowSettings()` | A copy of the flow; its byte-stable text |
2749
- | `setFindings(list)` | On the card (its status line becomes the message), under the field, and in the strip with *Go to it* |
2750
- | `setRunOverlay({steps: {[id]: {state, line, tone}}, banner})` | The last run, shown when *Steps show* is *The last run*. `line` is drawn as given |
2751
- | `setStepsShow('preview' \| 'run')`, `stepsShow` | — |
2752
- | `setReadOnly(false \| {reason})`, `readOnly` | — |
2753
- | `setActionState(id, {disabled, reason, busy})`, `setStatus(text)` | A refused verb shows its reason beside it |
2754
- | `select(id \| 'flow' \| null)`, `selected`, `focus()` | — |
2755
- | `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`) |
2756
- | `refreshPreview()` | Asks the providers now, without the debounce |
2757
- | `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 |
2758
- | `layout`, `placed` | The current layout and its coordinates |
2759
- | `destroy()` | Removes every listener, popover and table, and aborts the preview in flight |
2760
-
2761
- **Columns that arrive while you are in the settings** are drawn when the focus
2762
- leaves them, so a column typed by name before the columns were known becomes a
2763
- choice then, and nothing is rebuilt under the reader's caret.
2764
-
2765
- **The preview** is one request for the whole flow to each provider. It is sent
2766
- 800 ms after the last edit and once after a load. A rename does not send one,
2767
- because it changes no row. Every request carries a sequence number and an
2768
- `AbortSignal`. A newer edit aborts the request in flight, and an answer that is
2769
- not the newest is dropped even when it arrives. A failure is shown as the
2770
- provider's own sentence in the dock, and nothing else changes. The rows are a
2771
- FlexDesk `DataTable` in a box with a definite height. How many rows a preview
2772
- reads, as whom it reads them and that it writes nothing are all the provider's
2773
- business; the editor knows only what it is handed.
2774
-
2775
- **Keys.** These are the kit's, on the editor's root. ← → ↑ ↓ move between cards
2776
- and between the step tabs. Enter opens a step's settings on their first field,
2777
- and Escape goes back to the card. Delete removes the step, Ctrl/⌘+D duplicates
2778
- it (the copy is chosen and focused) and F2 renames it.
2779
- Ctrl/⌘+Z and Ctrl/⌘+Y undo and redo, and are stopped at the editor even inside
2780
- a field. The context-menu key or a right press opens a card's menu: *Open its
2781
- settings*, *Add a step after it*, *Duplicate*, *Remove step*, then your
2782
- `menuItems`. An item that cannot be chosen — *Duplicate* on a read-only flow, or
2783
- on a step whose type is not available — is greyed with its reason in its own
2784
- line, and a refused Ctrl/⌘+D says the reason beside the status. Backspace is never the
2785
- editor's. The cards are one Tab stop, the chosen one, and its "+" comes next in
2786
- the Tab order. The "+" shows on hover, on focus and on the chosen card, never
2787
- on hover alone.
2788
-
2789
- **What it does not do.** It does not fetch, save, validate, propagate a schema,
2790
- coerce a value or run anything. Steps are never dragged and lines are never
2791
- drawn by hand. The editor does not draw a page trail, a breadcrumb or a "you
2792
- are editing the shared flow" banner either: a host draws those around it.
2793
-
2794
- **Where each claim was checked.** The layout, the JSON, the edits and the
2795
- preview runner are pure, and `tests/flow_lanes_layout.test.mjs`,
2796
- `flow_lanes_pipeline.test.mjs` and `flow_lanes_preview.test.mjs` test them under
2797
- plain node. The layout suite reproduces the mock and shows that the earlier
2798
- layout (EcoSim's `buildLanes`, copied verbatim) puts the join in the feeder's
2799
- lane when the feeder is listed first. The preview suite has a provider that
2800
- answers out of order, and the stale answer is shown to be dropped.
2801
- `flow_lanes_editor.test.mjs` mounts the editor in jsdom: choosing a card
2802
- rebuilds no node, every edit is one undo entry, and a flow that cannot be drawn
2803
- is never written. `flow_lanes_css.test.mjs` holds the classes and the rules to
2804
- each other and the sheet's card sizes to `LANE_GEOMETRY`. The rest was checked
2805
- in headless Edge with real input (`node demo/flow_lanes_probe.mjs`, which opens
2806
- `demo/flow_lanes.html` at all three sizes). In a laid-out page every wire
2807
- starts at its card's tip and ends in the next card's notch, and the join's line
2808
- and its port dot meet the join's bottom centre. `elementFromPoint` at the centre
2809
- of Undo, a card, a "+", *Add a source*, a step tab, *Steps show*, a parameter,
2810
- the panel's first field, its name, *Remove step*, a dock tab and a preview cell
2811
- returns each one, and the settings are covered by nothing. The preview's
2812
- `DataTable` draws its rows with a height, and every card's words stay inside
2813
- the card. A real press on "+" opens the picker inside the viewport with its
2814
- search focused, and real keys filter it and add the step. A real drag across a
2815
- card starts no native drag (a `dragstart` counter at 0) and moves nothing. Real
2816
- arrows, Delete, Ctrl+Z and Backspace do what is said above, and three real
2817
- keystrokes ask the preview once.
2818
-
2819
- ## Forms: `openForm`
2820
-
2821
- `openForm({ title, fields, defaults, submitLabel })` (in `@flexdesk/widgets`)
2822
- opens a modal form and resolves with `{ [name]: value }`, or `null` when it is
2823
- cancelled. Fields are `text`, `number`, `password`, `select`, `textarea`,
2824
- `checkbox`, `toggle`, `picker`, `checklist`, `radio`, `color` and `colorList`;
2825
- `{ section: 'Label' }` entries are headings and `{ row: [...] }` entries put
2826
- fields side by side. The additions below are opt-in, with one change every form
2827
- sees, even one that uses none of them: a `select` or `radio` comes back with
2828
- its option's OWN value and type (a numeric option submits `2`, not `'2'`; an
2829
- option whose value is `null` submits `null`), a default of that type selects
2830
- its option (`defaults: { s: 2 }` selects the option whose value is `2`), and an
2831
- option object with no `label` shows its value. A select with no option
2832
- selected still reads and submits `''`.
2833
-
2834
- ### A form that adapts: `onFieldChange`
2835
-
2836
- ```js
2837
- openForm({
2838
- fields: [
2839
- { name: 'type', label: 'Type', type: 'select', options: ['bug', 'story'] },
2840
- { section: 'Story', name: 'story' }, // a NAMED section can be hidden
2841
- { name: 'points', label: 'Points', type: 'number', required: true },
2842
- ],
2843
- onFieldChange: (name, value, api) => {
2844
- const story = api.get('type') === 'story';
2845
- api.setVisible('story', story);
2846
- api.setVisible('points', story);
2847
- },
2848
- });
2849
- ```
2850
-
2851
- `onFieldChange(name, value, api)` is called once when the form opens (with
2852
- `name === null`, so the form starts consistent with its defaults) and after
2853
- every field's `change`. `api` addresses fields and named sections by name:
2854
-
2855
- - `setVisible(name, on)` hides or shows the row. A hidden row keeps its value
2856
- and is still submitted (a disabled option it holds submits as no value:
2857
- `''` for a select or combobox, `null` for a radio), but it is not asked
2858
- about: its `required` and its
2859
- `validator` stand down until it is shown again.
2860
- - `get(name)` reads the control as it stands: a checkbox as a boolean, a
2861
- select or radio as its option's own value (a select with no option selected
2862
- as `''`, a radio with none as `null`), a combobox as the value of the option
2863
- its text names — exactly what it submits — or as its text when it names
2864
- none, a checklist as the array of chosen values, anything else as its
2865
- string.
2866
- - `set(name, value)` writes it. It does not call `onFieldChange` back, so a
2867
- hook that fills one field from another cannot loop. A value a select or
2868
- radio does not offer, or offers only disabled, clears it (a select then
2869
- submits `''`, a radio `null`).
2870
- - `setOptions(name, options)` replaces the options of a `select` (a
2871
- combobox's suggestions too), `checklist` or `radio` while the form is open
2872
- — see below.
2873
-
2874
- A hook that throws is logged and the form carries on.
2875
-
2876
- ### Hidden rows stay hidden
2877
-
2878
- `.twm-modal__row` is a grid, and a grid rule beats the `display: none` that
2879
- the `hidden` attribute gets from the browser, so a hidden row stayed on
2880
- screen. `flexdesk.css` now has `.twm-modal__row[hidden],
2881
- .twm-modal__section[hidden] { display: none !important }`, and `setVisible`
2882
- also sets the row's inline `display`, so it holds under a stylesheet of your
2883
- own as well.
2884
-
2885
- ### A value you look up: `type: 'picker'`
2886
-
2887
- ```js
2888
- { name: 'parent', label: 'Parent', type: 'picker', required: true,
2889
- pick: async (current) => ({ value: 'EPIC-0001', label: 'EPIC-0001 — Auth rewrite' }),
2890
- displayFor: (value) => titleOf(value) }
2891
- ```
2892
-
2893
- The row shows a read-only box and a search button. The button, or a click on
2894
- the box, awaits `pick(currentValue)`. Return `{ value, label }` (the label is
2895
- shown, the value is submitted), a bare value (shown through `displayFor`, or
2896
- as itself), or `null` to leave the field as it was. `displayFor(value)` gives
2897
- the text for the value the form opens with and for `api.set`. The value is
2898
- kept in a hidden input, which the browser's own `required` check skips, so
2899
- `required: true` on a picker is checked by the form instead. The markup is
2900
- `.twm-picker` > `.twm-picker__display` + `.twm-btn.twm-picker__btn`.
2901
-
2902
- ### A select you may type into: `create`
2903
-
2904
- A `select` with a `create` spec is a type-ahead combobox: pick an option or
2905
- type a new value. `create: { onCreate, hint }` makes the typed value into an
2906
- entity before the form resolves, and shows "↳ will create *hint* …" while you
2907
- type. `create: true` is the plain case: the typed value is submitted as typed,
2908
- with no hint, because nothing is created. Until now `create: true` failed the
2909
- submit with "onCreate is not a function".
2910
-
2911
- `tests/open_form.test.mjs` mounts the real `openForm` in jsdom and covers all
2912
- four, plus a plain form submitting as before.
2913
-
2914
- ### Options: typed, unavailable, grouped, and changed while open (0.17.0)
2915
-
2916
- ```js
2917
- openForm({
2918
- fields: [
2919
- { name: 'country', label: 'Country', type: 'select', options: ['no', 'it'] },
2920
- { name: 'town', label: 'Town', type: 'select', options: [] },
2921
- { name: 'plan', label: 'Plan', type: 'select', options: [
2922
- { value: 1, label: 'Free' },
2923
- { value: 2, label: 'Pro', disabled: true, reason: 'needs a licence' },
2924
- { group: 'Teams', options: [{ value: 10, label: 'Small team' }, { value: 50, label: 'Large team' }] },
2925
- ] },
2926
- { name: 'days', label: 'Days', type: 'checklist', required: true,
2927
- options: [{ value: 'mon', label: 'Mon' }, { value: 'tue', label: 'Tue' }] },
2928
- { name: 'mode', label: 'Mode', type: 'radio', options: [
2929
- { value: 'fast', label: 'Fast', description: 'Skips the checks.' },
2930
- { value: 'safe', label: 'Safe' },
2931
- ] },
2932
- ],
2933
- onFieldChange: (name, value, api) => {
2934
- if (name === null || name === 'country') api.setOptions('town', townsOf(api.get('country')));
2935
- },
2936
- });
2937
- // → { country: 'it', town: 'Rome', plan: 10, days: ['mon'], mode: 'fast' }
2938
- ```
2939
-
2940
- An option is a bare value or `{ value, label?, disabled?, reason?,
2941
- description? }`.
2942
-
2943
- - **Values keep their type.** An option whose value is the number 10 submits
2944
- 10, and `api.get` reads 10 — not `'10'`. A default matches an option of the
2945
- same text, so `defaults: { plan: '10' }` selects 10. A combobox entry typed
2946
- as an option's text submits (and `api.get` reads) that option's value. An
2947
- option whose value IS the given one wins over one that only has its text:
2948
- with options `[1, '1']`, a default `'1'` submits `'1'`.
2949
- - **A disabled option** is shown with its `reason` (in a select's option text
2950
- and tooltip, under a radio, on a chip) and is never the submitted value: a
2951
- default naming it does not select it, `api.set` to it clears a select,
2952
- radio or combobox and leaves a checklist's chip unpressed, the reader
2953
- cannot pick it, a combobox does not suggest it, and a submit
2954
- that finds one selected anyway (a script set `.value`, or every option is
2955
- disabled) is refused with "Not available: *reason*" on the field. In a
2956
- hidden row, which is not asked about, it submits as no value instead.
2957
- - **`{ group, options }`** in a select's list is an `<optgroup>` headed
2958
- *group*. In a checklist or radio it is a `.twm-choice__group` heading over
2959
- that group's chips or rows (a group with no options draws none). A combobox
2960
- suggests a group's enabled options, without headings.
2961
- - **`api.setOptions(name, options)`** replaces the list while the form is
2962
- open and keeps the current value when the new list offers it enabled.
2963
- Otherwise a select falls to its first enabled option, a radio to none and a
2964
- checklist drops what is gone. It does not call `onFieldChange` back.
2965
- - **`type: 'checklist'`** draws toggle chips and submits the array of chosen
2966
- values in the list's order (`[]` for none). `required` means at least one,
2967
- and nothing chosen is "Required." on the field.
2968
- - **`type: 'radio'`** draws one radio row per option, with its `description`
2969
- under it, and submits the chosen value, or `null`. `required` means one is
2970
- chosen.
2971
-
2972
- The select, the chips and the radio list are the flow kit's
2973
- (`src/ui/components/choice_controls.js`): the settings panel's `enum` select,
2974
- `enum-chips`, `upstream-columns` and `choice-cards` draw with the same code, so
2975
- a form and a settings panel cannot drift apart. In a form they are classed
2976
- `.twm-flow-togglechips` / `.twm-flow-togglechip` and `.twm-flow-choices` /
2977
- `.twm-flow-choice`; an unavailable option's reason is `.twm-choice__reason`.
2978
-
2979
- `tests/open_form_choices.test.mjs` covers each of these in jsdom.
2980
-
2981
- ### Colours: `type: 'color'` and `type: 'colorList'` (0.17.0)
2982
-
2983
- ```js
2984
- openForm({
2985
- fields: [
2986
- { name: 'accent', label: 'Accent', type: 'color', default: 'rgb(31,119,180)',
2987
- presets: ['#1f77b4', '#ff7f0e', '#2ca02c'] },
2988
- { name: 'palette', label: 'Palette', type: 'colorList', default: ['#1f77b4', '#ff7f0e'] },
2989
- ],
2990
- });
2991
- // -> { accent: '#1f77b4', palette: ['#1f77b4', '#ff7f0e'] }
2992
- ```
2993
-
2994
- A `color` row is a swatch that opens the native picker, a hex box kept in step
2995
- with it (picking writes the box; typing a valid hex moves the swatch), and,
2996
- with `presets`, a strip of preset swatches. It submits lowercase `#rrggbb`, or
2997
- `''` when left empty. A `colorList` row is an ordered list of such fields: add,
2998
- remove, and reorder by drag or by the up / down buttons; `presets` are offered
2999
- on every row, and it submits the colours in their current order as an array of
3000
- `#rrggbb`. An `rgb()` / `rgba()` default (or `api.set` value) is shown as its
3001
- hex; alpha is dropped, because the native picker has none. A box that does not
3002
- hold `#rgb` or `#rrggbb` is a field error ("Not a hex colour", with the row
3003
- numbers for a list) and the form does not resolve; `required` on either is
3004
- checked by the form. `api.get` reads a `color` as its box text and a
3005
- `colorList` as the array of its rows' text.
3006
-
3007
- Both are also components of their own in `@flexdesk/widgets`:
3008
- `new ColorField({ value, presets, name, label, onChange })` and
3009
- `new ColorListField({ value, presets, newColor, addLabel, name, onChange })`,
3010
- each with `render()`, `getValue()` (null while something typed is not a
3011
- colour), `setValue()`, `focus()` and `dispose()`; `onChange(value, { commit })`
3012
- reports every edit and, with `commit: true`, every committed one.
3013
- `normalizeHex(value)` (`#rgb`, `#rrggbb`, `rgb()` -> `#rrggbb` or null) and
3014
- `isHexColor(value)` are exported with them, and `validateField` (in
3015
- `@flexdesk/tiles`) now rejects a `FieldTypes.COLOR` value that is not a hex
3016
- colour. The markup is `.twm-color-field` > `.twm-color-field__swatch` +
3017
- `.twm-color-field__hex` (+ `.twm-color-field__presets`), and `.twm-color-list`
3018
- rows are `.twm-color-list__row`. `tests/color_field.test.mjs` covers all of it.
3019
-
3020
- ### Number bounds and fields side by side: `min`, `max` and `{ row }` (0.17.0)
3021
-
3022
- ```js
3023
- openForm({
3024
- fields: [
3025
- { name: 'steps', label: 'Steps', type: 'number', min: 2, max: 200, step: 1 },
3026
- { row: [
3027
- { name: 'lo', label: 'Min', type: 'number', hint: 'lowest value' },
3028
- { name: 'hi', label: 'Max', type: 'number', min: 0 },
3029
- ] },
3030
- ],
3031
- });
3032
- // → { steps: 50, lo: 0, hi: 10 }
3033
- ```
3034
-
3035
- - **`min` / `max`** on a `number` field are the input's own `min` and `max`
3036
- attributes, and a value outside them is refused on submit with an error
3037
- under that field: "Must be between 2 and 200." (or "Must be at least 2." /
3038
- "Must be at most 200." with only one). The browser's own range check stops
3039
- such a submit first; the form cancels its bubble and shows the field error
3040
- instead, and it checks the range itself as well, before the field's
3041
- `validator`. An empty field that is not `required` is not out of range. A
3042
- row hidden by `setVisible` drops its bounds until it is shown again, as it
3043
- drops `required`. As with any `<input type="number">`, a `min` is also where
3044
- `step` counts from.
3045
- - **`{ row: [field, ...] }`** puts its fields on one line, sharing the width.
3046
- The row is layout only and has no name: each field in it is an ordinary
3047
- field with its own label, hint, error, `setVisible`, value and submitted
3048
- key, so hiding one hides only that one. The first keeps the form's label
3049
- column; the others size their label to its text. A form narrower than
3050
- 360px stacks them (a container query, `twm-form`, on a form that has a row;
3051
- a form without one is not made a container). A form with a row opens at the
3052
- wide (640px) layout. The row is `.twm-modal__field-group`.
3053
-
3054
- A form with neither renders exactly as before.
3055
- `tests/open_form_rows_bounds.test.mjs` covers both in jsdom.
3056
-
3057
- ### A switch: `ToggleField` and `type: 'toggle'` (0.17.0)
3058
-
3059
- ```js
3060
- import { ToggleField } from '@flexdesk/widgets';
3061
-
3062
- const autosave = new ToggleField({
3063
- label: 'Autosave', onText: 'On', offText: 'Off', // all optional
3064
- checked: true, compact: false, disabled: false,
3065
- onChange: (on) => settings.set('autosave', on),
3066
- });
3067
- host.appendChild(autosave.render());
3068
- autosave.get(); // true
3069
- autosave.set(false); // does not call onChange
3070
- autosave.setDisabled(true);
3071
- autosave.dispose();
3072
- ```
3073
-
3074
- A track and a knob drawn over a real checkbox with `role="switch"` and an
3075
- `aria-checked` that follows it. Space and Enter toggle the focused switch, a
3076
- click on the track, the state text or the label toggles it, the focused input
3077
- draws a ring on the track, and a disabled switch ignores clicks and keys alike.
3078
- `ariaLabel` names a switch with no visible label. The markup is
3079
- `label.twm-toggle` (`--compact`, `--disabled`) > `.twm-toggle__control` >
3080
- `input.twm-toggle__input` + `.twm-toggle__track` > `.twm-toggle__knob`, then
3081
- `.twm-toggle__state` and `.twm-toggle__label`; `flexdesk.css` styles it from
3082
- tokens alone, with no colour literal.
3083
-
3084
- In a form, `{ name, label, type: 'toggle', onText?, offText?, disabled?,
3085
- compact? }` mounts one and submits a boolean, like a checkbox; `api.get` reads
3086
- it as a boolean and `api.set` moves it. `tests/toggle_field.test.mjs` covers
3087
- both.
3088
-
3089
- ## A settings page: `SettingsView` (0.17.0)
3090
-
3091
- `new SettingsView({ store, eventBus })` (in `@flexdesk/widgets`) draws a
3092
- settings store's schema as a page. `store` is a `createSettingsStore()` handle
3093
- or the app-global binding's own, `getSettingsStore()` from `@flexdesk/core`
3094
- (its `set` is the one `setSetting` calls); `eventBus` is the bus that store
3095
- emits on (`registerSettingsEventBus(bus)` for the binding).
3096
-
3097
- ```js
3098
- import { getSettingsStore, registerSettingsEventBus } from '@flexdesk/core';
3099
- import { SettingsView, settingsViewContent } from '@flexdesk/widgets';
3100
-
3101
- registerSettingsEventBus(bus);
3102
- const view = new SettingsView({ store: getSettingsStore(), eventBus: bus });
3103
- view.mount(host);
3104
- view.selectCategory('window');
3105
- view.setQuery('shadow'); // what the filter box does
3106
- await view.resetAll(); // asks first; true when it reset
3107
- view.dispose();
3108
-
3109
- // or as tile content (props.category picks the category shown first):
3110
- createShell({ content: { settings: settingsViewContent({ store: getSettingsStore(), eventBus: bus }) } });
3111
- ```
3112
-
3113
- The sidebar holds a filter box, every category in `CategoryDef.order` and
3114
- *Reset all*. Beside it the chosen category's settings are listed under its
3115
- title, description and *Reset category*, grouped by `SettingDef.group` in
3116
- schema order. A row shows the label, a *Modified* marker while the value
3117
- differs from its default, the description, the path, its control and a reset
3118
- button. Text in the filter searches every category's labels, descriptions and
3119
- paths; the matches are listed under "Category › Group", and choosing a
3120
- category clears it.
19
+ ## Packages
3121
20
 
3122
- | `type` | Control |
3123
- | --- | --- |
3124
- | `boolean` | a `ToggleField` |
3125
- | `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)* |
3126
- | `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 |
3127
- | `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` |
3128
- | `colorList` | a `ColorListField`, written on each committed edit while every row holds a colour |
21
+ Import only what you use. The entries share their code, so loading several does not duplicate the event bus.
3129
22
 
3130
- A type it does not know shows the value as text, read only.
3131
-
3132
- The view is live both ways: a control writes with `store.set`, and the view
3133
- listens for `settings:<path>:changed` on every schema path, `settings:changed`
3134
- with a null path (the resets) and `settings:registered`, which the store now
3135
- emits from `extend()` / `registerSettings()` with `{ paths, categories }`. So a
3136
- `setSetting` made anywhere moves the open view, and a slice registered after
3137
- mount adds its categories and rows without a remount. A row's reset button
3138
- resets that one setting; *Reset category* and *Reset all* first ask
3139
- `confirm({ title, message, confirmLabel, danger })` (default `openConfirm`) and
3140
- change nothing unless it resolves true. Every label, description, category name
3141
- and query is set as text, so markup in any of them is shown as typed.
3142
-
3143
- Options: `store`, `eventBus` (both required), `confirm`, `category`,
3144
- `textDebounceMs`, `title` (the sidebar heading, and the content factory's tab
3145
- title; default "Settings"). The markup is `.twm-settings` >
3146
- `.twm-settings__sidebar` + `.twm-settings__content`, with rows
3147
- `.twm-settings__row` (`--modified`); `flexdesk.css` styles it from tokens.
3148
- `tests/settings_view.test.mjs` covers it in jsdom.
3149
-
3150
- ## The settings categories outside the page: `SettingsCategories` (0.17.2)
3151
-
3152
- A `SettingsView` made with `sidebar: false` draws no sidebar: no heading, no
3153
- filter box, no category list and no *Reset all*, only the chosen category's
3154
- settings (or the filter's matches). `new SettingsCategories({ view })` (in
3155
- `@flexdesk/widgets`) draws those parts for that view wherever it is mounted,
3156
- for example in an app's left navigator:
3157
-
3158
- ```js
3159
- import { SettingsView, SettingsCategories } from '@flexdesk/widgets';
3160
-
3161
- const view = new SettingsView({ store, eventBus: bus, sidebar: false });
3162
- view.mount(pageHost);
3163
- const cats = new SettingsCategories({ view });
3164
- cats.mount(navigatorHost);
3165
-
3166
- const off = view.subscribe(({ category, query }) => { /* e.g. a tab title */ });
3167
- // …
3168
- off();
3169
- cats.dispose();
3170
- view.dispose();
3171
- ```
3172
-
3173
- It keeps no state of its own. Typing in its filter box is `view.setQuery()`,
3174
- clicking a category is `view.selectCategory()` and its *Reset all* is
3175
- `view.resetAll()`, which asks the view's `confirm` first and changes nothing
3176
- unless it resolves true. It redraws from the view whenever the view's category,
3177
- filter or registered categories change, so the two agree whichever side moved
3178
- them: `view.selectCategory('window')` marks that category in the list, and
3179
- `view.setQuery('shadow')` puts the text in its filter box. As in the view's own
3180
- sidebar, choosing a category clears the filter, and no category is marked while
3181
- the filter is in effect. Its clicks and keystrokes act once the view is mounted.
3182
-
3183
- Two methods on `SettingsView` serve it and anything else that follows the view:
3184
- `subscribe(fn)` calls `fn({ category, query })` after each such change and
3185
- answers the unsubscribe; `isFiltering()` is true while the filter's trimmed
3186
- text is not empty. `settingsViewContent({ …, sidebar: false })` passes the
3187
- option through. A view made without `sidebar` (or with `sidebar: true`) draws
3188
- exactly as in 0.17.1.
3189
-
3190
- The markup is `.twm-settings-categories` > `.twm-settings__search`,
3191
- `.twm-settings__nav` (`button.twm-settings__cat`, `--active`) and
3192
- `.twm-settings__reset-all`, the same parts and classes as the sidebar's;
3193
- `flexdesk.css` styles it, leaving its width and padding to the host.
3194
- `tests/settings_view.test.mjs` §11–§14 cover it.
3195
-
3196
- ## A step wizard: `Wizard` (0.17.0)
3197
-
3198
- `new Wizard({ steps, data, onFinish, onCancel, labels })` (in `@flexdesk/widgets`)
3199
- draws a multi-step flow: a step indicator, a body slot and a footer with Cancel,
3200
- Back and Next (Finish on the last step). Mount it inline with `mount(el)`, or in a
3201
- modal `ManagedWindow` with `openInWindow({ id, title, ... })`.
3202
-
3203
- ```js
3204
- const wizard = new Wizard({
3205
- data: { file: null },
3206
- steps: [
3207
- { id: 'source', label: 'Source', icon: 'upload_file',
3208
- render(bodyEl, ctx) { // once per visit; may return a cleanup function
3209
- const input = picker(bodyEl);
3210
- input.onchange = () => { ctx.data.file = input.files[0]; ctx.revalidate(); };
3211
- },
3212
- canAdvance: (ctx) => ctx.data.file
3213
- ? { ok: true } : { ok: false, hint: 'Choose a file first' } },
3214
- { id: 'review', label: 'Review', render: (bodyEl, ctx) => review(bodyEl, ctx.data) },
3215
- ],
3216
- onFinish: async (ctx) => api.import(ctx.data), // a rejection marks the step as error
3217
- onCancel: () => {},
3218
- });
3219
- wizard.openInWindow({ id: 'import', title: 'Import' });
3220
- ```
3221
-
3222
- - **Gating.** While the current step's `canAdvance(ctx)` answers `{ok: false, hint}`,
3223
- Next is disabled and the hint is shown in the footer. `revalidate()` (also
3224
- `ctx.revalidate()`) asks again and redraws the indicator and the footer; it never
3225
- re-renders the body, so an input keeps its focus and its value.
3226
- - **Steps** are drawn done / current / upcoming / error, joined by a connector. A
3227
- done step is a button that jumps back to it; an upcoming step is inert, so the
3228
- only way forward is Next.
3229
- - **Moves.** `next()`, `back()` and `goTo(idOrIndex)` (back to any earlier step, or
3230
- forward one step through the gate) resolve `true` when they moved.
3231
- `setStepState(id, 'error' | null, message)` marks or clears a step.
3232
- - **Finish.** On the last step Next reads Finish and calls `onFinish(ctx)`. Until
3233
- it settles every button is disabled and moves, cancels and closes are refused,
3234
- not queued. Resolved, the wizard closes; rejected, the step is marked as error
3235
- and the rejection's message is shown, and Finish can be pressed again.
3236
- - **Cancel** (the button, `cancel()`, or the window's close button and Escape)
3237
- closes the wizard and calls `onCancel(ctx)` exactly once. `dispose()` tears it
3238
- down without calling either callback.
3239
-
3240
- Every transition goes through a core `StateMachine` (`step`, `finishing`,
3241
- `finished`, `cancelled`; pass `eventBus` to see its `fsm:*` events). `ctx` is
3242
- `{data, step, index, revalidate, next, back, goTo, setStepState}`, with `data`
3243
- the object you passed.
3244
-
3245
- ## Confirms show text, not markup (0.17.0)
3246
-
3247
- `openConfirm({ title, message, confirmLabel, cancelLabel, danger, icon })`
3248
- resolves `true` or `false`. Its `message` and both labels are **text**: they
3249
- are set with `textContent`, so a `<` shows as a `<`. Until 0.17.0 they went into
3250
- `innerHTML`, and `showDeleteConfirmDialog` / `showCloseConfirmDialog` put
3251
- `itemName` and `itemType` into that message raw: a flow named
3252
- `<img src=x onerror=…>` rendered an image and ran its handler, and a name with
3253
- an unclosed `<` swallowed the rest of the message.
3254
-
3255
- A caller that passed markup in `message` now sees the tags; write a `\n` for a
3256
- line break instead (the message body is `white-space: pre-line`).
3257
- `additionalMessage` on `showDeleteConfirmDialog` follows on its own paragraph
3258
- exactly as before. A body that needs real markup is `openModal`'s job: it takes
3259
- the caller's own DOM.
3260
-
3261
- `tests/confirm_escaping.test.mjs` opens each of them in jsdom with hostile
3262
- names and labels.
3263
-
3264
- ## Markdown: `renderMarkdown` (0.17.0)
3265
-
3266
- `renderMarkdown(source, options)` (`@flexdesk/widgets`) returns an HTML
3267
- string. It covers a CommonMark-ish subset — ATX headings, paragraphs,
3268
- emphasis, strong, `~~strike~~`, code spans, fenced code, block quotes, nested
3269
- ordered and unordered lists, GFM pipe tables, rules, links, images, autolinks,
3270
- hard breaks and `$…$` / `$$…$$` math — and is **safe by default**:
3271
-
3272
- - raw HTML is escaped: `<img src=x onerror=alert(1)>` shows as that text.
3273
- `allowHtml: ['kbd', 'br']` lets named tags through, the tag only and never
3274
- its attributes; a tag that runs or embeds anything (`script`, `iframe`,
3275
- `svg`, `img`, …) throws. An allowed tag left open is closed at the end of its
3276
- paragraph.
3277
- - a link goes out only to `http(s):`, `mailto:` or a `#anchor`, always with
3278
- `rel="noopener noreferrer"`; a `javascript:` link (or any other) renders its
3279
- text and no anchor. An image loads from `http(s):` or a relative path, or
3280
- shows its alt text.
3281
-
3282
- ```js
3283
- import { renderMarkdown } from '@flexdesk/widgets';
3284
-
3285
- el.innerHTML = renderMarkdown(source, {
3286
- highlight: (code, lang) => myHighlighter(code, lang), // null: escaped
3287
- math: undefined, // default: window.katex when present, else the escaped source
3288
- // false: `$` is text; or (tex, { display }) => html | null
3289
- inlineRules: [{ pattern: /\[\[ref:([\w-]+)\]\]/, render: (m, ctx) => `<a href="#${ctx.escape(m[1])}">…</a>` }],
3290
- blockRules: [{ start: /^:::(\w+)/, end: /^:::$/, render: ({ lines, match, closed }, ctx) => '…' }],
3291
- inline: false, // true: inline markup only, no blocks, no <p>
3292
- });
3293
- ```
3294
-
3295
- What the caller returns — the highlighter's, the math hook's and the rules'
3296
- HTML — is trusted and goes in as it is; a rule answering `null` declines.
3297
- `ctx` gives a rule `escape(text)` and `inline(text)`. Not in the subset:
3298
- setext headings, indented code blocks, reference links, raw HTML blocks.
3299
-
3300
- **Streaming.** Every element it opens it closes, so each prefix of a document
3301
- arriving token by token renders to balanced HTML: an open fence is closed at
3302
- the end of the input, and an emphasis or link whose closer has not arrived is
3303
- still text. `tests/markdown.test.mjs` renders every prefix of a document using
3304
- every construct and checks the tags balance; `tests/help_modal_markdown.test.mjs`
3305
- checks jsdom has nothing to repair.
3306
-
3307
- **Help topics in markdown.** A `HelpProvider`'s `loadTopic` may now answer
3308
- `{ title, markdown }`; the modal renders it with `renderMarkdown` and the
3309
- provider's optional `markdownOptions`. `{ title, html }` is unchanged:
3310
- pre-rendered, **trusted** HTML, inserted as it is.
3311
-
3312
- **A table of contents for a help topic.** A topic with two or more headings,
3313
- markdown or html, gets its contents between the breadcrumb and the article: a
3314
- `createDocumentOutline` (below) of its `h1`–`h6`, numbered, the one in view
3315
- marked as the content pane scrolls, a click that scrolls to its heading. The
3316
- outline points at the heading elements themselves, so your markup needs no
3317
- ids and the modal adds none; a topic with one heading or none shows no
3318
- contents (the empty `.help-content__toc` is `hidden`). Until now the modal's
3319
- documentation promised this and drew nothing. `tests/help_modal_toc.test.mjs`
3320
- drives it in jsdom (BUG-0010).
3321
-
3322
- ## A document outline: `createDocumentOutline` (0.17.0)
3323
-
3324
- A long document's sections, listed and numbered, the one in view marked while
3325
- the document scrolls, and a click that goes to one (in `@flexdesk/widgets`):
3326
-
3327
- ```js
3328
- const outline = createDocumentOutline(sidebarEl, {
3329
- entries: [{ id: 'intro', level: 1, label: 'Introduction' },
3330
- { id: 'scope', level: 2, label: 'Scope' }],
3331
- numbering: true, // 1, 1.1, 1.2, 2 …
3332
- maxDepth: 3, // deeper entries are drawn at depth 3
3333
- scrollContainer: articleEl, // the element that scrolls; the spy's root
3334
- resolveTarget: (entry, container) => container.querySelector(`[data-section="${entry.id}"]`),
3335
- rootMargin: '0px 0px -70% 0px', // the default: the top 30% is "in view"
3336
- onSelect: (id, entry) => { /* … */ },
3337
- emptyState: 'No headings',
3338
- });
3339
- outline.setEntries(next); // the document has other sections now
3340
- outline.refresh(); // the same sections, re-rendered: resolve again
3341
- outline.setActive('scope'); outline.getActive();
3342
- const off = outline.onActiveChange((id, entry) => { /* … */ });
3343
- outline.dispose();
3344
- ```
3345
-
3346
- Where the entries come from is yours: the outline is told `{id, level, label}`
3347
- and, through `resolveTarget` (by default the element in the container whose
3348
- `id` is the entry's), which element each one stands for. Labels are text.
3349
- Numbers are hierarchical, and a skipped level is one step deeper, not two:
3350
- levels 1, 3, 3, 2 are 1, 1.1, 1.2, 1.3.
3351
-
3352
- Of the targets an IntersectionObserver rooted at `scrollContainer` reports in
3353
- view, the one listed first is active; with none in view the last one stays. An
3354
- entry whose target does not resolve is listed, but never watched and never
3355
- active, and `setActive` refuses it (`false`). A click marks its entry at once,
3356
- scrolls its target into view (`scrollOnSelect: false` leaves that to
3357
- `onSelect`) and suppresses the spy while the scroll passes the sections in
3358
- between, until the container's `scrollend` or, without one, `scrollEndTimeout`
3359
- (1000 ms). `dispose()` disconnects the observer and clears that timer. The
3360
- indent per level is `--twm-doc-outline-indent` (12px).
3361
-
3362
- `tests/document_outline.test.mjs` drives it in jsdom with a hand-fired
3363
- observer.
3364
-
3365
- ## Action dropdowns: separators (0.17.0)
3366
-
3367
- `new ActionDropdown({ trigger, options, onSelect })` draws one button per
3368
- option. An entry `{ separator: true }` in `options` draws a divider instead: a
3369
- `<div role="separator" class="twm-action-dropdown-separator">` that takes no
3370
- focus, that ArrowUp/ArrowDown pass over, and that never reaches `onSelect`.
3371
- A separator only separates: one at the start or end of the list, or right
3372
- after another, is dropped, so groups that come out empty leave no stray or
3373
- doubled rule. `setOptions` follows the same rules, and an option's
3374
- `data-index` stays its index in `options`.
3375
-
3376
- `tests/action_dropdown_separators.test.mjs` drives it in jsdom.
3377
-
3378
- ## A list of tags: `createTagInput` (0.17.0)
3379
-
3380
- `createTagInput(host, { value, placeholder, readOnly, allowDuplicates, onChange })`
3381
- (in `@flexdesk/widgets`) draws a list of short strings as removable chips
3382
- followed by a text box, and appends it to `host`.
3383
-
3384
- ```js
3385
- const tags = createTagInput(host, {
3386
- value: ['baseline'],
3387
- placeholder: 'Add a tag…',
3388
- onChange: (list) => save(list), // a fresh copy after each change the reader makes
3389
- });
3390
- tags.getValue(); // ['baseline']
3391
- tags.setValue(['a', 'b']); // redraws; does not call onChange
3392
- tags.dispose();
3393
- ```
3394
-
3395
- - **Enter**, a **comma** and **blur** commit what was typed: it is split at
3396
- commas and each piece trimmed, so `a, b` then Enter adds two chips and
3397
- `onChange` receives `['a', 'b']`.
3398
- - Empty pieces are ignored, and so is a piece already in the list unless
3399
- `allowDuplicates` is set. A commit that adds nothing calls no `onChange`.
3400
- - **Backspace** in an empty box removes the last chip.
3401
- - A **paste** that carries a comma is split into chips at once; a paste
3402
- without one is ordinary text in the box.
3403
- - Each chip's remove button has an `aria-label`, "Remove <tag>" by default;
3404
- `removeLabel(tag)` words it (for another language). `label` names the text
3405
- box and the group.
3406
- - `readOnly` draws the chips alone: no remove buttons and no text box.
3407
-
3408
- The handle is `{ element, input, getValue, setValue, focus, dispose }`
3409
- (`input` is `null` when read-only). The styles are the `twm-tag-input` block in
3410
- `flexdesk.css`. `tests/tag_input.test.mjs` covers each of the above in jsdom.
3411
-
3412
- ## Progress: bars, lists and phases (0.17.0)
3413
-
3414
- Three classes in `widgets`, each building its own element (`.element`, appended
3415
- to `host` when one is given) and changing it **in place**: an update twenty
3416
- times a second gets the same nodes back every time, so hover, tooltips and the
3417
- fill's transition survive. Names and labels are text.
3418
-
3419
- | Class | Does |
3420
- |---|---|
3421
- | `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` |
3422
- | `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 |
3423
- | `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()` |
3424
-
3425
- ```js
3426
- const list = new ProgressList(panel, { items: [] });
3427
- job.on('tick', (workers) => list.setItems(workers.map((w) => ({
3428
- key: w.id, name: w.label, completed: w.done, total: w.total, state: w.state }))));
3429
- ```
3430
-
3431
- `showRunProgressDialog` and `showComputingWindow` take `progress` (`true`, or
3432
- ProgressBar options) and `steps`, fixed when the dialog opens. Their handles
3433
- gain `setProgress(next)` and `setStep(which, opts)`, which throw when the dialog
3434
- was opened without `progress` or `steps` respectively, and **`result`**, a
3435
- Promise that settles once with
3436
- `{ status, message }`: `complete({ ok })` gives `done` or `error` (the bar and
3437
- the phases show it), `cancelled()` gives `cancelled`, and so does **closing the
3438
- dialog while it runs** (by its ×, Escape, `close()`, or for `showComputingWindow`
3439
- opening another). Closing never calls `onCancel`; only the Stop / Cancel button
3440
- does, as before. `showComputingWindow` also gains `complete` and `cancelled`,
3441
- after which it shows a Close button.
3442
-
3443
- ```js
3444
- const dlg = showRunProgressDialog({ title: 'Export', steps: ['Prepare', 'Render', 'Write'],
3445
- progress: { showElapsed: true }, onCancel: () => job.stop() });
3446
- dlg.setStep(1); dlg.setProgress(0.4);
3447
- const { status } = await dlg.result; // 'done' | 'error' | 'cancelled'
3448
- ```
3449
-
3450
- `tests/progress.test.mjs` holds all of it in jsdom.
3451
-
3452
- ## Tabs: `createTabs` (0.17.0)
3453
-
3454
- One tab strip, from `@flexdesk/widgets`, keyed by whatever ids you give it — not
3455
- by file path (`NotebookTabBar`) and not by a bus event (`createBottomPanelTabs`,
3456
- which is now built on it). It is a strip and nothing else, or a strip with the
3457
- panes behind it.
3458
-
3459
- ```js
3460
- const tabs = createTabs(el, {
3461
- items: [
3462
- { id: 'inputs', label: 'Inputs', icon: 'tune', title: 'Model inputs' },
3463
- { id: 'results', label: 'Results', badge: { count: 3, level: 'warning' } },
3464
- { id: 'draft', label: 'Draft', dirty: true, closable: true },
3465
- ],
3466
- active: 'inputs',
3467
- onSelect: (id, item) => …,
3468
- onReselect: (id, item) => …, // optional: a click on the tab in front
3469
- onClose: (id) => tabs.update(items.filter((i) => i.id !== id)), // optional
3470
- onReorder: (ids) => …, // optional: tabs drag
3471
- onContextMenu: (id, event, item) => …, // optional
3472
- mountPane: (item, paneEl, tabs) => ({ onShow() {}, destroy() {} }), // optional: strip only
3473
- compact: false,
3474
- label: 'Views',
3475
- });
3476
- tabs.update(nextItems, 'results');
3477
- tabs.setBadge('results', 7, 'alert');
3478
- tabs.setIndicator('inputs', 'info');
3479
- ```
3480
-
3481
- | Member | Does |
3482
- |---|---|
3483
- | `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 |
3484
- | `select(id)`, `active()`, `has(id)`, `ids()` | The tab in front; `select` answers false for an id it does not have |
3485
- | `setBadge(id, n, level?)` | A count, `99+` above 99, none at zero; `level` is `info`, `warning` or `alert` (`TAB_LEVELS`) |
3486
- | `setIndicator(id, value)` | A dot: a level, `true` for the neutral one, falsy for none |
3487
- | `instance(id)`, `instances()` | The mounted panes' instances |
3488
- | `destroy()` | Destroys the panes, lets the bus go, removes the markup |
3489
-
3490
- - **Panes mount on first selection.** The tab in front at creation counts;
3491
- `eager: true` on an item mounts its pane at once — at creation, or in the
3492
- `update` that brings the item in, before the tab in front is settled. Each
3493
- instance hears `onShow()` whenever its tab comes to the front and `destroy()`
3494
- when its item goes.
3495
- - **`onSelect` hears changes**, not the tab chosen at creation. A click on the
3496
- tab already in front is not a change; `onReselect(id, item)` hears it. An
3497
- `update` that leaves no items empties the front, heard as `onSelect(null, null)`.
3498
- - **The bus is opt-in:** pass `eventBus` *and* `selectEvent: 'views:show'` and
3499
- that event selects `payload.id` (or `selectFrom(payload)`). No event name, no
3500
- subscription.
3501
- - **Keys** follow the ARIA tabs pattern with automatic activation:
3502
- ArrowLeft/ArrowRight (wrapping), Home, End; Delete (and a middle click) closes
3503
- a closable tab. Only the tab in front is in the tab order. When an `update`
3504
- removes the tab that has focus (Delete, then your `onClose` drops it), focus
3505
- moves to the tab now in front instead of falling to the page. Focus that the
3506
- update put somewhere else (a pane that focuses itself in `onShow`) stays there.
3507
- - **Overflow scrolls**, never wraps: a vertical wheel moves the strip sideways and
3508
- the tab coming to the front is scrolled into view (`revealInStrip`,
3509
- `wheelStripSideways`, shared with `NotebookTabBar`).
3510
- - **Classes** are BEM under `block` (default `twm-tabs`): `__tabs`, `__tab`,
3511
- `__tab--on`, `__tab--dirty`, `__tab--closable` (it has a close mark), `__icon`,
3512
- `__label`, `__badge`, `__indicator`, `__dirty`, `__close`, `__body`,
3513
- `--compact`, and `twm-tabs-pane`. A dirty tab shows its dot until hovered,
3514
- then its close mark; one with no close mark keeps the dot. Tabs and panes
3515
- carry the id in `data-tab-id` (`dataKey`), as a string; callbacks and `ids()`
3516
- hand back the id as you gave it, so a numeric id stays a number.
3517
-
3518
- `tests/tabs.test.mjs` covers it in jsdom; `tests/bottom_panel_tabs.test.mjs`
3519
- runs on the bottom panel built over it. The bottom panel behaves as it did:
3520
- `api.root` is set inside a pane's `mount`, and a click on the tab in front
3521
- re-shows its pane, fires `onSelect` and is remembered, as `select` does. A
3522
- remembered tab that is already in front when `remember.read` answers is shown
3523
- again: its pane hears `onShow` and `onSelect` fires, and nothing is written back.
3524
-
3525
- ## Notifications: `NotificationCenter`
3526
-
3527
- Toasts, from `toast:show` on the bus (or `toastInfo` / `toastWarn` /
3528
- `toastError` / `toastSuccess`) or `show()`. A payload with `persistent: true`
3529
- stays until its × is clicked. `history` keeps every notification shown, and
3530
- `NotificationHistory` puts it behind a bell in the bottom bar.
3531
-
3532
- *New in 0.11.0:* **`overflow: 'collapse'`.** By default, a toast past
3533
- `maxVisible` (3) pushes the oldest off the screen. With `'collapse'`, the toasts
3534
- on screen stay, and the rest wait behind one **"+N more"** toast whose count
3535
- follows them. Each comes in as a visible one closes. A sticky alert is never
3536
- pushed off unseen. Clicking the summary calls `summaryAction(held)` and emits
3537
- `notification:summary` with `{held}`, so a history panel can open. Every toast
3538
- is in `history` as it arrives, waiting or not.
3539
-
3540
- ```js
3541
- const nc = new NotificationCenter({ eventBus, maxVisible: 3, overflow: 'collapse',
3542
- summaryAction: () => history.open() });
3543
- nc.mount(document.body);
3544
- ```
3545
-
3546
- *New in 0.17.0:* **the toast helpers read no global.** `toastInfo` / `toastWarn` /
3547
- `toastError` / `toastSuccess` emit on the bus of the `NotificationCenter` mounted
3548
- last; its `dispose()` hands them back. `setToastBus(bus)` points them at another
3549
- bus (and returns the one it replaced), `setToastBus(null)` back at the console,
3550
- and `getToastBus()` says which is current. With no bus — before any centre is
3551
- mounted — a helper logs to the console and throws nothing. Until 0.17.0 they
3552
- looked for one application's bus on a window global.
3553
-
3554
- ### Progress toasts (0.17.0)
3555
-
3556
- A toast can track a job. `show({ progress })` takes a 0..1 fraction,
3557
- `{ value, total }`, or `null` for indeterminate, and draws a `ProgressBar` in
3558
- the toast; `status` is a line under the message and `counts` a label beside the
3559
- bar. A progress toast is persistent, has a collapse toggle (or `collapsed: true`),
3560
- and is not closed by a click on its body or on one of its actions while it runs.
3561
-
3562
- `update(id, patch)` changes only that toast's nodes: `progress`, `status`,
3563
- `counts`, `collapsed`, `message`, `title`, `description`, `severity`. No other
3564
- toast is redrawn, by an update or by toasts arriving and leaving, so a bar, a
3565
- collapsed state and a hover survive (a toast whose title or description appears
3566
- or goes is rebuilt in its place). `update()` also reaches a toast held behind
3567
- "+N more", and `progress` on a plain toast turns it into a running progress
3568
- toast. The toast **runs until a patch gives it an outcome severity**: `success`
3569
- ends the bar done, `error` / `fatal` error; any other severity only restyles it.
3570
- Once finished the bar keeps that state, whatever severity comes later. A
3571
- `durationMs` in a patch to a toast that is not running (re)starts its
3572
- auto-dismiss, and `0` keeps it until closed.
3573
-
3574
- With `overflow: 'collapse'`, a running progress toast is never held behind
3575
- "+N more" and never counted in it (a held toast that starts running through
3576
- `update()` comes on screen at once). It counts toward `maxVisible`, so when the
3577
- screen is full the newest other toast steps back into the queue (first in line
3578
- to return). Once finished it folds like any other toast. With `'drop'`, the
3579
- oldest toast that is not a running job leaves.
3580
-
3581
- ```js
3582
- const id = nc.show({ title: 'Batch', message: 'Running', progress: 0, counts: '0 / 200',
3583
- actions: [{ label: 'Stop', handler: () => job.stop() }] });
3584
- job.on('tick', (done) => nc.update(id, { progress: { value: done, total: 200 },
3585
- counts: `${done} / 200`, status: job.current }));
3586
- job.on('end', () => nc.update(id, { progress: 1, severity: 'success', message: 'Done', durationMs: 3000 }));
3587
- ```
3588
-
3589
- `tests/notification_progress.test.mjs` holds it in jsdom.
3590
-
3591
- ### Toast cards are styled and clickable (0.17.2)
3592
-
3593
- A toast card is `.twm-notification` with `.twm-notification--<severity>`
3594
- (`info`, `success`, `warn`, `error`, `fatal`), and the "+N more" summary is
3595
- `.twm-notification.twm-notification--summary`. `flexdesk.css` gives the card its
3596
- background, a left border in the severity's colour (`--color-success`,
3597
- `--color-warning`, `--color-danger`; info keeps the blue) and the entry
3598
- animation `twm-notification-in`, which the sheet now defines. The card sets
3599
- `pointer-events: auto` over the root's `none`, so its buttons and its
3600
- click-to-close work.
3601
-
3602
- Until 0.17.2 the card carried the bare `notification` / `notification--<severity>`
3603
- classes, which no FlexDesk sheet styled: the card had no background and no
3604
- border, and inherited `pointer-events: none`, so its buttons could not be
3605
- clicked. A consumer stylesheet that targeted `.notification` should target
3606
- `.twm-notification` instead. The live region is `.twm-notification-center__live-region`.
3607
- `tests/notification_toast_styles.test.mjs` holds the classes, the cascade and
3608
- the sheets.
3609
-
3610
- ### Alerts with history: a log, a pane, a tab host and a bell (0.14.0)
3611
-
3612
- A toast is the moment. The **alert log** keeps what came in until it is
3613
- dismissed, and four pieces draw it. Each works without the others:
3614
-
3615
- | Piece | Entry point | Does |
3616
- |---|---|---|
3617
- | `createAlertLog({ source, limit })` | `widgets` | Entries `{id, at, level, title, body?, ref?, read}`, newest first; `add`, `receive`, `markRead`, `dismiss`, `unreadCount`, `worstUnread`, `subscribe`, `loadMore` |
3618
- | `localSource({ read, write })` | `widgets` | Keeps the whole log in one stored value (localStorage, a workspace file) |
3619
- | `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* |
3620
- | `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}` |
3621
- | `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 |
3622
-
3623
- A **source** is where entries live: `load({offset, limit})` answers
3624
- `{entries, more?, unread?}`, plus `markRead(ids | null)` and, optionally,
3625
- `dismiss(ids | null)`. Without `dismiss`, the pane offers no Dismiss or Clear.
3626
- Levels are `info`, `warning` and `alert`; `warn`, `error` and `danger` are read as
3627
- those.
3628
-
3629
- ```js
3630
- const log = createAlertLog({ source: {
3631
- load: ({ offset, limit }) => api.notifications({ offset, limit }),
3632
- markRead: (ids) => (ids ? Promise.all(ids.map(api.readNotification)) : api.readAll()),
3633
- } });
3634
- const tabs = createBottomPanelTabs(panelEl, { eventBus, panes: [
3635
- { id: 'alerts', label: 'Alerts', eager: true,
3636
- mount: (el, store, api) => mountAlertsPane(el, { log, onOpen: openRef,
3637
- onBadge: ({ unread, level }) => api.setBadge('alerts', unread, level) }) },
3638
- ] });
3639
- new NotificationHistory({ notificationCenter: nc, eventBus, alertLog: log, mountPoint: statusBarEl }).mount();
3640
- ```
3641
-
3642
- An eager pane, and the first one shown, mount inside `createBottomPanelTabs`
3643
- before it returns. Reach the host through `mount`'s `api` argument, not the
3644
- variable being assigned. Without `alertLog` and `mountPoint`, `NotificationHistory`
3645
- is the bell it was.
3646
-
3647
- ## A conversation: `mountConversationView` (0.17.0)
3648
-
3649
- A transcript of role-tagged messages above a composer, in `@flexdesk/widgets`.
3650
- It has no markdown and no model in it: you draw a message's body, and it tells
3651
- you when the user submits or cancels.
3652
-
3653
- ```js
3654
- const view = mountConversationView(el, {
3655
- render: (message, bodyEl, { streaming }) => { bodyEl.innerHTML = toHtml(message.text); },
3656
- onSubmit: (text) => { view.append({ role: 'user', text }); view.setBusy(true); ask(text); },
3657
- onCancel: () => abort(),
3658
- placeholder: 'Ask anything',
3659
- });
3660
- view.beginStream({ role: 'assistant' });
3661
- for await (const chunk of reply) view.appendChunk(chunk); // re-renders this message only
3662
- view.endStream();
3663
- view.setBusy(false);
3664
- ```
3665
-
3666
- | Method | Does |
23
+ | Entry | What it is |
3667
24
  |---|---|
3668
- | `append({ id?, role, text, meta? })` → id | Adds a message; `role` becomes `twm-chat__msg--<role>` |
3669
- | `replaceAll(messages)`, `clear()` | Restores a transcript (and scrolls to its end), or empties it |
3670
- | `beginStream(message?)` → id, `appendChunk(text)`, `endStream()` | A message that grows; each chunk re-renders that message's body and nothing else |
3671
- | `slot(id)` | The element below a message's body, yours to fill (tool output, buttons); a re-render leaves it alone |
3672
- | `messages()`, `streamingId()` | Copies of the messages; the one streaming |
3673
- | `getScrollTop()`, `setScrollTop(px)`, `scrollToBottom()`, `isPinned()` | The transcript's scroll position, to keep across a remount |
3674
- | `setBusy(bool)`, `isBusy()` | `true` turns Send into Cancel |
3675
- | `getText()`, `setText(text)`, `focus()`, `destroy()` | The composer, and teardown |
3676
-
3677
- - **`render(message, bodyEl, { streaming })`** fills an emptied body. One that
3678
- throws leaves that message as its plain text and the view carries on; the
3679
- error goes to `onRenderError(error, message)`. Without `render`, every body
3680
- is plain text (`textContent`, never markup).
3681
- - **Pinned to the bottom.** The transcript starts out following new content.
3682
- Any scroll up, even a few px from the end (the first frame of a smooth wheel
3683
- or arrow-key scroll), stops that and shows *Jump to latest*, and later chunks
3684
- leave the reader where they are. It follows again only after a scroll down
3685
- that ends within `pinThreshold` px (32) of the end, a click on *Jump to
3686
- latest*, `scrollToBottom()`, `replaceAll()` / `clear()`, or a
3687
- `setScrollTop(px)` that lands within the threshold. Content growing below
3688
- never unpins it, nor does content shrinking under it (the browser moves
3689
- `scrollTop` back to the new end, which is not the reader scrolling). A wheel
3690
- over a scroller nested in a body or a slot (a `<pre>`, a tool-output box)
3691
- scrolls that scroller (or, at its top with `overscroll-behavior: contain`,
3692
- keeps the wheel), and a wheel a handler in the transcript cancels, a
3693
- Ctrl+wheel (page zoom, or a trackpad pinch) and a wheel up over a transcript
3694
- already at its top move nothing, so in all these cases the transcript keeps
3695
- following.
3696
- - **The composer** grows with its text up to `composerMaxHeight` (200 px).
3697
- Enter submits and clears it (blank text is ignored, and so is Enter during
3698
- IME composition); Shift+Enter is left to the browser, which inserts the
3699
- newline (so undo and `input` listeners see it). While busy, Enter submits
3700
- nothing, and Esc (wherever focus is inside the view) or a click on Cancel
3701
- calls `onCancel` once per busy period. That Esc goes no further
3702
- (`stopPropagation`), so a `ManagedWindow` or panel holding the view that
3703
- closes on Escape stays open. An Esc a handler inside the view has already
3704
- consumed (`preventDefault`, e.g. an inline popup in the transcript closing
3705
- itself) is left alone: it neither cancels nor is stopped.
3706
- - **Ids are unique.** `append`, `beginStream` or `replaceAll` with an id already
3707
- in the transcript (or repeated within the `replaceAll` list) throws, and the
3708
- refused call changes nothing: no row, no open stream, the old transcript kept.
3709
- - `labels: { send, cancel, jump, input }` replaces the four texts.
3710
-
3711
- `tests/conversation_view.test.mjs` drives all of it in jsdom with a fake
3712
- scroll geometry.
3713
-
3714
- ## The host port
3715
-
3716
- FlexDesk never touches `pywebview`, `fetch`, `localStorage` or the filesystem. It
3717
- asks a **Host**, and you supply one. `createHost({...})` validates the capabilities
3718
- you pass and freezes the result; anything you leave out is simply absent, and the
3719
- library degrades rather than throwing.
3720
-
3721
- ```js
3722
- createHost({
3723
- state: { read, write }, // layout persistence
3724
- dialogs: { saveFile, openFile? }, // optional — CSV export falls back to a Blob;
3725
- // openFile (itself optional) to pickFile()
3726
- window: { minimize, maximize, ... }, // optional — no native chrome buttons without it
3727
- gallery: { list }, // optional (0.17.0) — mountGalleryPicker's entries
3728
- })
3729
- ```
3730
-
3731
- `NULL_HOST` is a valid host with nothing behind it.
3732
-
3733
- Two adapters ship ready-made. `createPywebviewHost()` speaks to a pywebview
3734
- window, and `createPhotinoHost()` (0.14.0) to a Photino.NET window over
3735
- `window.external.sendMessage` / `receiveMessage`:
3736
-
3737
- ```js
3738
- import { createPhotinoHost } from '@flexdesk/host';
3739
-
3740
- // In a browser tab this is NULL_HOST. Keep a capability of your own by passing
3741
- // it in, or leave one out with null.
3742
- const host = createPhotinoHost({ state: myServerState, onEvent: (name, value) => {} });
3743
- ```
3744
-
3745
- `dialogs.saveFile` falls back to a Blob download when the window refuses, and
3746
- the messages the C# side has to answer are in
3747
- [`src/host/photino_host.md`](src/host/photino_host.md).
3748
-
3749
- **The dialogs a pywebview bridge answers.** `createPywebviewHost()` calls these
3750
- methods on `window.pywebview.api`, positionally. Each is optional: a missing
3751
- one makes its capability answer `null`, and FlexDesk degrades.
3752
-
3753
- | Bridge method | Arguments | Reply | Behind |
3754
- |---|---|---|---|
3755
- | `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` |
3756
- | `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` |
3757
-
3758
- ### Opening a file: `pickFile` (0.17.0)
3759
-
3760
- ```js
3761
- import { pickFile } from '@flexdesk/host';
3762
-
3763
- const r = await pickFile(host, { accept: ['.json'], kind: 'json' }); // encoding: 'text' (default) or 'base64'
3764
- if (r.ok) load(JSON.parse(r.text));
3765
- else if (r.error) toast(r.error); // r.cancelled: say nothing
3766
- ```
3767
-
3768
- `pickFile(host, {accept, kind, encoding})` takes `accept` as a list or as a
3769
- comma-separated string (`'.json,.csv'`); the host always receives the list
3770
- (`['.json', '.csv']`). It asks the host's
3771
- `dialogs.openFile` first, a new optional member of `dialogs`: a host whose
3772
- `dialogs` has only `saveFile` is still complete. Without it, or when it
3773
- answers `null` (no dialog behind it), a transient hidden
3774
- `<input type="file">` opens the browser's chooser, so the same call works in a
3775
- browser tab, under `NULL_HOST`. Either way it resolves one of
3776
-
3777
- ```
3778
- {ok: true, name, path?, text} encoding 'text'
3779
- {ok: true, name, path?, data} encoding 'base64': the file's bytes
3780
- {ok: false, cancelled: true} the user closed the chooser
3781
- {ok: false, error} show it
3782
- ```
3783
-
3784
- `path` comes only from a native host. The pywebview adapter maps `openFile` to
3785
- `read_file_dialog(accept, kind, encoding)` (the table above); the Photino
3786
- adapter sends `dialogs.openFile`, documented in
3787
- [`photino_host.md`](src/host/photino_host.md). The browser chooser reports a
3788
- cancel through the input's `cancel` event, which every current engine fires.
3789
-
3790
- ### A window with its own chrome (0.15.0)
3791
-
3792
- The same app runs three ways, and the page needs no switch for it:
3793
-
3794
- | | title bar and borders | FlexDesk draws |
3795
- |---|---|---|
3796
- | a browser tab | the browser's | nothing |
3797
- | a Photino window, native frame | the OS's | nothing |
3798
- | a Photino window, custom frame | none: your top bar is the title bar | minimise / maximise / close, resize handles, drag |
3799
-
3800
- ```js
3801
- import { WindowChromeController } from '@flexdesk/widgets';
3802
-
3803
- new WindowChromeController({
3804
- host, // createPhotinoHost(), or any Host
3805
- topBar: '.my-top-bar', // drags the window (default .twm-global-top-bar)
3806
- controls: '.my-top-bar .right', // where the three buttons go (default: the bar's .twm-bar-right)
3807
- noDrag: '.my-search', // more that never starts a drag
3808
- }).initialize(); // resolves true only with custom chrome
3809
- ```
3810
-
3811
- `host.window.chrome()` decides. The Photino host asks its native side once
3812
- (`host.describe`, its `frame` field) and answers `'custom'` only when the
3813
- window was made frameless, so a browser tab and a native-frame window get no
3814
- handles, no buttons and no CSS. With custom chrome, a drag on the top bar and
3815
- a press on a resize handle go to the host's `startNativeDrag()` /
3816
- `startNativeResize(edge)`. On Windows the native side hands them to the OS,
3817
- so the window snaps, takes Win+arrows and resizes like any other.
3818
- [`photino_host.md`](src/host/photino_host.md) has the native half: keep the
3819
- frame styles, return the whole window from `WM_NCCALCSIZE`, start gestures with
3820
- `WM_NCLBUTTONDOWN`. Don't use Photino's `SetChromeless`, which loses all of that.
3821
-
3822
- ### Links out, and the browser's right-click menu (0.16.0)
3823
-
3824
- In a native window a `target="_blank"` link or a `window.open()` opens nothing,
3825
- or a bare popup with no way back. Hand them to the system browser instead:
3826
-
3827
- ```js
3828
- import { routeExternalLinks } from '@flexdesk/host';
3829
- import { suppressNativeContextMenu } from '@flexdesk/widgets';
3830
-
3831
- routeExternalLinks({ host }); // a no-op without host.shell, so call it anywhere
3832
- suppressNativeContextMenu(); // only your own menus, plus the native one in text
3833
- ```
25
+ | `@flexdesk/core` | Event bus, settings, logging, state machine, state guard |
26
+ | `@flexdesk/host` | The host port: how the library reaches storage, dialogs and native windows |
27
+ | `@flexdesk/wm` | The tiling window manager: tabs, splits, desktops, command palette, `createShell()` |
28
+ | `@flexdesk/widgets` | DataTable, forms, menus, tabs, toasts, panels, windows, trees |
29
+ | `@flexdesk/charts` | Plotly charts (Plotly is a peer dependency) |
30
+ | `@flexdesk/tiles` | A draggable, resizable dashboard widget grid |
31
+ | `@flexdesk/editor` | A Monaco multi-file editor (Monaco is a peer dependency) |
32
+ | `@flexdesk/canvas` | A pannable, zoomable canvas with an edge router and a graph layer |
33
+ | `@flexdesk/flow` | Flow editors: canvas, outline and lanes |
3834
34
 
3835
- `routeExternalLinks` sends http(s) and mailto links that leave the app (a
3836
- `_blank` target, another origin, a middle click) and external `window.open`
3837
- calls to `host.shell.openExternal(url)`. That is a new optional host
3838
- capability, which `createPhotinoHost()` offers as `shell.openExternal`. If the
3839
- native side refuses, the original `window.open` runs. In a browser tab the
3840
- host has no `shell`, and nothing changes.
35
+ With npm the entries are imported as `flexdesk/wm`, `flexdesk/widgets` and so on. The docs write them as `@flexdesk/…`, the names an import map gives them. [Packages and entry points](docs/concepts/packages-and-entry-points.md) has the sizes.
3841
36
 
3842
- `suppressNativeContextMenu` prevents the browser's or webview's own menu (Back,
3843
- Reload, Print, Inspect) on any right-click that no handler claimed with
3844
- `preventDefault()`. It keeps the native menu in editable text and on selected
3845
- text, where Paste and Copy are the point, and wherever `allow(event)` says so.
3846
- Both calls return `{ dispose }`.
37
+ ## Documentation
3847
38
 
3848
- ## The state guard: who wrote that attribute (0.17.0)
3849
-
3850
- `StateGuardService` blocks (`strict`) or reports (`warn`) `data-*` writes that
3851
- are on no allow-list. A module override lets one module write a key nobody
3852
- else may:
3853
-
3854
- ```js
3855
- import { StateGuardService, DEFAULT_STATE_GUARD_CONFIG } from '@flexdesk/core';
3856
-
3857
- const guard = new StateGuardService({ eventBus, config: { ...DEFAULT_STATE_GUARD_CONFIG, mode: 'strict' } });
3858
- guard.allow('src/app/my_widget.js', ['data-foo']); // a literal substring of the module, or a RegExp
3859
- ```
3860
-
3861
- A write is attributed to the frame that made it: the URL or path of the file
3862
- that called `setAttribute`, `removeAttribute` or `dataset`, without line and
3863
- column, whatever your directory layout. A FlexDesk widget's write is the
3864
- widget's, not that of the app module that called the widget. To attribute
3865
- writes further up the stack instead, pass `moduleFramePattern`: the first
3866
- frame from the writer up whose location matches names the module, by the
3867
- pattern's first capture group if it has one.
3868
-
3869
- ```js
3870
- config: { ...DEFAULT_STATE_GUARD_CONFIG, moduleFramePattern: /(src\/app\/[^/]+\.js)/ }
3871
- // a write made inside @flexdesk/widgets on behalf of src/app/my_widget.js
3872
- // is now attributed to 'src/app/my_widget.js'
3873
- ```
3874
-
3875
- The defaults are `mode: 'warn'`, no module overrides and no frame pattern.
3876
- Before 0.17.0 the module was only found under a `js_new/` path, the defaults
3877
- carried one application's override, and the mode read one application's debug
3878
- global; choose strict mode in your own code.
3879
-
3880
- ### The defaults cover FlexDesk's own keys (0.17.0)
3881
-
3882
- `DEFAULT_STATE_GUARD_CONFIG` includes every data key FlexDesk's own widgets
3883
- write, so the shell, dialogs, forms, tooltips, tiles and flow editors run under
3884
- `mode: 'strict'` with no violation. The list also carries some keys that
3885
- FlexDesk itself does not write. Add your application's keys on top:
3886
-
3887
- ```js
3888
- const guard = new StateGuardService({ eventBus, logger, config: {
3889
- ...DEFAULT_STATE_GUARD_CONFIG,
3890
- allowedDatasetKeys: [...DEFAULT_STATE_GUARD_CONFIG.allowedDatasetKeys, 'order-id'],
3891
- } });
3892
- ```
3893
-
3894
- An allow-list key may be spelled either way: `orderId` and `order-id` are the
3895
- same key, in the allow-list and in module overrides
3896
- (`allow(pattern, attributes, { datasetKeys })` and `config.moduleOverrides`)
3897
- alike. camelCase maps to kebab-case as the DOM does for ordinary keys: each
3898
- capital letter becomes `-` plus its lower-case form. So `rowID` is `row-i-d`
3899
- (what `dataset.rowID` writes), not `rowid`; to allow `data-id`, list `id`.
3900
- Before 0.17.0 a camelCase entry never matched, and some entries named keys no
3901
- element carries.
3902
-
3903
- ## Peer dependencies
3904
-
3905
- `@flexdesk/charts` needs Plotly (4.4 MB). `@flexdesk/editor` needs Monaco (14 MB).
3906
- Neither is bundled, and **neither is guessed at**:
3907
-
3908
- ```js
3909
- import { setPlotlySource } from '@flexdesk/charts';
3910
- import { setMonacoBasePath, setDefaultLanguage } from '@flexdesk/editor';
3911
-
3912
- setPlotlySource('/vendor/plotly/plotly.min.js');
3913
- setMonacoBasePath('/vendor/monaco');
3914
- setDefaultLanguage('my-dsl'); // default: 'plaintext'
3915
- ```
3916
-
3917
- There is deliberately **no default path**. A hardcoded `'vendor/monaco'` is one
3918
- application's directory layout, and for everyone else it is a 404 that surfaces as
3919
- an editor which silently never appears. `initMonaco()` throws instead.
3920
-
3921
- `getEditorFactory()` and `initMonaco()` **reject** when no path was set, when
3922
- `vs/loader.js` fails to load, or when Monaco's AMD loader reports an error, so a
3923
- consumer can show a message instead of waiting forever. What happens next
3924
- depends on which of those it was:
3925
-
3926
- - **`vs/loader.js` failed to load** (a 404, a wrong path): not sticky. Fix the
3927
- path (`setMonacoBasePath`, or pass it to `initMonaco`) and the next call
3928
- tries again.
3929
- - **Monaco's AMD loader reported an error** (`vs/editor/editor.main` or a
3930
- dependency failed): **final for the page**. Monaco's loader caches the failed
3931
- module and has no way to forget it, so every later call rejects with that
3932
- same first error, whatever path it is given. Fix the path and reload.
3933
-
3934
- Loading is shared: if you call `initMonaco(path)` yourself, `getEditorFactory()`
3935
- uses that load and needs no `setMonacoBasePath()`. `monacoReady` only ever
3936
- resolves — it is the "Monaco has arrived" signal, not the place to learn that
3937
- an attempt failed.
3938
-
3939
- If you already load Plotly yourself (a `<script>` tag that sets `window.Plotly`),
3940
- the chart loader short-circuits and you can skip `setPlotlySource`.
3941
-
3942
- ### Auto-height editors: `maxHeight` (0.17.0)
3943
-
3944
- `createEditor()` sizes its container to the content (80px at least, see
3945
- `setMinHeight`). Cap it with `maxHeight`, in px; `Infinity`, the default, is
3946
- no cap. Past the cap Monaco scrolls internally and takes the mouse wheel;
3947
- below it the wheel reaches whatever scrolls around the editor.
3948
-
3949
- ```js
3950
- const h = factory.createEditor(el, source, { maxHeight: 400 });
3951
- h.setMaxHeight(Infinity); // applies at once: grows to the content
3952
- ```
3953
-
3954
- A non-positive or non-number cap throws a `RangeError`. With `noAutoHeight`
3955
- the container's own layout decides the size and `setMaxHeight` has no effect.
3956
- Before 0.17.0 the cap was a fixed 400px, switched on by `data-code-max-height="true"`
3957
- on a `.notebook-editor-container` ancestor; that attribute is no longer read.
3958
-
3959
- ## Storage keys and names are yours (0.17.0)
3960
-
3961
- Every localStorage key FlexDesk writes is an option, and so is each name in the
3962
- table below that it registers where other code can see it, except the last row:
3963
- the tile tab MIME is fixed. The defaults are FlexDesk's own; pass yours at boot,
3964
- before the first read:
3965
-
3966
- | What | Default | Set it with |
3967
- |---|---|---|
3968
- | Settings (the `getSetting`/`setSetting` binding) | `'flexdesk.settings.v1'` | `configureSettings({ storageKey })` from `@flexdesk/core` |
3969
- | ManagedWindow geometry | `'flexdesk.managedWindows.v1'` | `ManagedWindow.configure({ storageKey })` from `@flexdesk/widgets` |
3970
- | Saved tile layouts | `'twm.tiles.layouts'` | `configureLayoutPersistence({ layoutsKey })` from `@flexdesk/tiles` |
3971
- | Saved tile configurations | `'twm.tiles.configs'` | `configureLayoutPersistence({ configsKey })` from `@flexdesk/tiles` |
3972
- | Built-in Monaco themes | `'flexdesk-dark'`, `'flexdesk-light'` | `setEditorThemeNames({ dark, light })` from `@flexdesk/editor` |
3973
- | MIME type of a dragged editor tab | `'application/x-flexdesk-tab'` | `setTabDragMime(mime)` from `@flexdesk/editor` |
3974
- | Chart PNG export file name | `'chart'` (saved as `chart.png`) | `setChartExportFilename(name)` from `@flexdesk/charts` |
3975
- | `LoggingService` service name on each entry | `'flexdesk'` | `new LoggingService({ name })` from `@flexdesk/core` |
3976
- | 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` |
3977
-
3978
- ```js
3979
- import { configureSettings } from '@flexdesk/core';
3980
- import { ManagedWindow } from '@flexdesk/widgets';
3981
- import { setEditorThemeNames, setTabDragMime } from '@flexdesk/editor';
3982
- import { setChartExportFilename } from '@flexdesk/charts';
3983
-
3984
- configureSettings({ storageKey: 'myapp.settings.v1' });
3985
- ManagedWindow.configure({ storageKey: 'myapp.windows.v1' });
3986
- setEditorThemeNames({ dark: 'myapp-dark', light: 'myapp-light' });
3987
- setTabDragMime('application/x-myapp-tab');
3988
- setChartExportFilename('myapp_chart');
3989
- ```
3990
-
3991
- Each default is exported beside its setter (`DEFAULT_SETTINGS_STORAGE_KEY`,
3992
- `DEFAULT_WINDOW_STORAGE_KEY`, `DEFAULT_EDITOR_THEME_NAMES`,
3993
- `DEFAULT_TAB_DRAG_MIME`, `DEFAULT_CHART_EXPORT_FILENAME`), and each setter has a
3994
- getter (`getSettingsStorageKey()`, `ManagedWindow.storageKey`,
3995
- `getEditorThemeNames()`, `getTabDragMime()`, `getChartExportFilename()`). An
3996
- embedder that passes nothing gets the defaults silently: nothing is logged.
3997
-
3998
- - **Settings** are read lazily: importing `@flexdesk/core` touches no storage,
3999
- and the binding reads its key on the first `getSetting`/`setSetting`/reset.
4000
- `registerSettings()` and `registerSettingsEventBus()` may come before or after
4001
- `configureSettings()`. Configured after the binding was in use, it moves: values
4002
- return to their defaults, are reloaded from the new key, and one
4003
- `settings:changed` (`path: null`) is emitted. The old key is not touched. A
4004
- private store from `createSettingsStore()` moves the same way with
4005
- `store.setStorageKey(key)` (and answers `store.getStorageKey()`).
4006
- - **ManagedWindow** reads geometry when a window is first shown; configure it
4007
- before the first `show()`.
4008
- - **Editor themes**: a factory starts on the dark name current when it is
4009
- created, and `init()` defines the built-in themes under the names current
4010
- then. An app with a theme of its own defines it with `monaco.editor.defineTheme`
4011
- and passes its name to `factory.setTheme(name)`.
4012
- - **The tab MIME** is read at drag time by every `NotebookTabBar` and
4013
- `EditorPane`, so the two always agree.
4014
-
4015
- **Upgrading from 0.16.** The old defaults were one application's names:
4016
- `'ecosim.settings.v1'`, `'ecosim.managedWindows.v1'`, `'ecosim-dark'` /
4017
- `'ecosim-light'`, `'application/x-ecosim-tab'`, `'ecosim_chart'` and the log
4018
- service name `'ecosim'`. If your users have settings or window geometry saved
4019
- under the old keys, pass those keys explicitly and nothing is lost:
4020
-
4021
- ```js
4022
- configureSettings({ storageKey: 'ecosim.settings.v1' });
4023
- ManagedWindow.configure({ storageKey: 'ecosim.managedWindows.v1' });
4024
- ```
4025
-
4026
- Code that called `factory.setTheme('ecosim-light')` now calls
4027
- `factory.setTheme(getEditorThemeNames().light)` (or names the themes back with
4028
- `setEditorThemeNames`), and code that read `'application/x-ecosim-tab'` off a drag
4029
- reads `getTabDragMime()`.
4030
-
4031
- ## Derived series: `seriesTransforms` (0.17.0)
4032
-
4033
- A plot can draw series worked out from its own: a trend, a moving average, a
4034
- decomposition. `@flexdesk/charts` ships none; you pass them to
4035
- `PlotPopoutWindow` (or `openPlotPopoutWindow`) as `seriesTransforms`:
4036
-
4037
- ```js
4038
- import { openPlotPopoutWindow, renderSeriesTransformControls } from '@flexdesk/charts';
4039
-
4040
- const seriesTransforms = {
4041
- hp: {
4042
- label: 'HP filter',
4043
- params: { lambda: { label: 'Lambda', default: 1600, min: 1, step: 100 } },
4044
- outputs: {
4045
- trend: { label: 'Trend', suffix: ' (HP Trend)', lineStyle: 'dashed' },
4046
- cycle: { label: 'Cycle', suffix: ' (HP Cycle)', lineStyle: 'dotted', lineWidth: 1 },
4047
- },
4048
- run: (y, { lambda }) => myBackend.hpFilter(y, lambda), // -> { trend: [...], cycle: [...] }
4049
- },
4050
- };
4051
-
4052
- openPlotPopoutWindow({ id, title, plotConfig, getResults, services, seriesTransforms,
4053
- configRenderer: (el) => renderSeriesTransformControls(el, plotConfig.subplots[0],
4054
- seriesTransforms, { onChange: redraw }) });
4055
- ```
4056
-
4057
- What a subplot switches on is in the subplot:
4058
- `subplot.transforms = { hp: { outputs: { trend: true }, params: { lambda: 100 } } }`.
4059
- For every transform with an output switched on, `run(y, params)` is called
4060
- once per line or scatter trace of the subplot (params: the declared defaults
4061
- under the subplot's own), and each switched-on output it returns is drawn as
4062
- an extra trace in the source trace's colour, on its axis, named
4063
- `<source name><suffix>`, with the output's `lineStyle` and `lineWidth`
4064
- (default 2). A transform that throws or rejects is skipped for that trace,
4065
- reported to `services.logger.warn`, and the figure is drawn without it.
4066
- `deriveSeriesTraces(traces, subplot, seriesTransforms, { onError })` is that
4067
- step on its own, for a renderer of your own.
4068
-
4069
- `renderSeriesTransformControls(container, subplot, seriesTransforms, { onChange })`
4070
- renders, per transform, a toggle for each output and a number field for each
4071
- parameter (disabled while no output is on); it edits `subplot.transforms` in
4072
- place and returns `{ dispose }`. With no transforms it renders nothing.
4073
-
4074
- Until 0.17.0 the plot window called one application's backend for a
4075
- Hodrick-Prescott filter, and every default subplot carried `showHpTrend`,
4076
- `showHpCycle` and `hpLambda`. Those keys are gone and are ignored in saved
4077
- configs; without `seriesTransforms` no derived trace is drawn.
4078
- `tests/series_transforms.test.mjs` pins all of it.
4079
-
4080
- ## No application globals: the gallery picker and plot-window events (0.17.0)
4081
-
4082
- Nothing under `src/` outside `src/host` reads `window.pywebview` or an
4083
- application's global; `tests/no_app_globals.test.mjs` pins it, with the toast
4084
- helpers ([Notifications](#notifications-notificationcenter)) and these two:
4085
-
4086
- **`mountGalleryPicker(el, { entries | host, kind, ... })`** takes its entries from
4087
- `entries`, or else from the host's optional `gallery` capability:
4088
- `host.gallery.list(kind)` answers `[{id, kind, label, description?, fields?}]`
4089
- (or a promise of them). Neither shipped adapter advertises it; add your own:
4090
-
4091
- ```js
4092
- const host = createHost({ ...createPywebviewHost({ bridgeRef }),
4093
- gallery: { list: (kind) => myBackend.starters(kind) } });
4094
- await mountGalleryPicker(el, { host, kind: 'report', onChange });
4095
- ```
4096
-
4097
- With neither `entries` nor the capability, the picker shows its `emptyLabel`
4098
- and logs one warning; a `list` that throws does the same. Until 0.17.0 it
4099
- called one application's bridge method.
4100
-
4101
- **`PlotPopoutWindow({ streamEvents })`** names the bus events a live data
4102
- source emits, next to `refreshEvent`: `{ delta, complete }`. The window
4103
- redraws on each while live update is on. There are no default names: without
4104
- `streamEvents`, or for a name left out or given as `null`, nothing is listened
4105
- for, and the window updates through `refreshEvent` and explicit calls only.
4106
-
4107
- ```js
4108
- openPlotPopoutWindow({ id, title, plotConfig, getResults, services,
4109
- refreshEvent: 'run:finished',
4110
- streamEvents: { delta: 'sim:delta', complete: 'sim:done' } });
4111
- ```
4112
-
4113
- ## A plot config in any container: `renderPlotGrid` (0.17.0)
4114
-
4115
- `PlotPopoutWindow` used to keep its subplot renderer private, so anything else
4116
- that showed the same plot config (a notebook cell, a dashboard tile) had to
4117
- copy it. It is now exported, and the window is one of its callers:
4118
-
4119
- ```js
4120
- import { renderPlotGrid, migrateData } from '@flexdesk/charts';
4121
-
4122
- const grid = renderPlotGrid(el, migrateData(savedConfig), { time, series }, {
4123
- overlays, // { [series.id]: { years, values } } for series with an `overlay`
4124
- seriesTransforms, // as above
4125
- hiddenSeries: new Set(), // trace names not to draw
4126
- });
4127
- await grid.update({ results: next }); // any of plotConfig, results, overlays, hiddenSeries
4128
- grid.resize();
4129
- grid.dispose();
4130
- ```
4131
-
4132
- It lays a `.twm-plot-grid` out from `PLOT_LAYOUTS` (a cell per slot, a
4133
- `.twm-plot-grid__cell` in that slot's grid area) and draws one figure per
4134
- subplot: its chart type, interpolation, data points and stack groups, every
4135
- y-axis (left or right, linear or log, min, max, step), reference lines and
4136
- shaded bands, the NaN strategy, the x-axis clipped to the results' time
4137
- range, or an x variable instead of time (`xAxis: { useTime: false, variable }`).
4138
- A subplot with nothing to draw shows `emptyMessage` (default `No data yet.`); a
4139
- slot with no subplot shows `noSubplotMessage` (`No subplot configured.`); null
4140
- results empty every subplot. A cell that is detached or has no size is not
4141
- drawn: the grid's own ResizeObserver draws it once it has one. `dispose()`
4142
- purges every figure and removes the grid; `grid.charts` is the cells, for
4143
- Plotly calls of your own (relayout, toImage), and `grid.plotted` is the cells
4144
- that show a figure right now, however it got there (an `update()`, or a
4145
- deferred draw on a resize). `update()` resolves whether it drew anything, but a
4146
- cell it deferred is drawn later, so ask `grid.plotted`, not that value, before
4147
- acting on a figure.
4148
-
4149
- The other options:
4150
-
4151
- | Option | What it does |
39
+ | | |
4152
40
  |---|---|
4153
- | `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. |
4154
- | `derivedTraces(traces, subplot, { results, index })` | Extra Plotly traces (or a promise of them) appended to a subplot's figure. |
4155
- | `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. |
4156
- | `theme` | Overrides for `PLOT_GRID_THEME`: `fontFamily`, `fontColor`, `fontSize`, `paperBackground`, `plotBackground`, `gridColor`, `lineColor`, `tickColor`, `zeroLineColor`, `hoverBackground`, `hoverBorder`. |
4157
- | `alwaysShowLegend` | Draws a subplot whose legend is `'hidden'` with one at the top. |
4158
- | `layout` | Plotly layout fields merged over every figure's (`hovermode`, `margin`, ...). |
4159
-
4160
- `PlotPopoutWindow` takes `resolveSeries` too, for its chart and its data
4161
- table. Its chart cells were `div.plot-tile-chart` and are now
4162
- `div.twm-plot-grid__cell` inside a `.twm-plot-grid`. An overlay is drawn for
4163
- any series with an `overlay` and data under its id in `actuals`; it used to
4164
- need `overlay.etlKey` as well. More changes to what the window draws, for
4165
- configs outside the fixtures:
4166
-
4167
- - On an `'area-stacked'` subplot, overlays are stacked too, in a group of their
4168
- own per y-axis (`stackgroup: 'overlay-<y ref>'`, `groupnorm: 'fraction'`,
4169
- `mode: 'lines'`, no fill), so they read on the same 0..1 scale as the stack.
4170
- They used to be drawn unstacked, at their raw values.
4171
- - A subplot with no traces draws an empty figure, not the empty message, only
4172
- when **all of its own** series are hidden (its overlays and derived traces do
4173
- not count). It used to do that for every subplot as soon as any series
4174
- anywhere in the window was hidden. Null results win over that rule: with no
4175
- results every configured subplot shows `emptyMessage`, hidden series or not.
4176
- - While `getResults()` answers null and nothing has been drawn yet, every cell
4177
- shows `emptyMessage`; the cells used to stay blank. Figures already drawn
4178
- stay while the results are briefly null, as before.
4179
- - A y axis's positive `step` sets the Plotly `dtick` of that axis. The window
4180
- used to ignore `step`, so Plotly chose the tick spacing.
4181
- - A subplot with `xAxis: { useTime: false, variable }` is drawn against that
4182
- variable, with an x-axis title (`xAxis.label`, else the variable's name) and
4183
- no clip of the x axis to the time range. The window used to ignore `xAxis`
4184
- and always drew against time, clipped to the first and last time point.
4185
-
4186
- Its zoom and pan buttons and Download PNG act on `grid.plotted`, so a figure the
4187
- grid drew on a resize (the window opened while its plot area had no size) is
4188
- zoomable and exportable; Download PNG exports the first subplot that shows a
4189
- figure, where it used to try the first slot whatever it held.
4190
- `tests/plot_grid.test.mjs` pins all of it,
4191
- including that the window draws the same traces, names and axes for the
4192
- fixture configs (`tests/fixtures/plot_grid_fixtures.mjs`) as it did before.
4193
-
4194
- ## Editing a plot config: `renderPlotConfigEditor` (0.17.0)
4195
-
4196
- `renderPlotGrid` draws a plot config; `renderPlotConfigEditor` edits one, in
4197
- place, so an embedder no longer writes its own panel for it:
4198
-
4199
- ```js
4200
- import { renderPlotConfigEditor, renderPlotGrid, migrateData } from '@flexdesk/charts';
4201
-
4202
- const plotConfig = migrateData(savedConfig);
4203
- const editor = renderPlotConfigEditor(el, plotConfig, {
4204
- onChange: () => grid.update({ plotConfig }), // after every edit
4205
- seriesProvider: ({ value, role, subplot }) => names(value), // items, or a promise of them
4206
- seriesTransforms, // their toggles, per subplot
4207
- overlaySources: () => mySources(), // optional
4208
- extraSeriesFields: (el, series, { subplot, axis, onChange }) => { /* your fields */ },
4209
- });
4210
- editor.dispose();
4211
- ```
4212
-
4213
- It draws a layout picker (every `PLOT_LAYOUTS` entry), a tab per subplot slot
4214
- when the layout has more than one, and for the subplot in front: its title;
4215
- its chart type, legend, interpolation, NaN handling and data points; its x
4216
- axis (time, or a series); its y axes (label, scale, position, min, max, step)
4217
- and, under each, its series (a picker, legend label, colour, width, dash and
4218
- stack group); its z axis while the chart type is 3D; its reference lines and
4219
- shaded bands; and the toggles of any `seriesTransforms`. Every edit writes the
4220
- config it was given and then calls `onChange(config)`.
4221
-
4222
- - A layout with more slots than there are subplots adds default subplots
4223
- (`makeDefaultSubplot`); one with fewer keeps the extra subplots, so switching
4224
- back restores them.
4225
- - **Add Y axis** adds `makeDefaultYAxis(index)` (left for an even index, right
4226
- for an odd one) and **Add series** adds `makeDefaultSeries(index)` (coloured
4227
- by its place among the subplot's series). Both come from `@flexdesk/charts`,
4228
- and `makeDefaultSubplot` and `migrateData` build their axes and series from
4229
- them, so an axis added in the editor matches a default subplot's.
4230
- - A 3D chart type gives the subplot a z axis (label, scale, min, max, step and
4231
- a series) and uses its first y axis only: the other y axes are shown disabled
4232
- and **Add Y axis** is disabled. A 2D chart type sets `zAxis` back to `null`.
4233
- - Every series picker is an `AutocompleteField` fed by `seriesProvider`, asked
4234
- `{ value, role, subplot }`, where `role` is `'series'`, `'x'` or `'z'`. It
4235
- answers AutocompleteField items (`{ label, insertText?, type? }`) or a promise
4236
- of them. A provider that throws or rejects shows no suggestions and leaves
4237
- the typed text as it is (`logger.warn` hears it when you pass `logger`).
4238
- Without `seriesProvider` the pickers suggest nothing and take typed text.
4239
- - `overlaySources()` answers choice options (`{ value, label }`, or
4240
- `{ group, options }`), or a promise of them, asked once per draw. With it,
4241
- every series row gets an overlay source select; choosing one writes
4242
- `series.overlay = { source, ... }` and shows the overlay's label, colour,
4243
- dash, width and mode. `renderPlotGrid` draws it from the data you pass under
4244
- the series' id in `overlays`. **None** removes `series.overlay`. Without
4245
- `overlaySources` there are no overlay controls.
4246
- - `extraSeriesFields(el, series, { subplot, axis, onChange })` puts fields of
4247
- your own in each series row; call `onChange()` after you edit the series. What
4248
- it returns is disposed (`dispose()`) when the row is redrawn or the editor
4249
- is disposed. A `dispose()` that throws is reported to `logger.warn` and does
4250
- not stop the rest of the editor being disposed.
4251
-
4252
- It returns `{ dispose, refresh, selectSubplot, activeSubplot }`: `dispose()`
4253
- disposes every field it created (every `AutocompleteField` among them) and
4254
- removes the editor; `refresh()` redraws it after the config was changed from
4255
- outside; `selectSubplot(index)` brings a subplot to the front. It fits
4256
- `PlotPopoutWindow`'s `configRenderer` slot, `(container) => { dispose() } | void`:
4257
- the window disposes what the renderer returns when it closes, so return the
4258
- editor and its dropdowns leave `document.body` with the window.
4259
- `openPlotPopoutWindow` is async; await it:
4260
-
4261
- ```js
4262
- const win = await openPlotPopoutWindow({ id, title, plotConfig, getResults, services,
4263
- configRenderer: (el) => renderPlotConfigEditor(el, plotConfig,
4264
- { onChange: () => win.updateConfig(plotConfig) }) });
4265
- ```
4266
-
4267
- Its classes are under `.twm-plot-config` in `flexdesk.css`.
4268
- `tests/plot_config_editor.test.mjs` pins all of it.
4269
-
4270
- ## Layouts are keyed by whatever you key them by
4271
-
4272
- `@flexdesk/tiles` stores layouts under keys **you** choose and resolves a starting
4273
- layout through a callback **you** supply:
4274
-
4275
- ```js
4276
- import { configureLayoutPersistence } from '@flexdesk/tiles';
4277
-
4278
- configureLayoutPersistence({
4279
- layoutsKey: 'myapp.layouts', // default: 'twm.tiles.layouts'
4280
- resolveTemplate: (ctx) => pickTemplate(ctx) ?? null, // null => empty grid
4281
- });
4282
- ```
4283
-
4284
- **Configure it before anything reads it.** The store is a singleton; built before
4285
- you supply your key, it is built with the default, and every layout your users have
4286
- saved becomes silently invisible. That is a data-loss bug that raises no error, so
4287
- the configuration step is explicit rather than lazy.
4288
-
4289
- A layout is keyed by *something* — a document, a user, a run — and the library has
4290
- no opinion about which. It calls that a `contextId`.
4291
-
4292
- ### Chart and KPI tiles (0.13.0)
4293
-
4294
- `@flexdesk/tiles` ships two widgets: `flexdesk.chart` (line, bar, area, scatter,
4295
- pie, donut, funnel, histogram — Plotly, so `setPlotlySource` first) and
4296
- `flexdesk.kpi` (one number, a label and an optional delta; plain DOM, no Plotly).
4297
- Nothing registers at import — call `registerChartTiles()` (idempotent). You hand
4298
- a tile **already-computed** series; turning records into series is yours:
4299
-
4300
- ```js
4301
- import { registerChartTiles } from '@flexdesk/tiles';
4302
- registerChartTiles();
4303
- const chart = grid.addTile('flexdesk.chart', { x: 0, y: 0, w: 6, h: 4 },
4304
- { chart_type: 'line', title: 'Burndown', unit: 'pts' });
4305
- chart.onPointClick = ({ customdata }) => openItem(customdata);
4306
- chart.setData({
4307
- series: [
4308
- { x: days, y: remaining, name: 'Remaining', interpolation: 'step', customdata: ids },
4309
- { x: days, y: ideal, name: 'Ideal', lineStyle: 'dashed' },
4310
- ],
4311
- markers: [{ x: today, label: 'Today' }], // vertical lines
4312
- note: 'From 41 stories', // a line under the figure
4313
- });
4314
- grid.addTile('flexdesk.kpi', { x: 6, y: 0, w: 3, h: 2 }, { title: 'Open', unit: 'bugs' })
4315
- .setData({ value: 42, reference: 37 }); // ▲ +5 bugs
4316
- ```
4317
-
4318
- `setData(null)` or an empty series is the empty state, and it releases any figure
4319
- still drawing. A pie takes `{labels, values}`, a histogram `{values}`. The config
4320
- (`chart_type`, `title`, `unit`, …) round-trips through `getLayout()`/`setLayout()`.
4321
- `buildOverlayShapes` gained `verticalLines: [{x, label?, color?, lineStyle?}]` for
4322
- the same marker outside a tile.
4323
-
4324
- A chart tile's **Download PNG** (0.14.1) goes through the grid's `host`
4325
- (`new TileGrid({ host })`): with `dialogs`, the native Save dialog — a save
4326
- toasts where the file went, a cancel says nothing — and with no host, or a
4327
- `saveFile` that answers null, the browser download it always was.
4328
-
4329
- ### Figure tiles: one override, the draw is the base's (0.17.0)
4330
-
4331
- `FigureTile` is the base every Plotly tile draws through — `ChartTile` is one.
4332
- A subclass overrides **one** method, `figure(data)`, answering
4333
- `{traces, layout?, config?, frames?}` or `null`, synchronously:
4334
-
4335
- ```js
4336
- import { FigureTile, registerWidget } from '@flexdesk/tiles';
4337
- import { buildHistogramTrace } from '@flexdesk/charts';
4338
-
4339
- class SpreadTile extends FigureTile {
4340
- static TYPE = 'myapp.spread';
4341
- static TITLE = 'Spread';
4342
- figure(data) {
4343
- if (!data?.values?.length) return null; // the empty state
4344
- return { traces: [buildHistogramTrace({ x: data.values })], layout: { margin: { t: 8 } } };
4345
- }
4346
- }
4347
- registerWidget(SpreadTile);
4348
- ```
4349
-
4350
- The base awaits `ensurePlotly()` (so a page using `setPlotlySource` draws on
4351
- first view, and data that changes while Plotly loads draws once), draws through
4352
- `createChart` with `scrollZoom` and the mode bar off (`config` goes over that),
4353
- adds `frames`, and discards a draw that lands after a newer one. `null` is the
4354
- empty state with `static EMPTY_MESSAGE` — call `this.showEmpty(why)` yourself
4355
- first to say why. A `figure()` that throws, or a draw that fails, is the error
4356
- state plus `tile:render-failed {tileId, message}`. Loading, empty, error and
4357
- `dispose()` purge the figure; a resize that settles relayouts it once. The ⋯
4358
- menu is `TileBase`'s entries plus **Download PNG** (through `host.dialogs`, as
4359
- above); extend it with `[...super.menuItems(), …]` and route your actions
4360
- through `_onMenuAction`, calling `super` for the rest. **Expand** opens
4361
- `openRawTracesWindow` with the last figure drawn (`getExpandData()`), and
4362
- `onPointClick({customdata, point, tile})` — an option or a property — hears a
4363
- click on a mark. `setData(data)` feeds one tile; `null` clears it.
4364
-
4365
- `ChartTile` is now a `FigureTile`: its menu stays Download PNG alone, and its
4366
- header gains the Expand button.
4367
-
4368
- ### List and people tiles, and one scope for the board (0.16.1)
4369
-
4370
- Two more widgets, registered by `registerListTiles()` (idempotent, nothing at
4371
- import). Like the chart tile they take **rows you have already worked out**;
4372
- what they own is what every list on a board needs: the count ("7 of 12"), the
4373
- line that says what empty means, a button in the bar, a click, the keyboard and
4374
- a right-click menu per row.
4375
-
4376
- ```js
4377
- import { registerListTiles, createScopePicker } from '@flexdesk/tiles';
4378
- registerListTiles();
4379
- const late = grid.addTile('flexdesk.list', { x: 0, y: 0, w: 12, h: 4 },
4380
- { title: 'Overdue', empty_text: 'Nothing is late.', max_rows: 0 });
4381
- late.onRowActivate = ({ row, event }) => open(row.key, { modified: event.ctrlKey });
4382
- late.rowMenu = ({ row }) => [{ label: 'Open', icon: 'open_in_new', action: 'open' }];
4383
- late.onRowMenuAction = ({ action, row }) => { /* … */ };
4384
- late.onAction = () => openAsList();
4385
- late.setData({
4386
- rows: [{ key: '7', icon: 'task', lead: 'TASK-0007', title: 'Sign-off',
4387
- cells: [{ text: 'priya' }, { text: '9 days late', tone: 'danger' }] }],
4388
- action: { label: 'Open as a list', icon: 'list' },
4389
- });
4390
- grid.addTile('flexdesk.people', { x: 0, y: 4, w: 6, h: 4 }, { title: 'Who has what' })
4391
- .setData({ people: [{ key: '', name: 'Nobody', muted: true, count: 2,
4392
- pills: [{ text: '2 undated', tone: 'muted' }] }] });
4393
- ```
4394
-
4395
- - A row is `role="button"`: click, Enter and Space call `onRowActivate` with the
4396
- event; Up/Down/Home/End move. Right-click, the context-menu key and Shift+F10
4397
- open the row's menu (`showContextMenu`'s items; none means no menu). A row
4398
- carries `data-row-key`; `rowsDraggable` makes rows draggable.
4399
- - `{rows: []}` keeps the bar and says `empty` (or `config.empty_text`) where the
4400
- rows would be; `setData(null)` is the empty state. Text is always text.
4401
- - `tone` (`danger`, `warning`, `ok`, `muted`) picks a colour only; `className`
4402
- on a row or a cell is yours, added as it is.
4403
- - `groupByPerson(items, personOf)` groups for a people tile, most first.
4404
-
4405
- **One scope for the whole board.** `createScopePicker({options, value, onChange})`
4406
- is a row of buttons, one pressed. `grid.setScope(value)` (or
4407
- `new TileGrid({ scope, onScopeChange })`) hands the value to every tile as
4408
- `tile.scope` and `tile.onScopeChange(scope)`, a tile added later starts with
4409
- it, a grid with a data source reloads its tiles, and the grid's `onScopeChange`
4410
- is told. What a scope means is yours; it is not part of the layout.
4411
-
4412
- ### A tile's settings as a form: `mountTileConfigForm` (0.17.0)
4413
-
4414
- `getConfigSchema()` answers `{fields}`; `mountTileConfigForm` draws them, so a
4415
- config panel is no longer a control written by hand per field:
4416
-
4417
- ```js
4418
- import { mountTileConfigForm, openTileConfig } from '@flexdesk/tiles';
4419
- const form = mountTileConfigForm(host, {
4420
- schema: tile.getConfigSchema(),
4421
- config: tile.config, // defaults filled by mergeWithDefaults
4422
- data, // what getFieldOptions reads for dynamic options
4423
- onChange: (key, value, config) => tile.update(undefined, config),
4424
- renderers: { variable: ({ field, value, data, readonly, commit }) => myPicker(...) },
4425
- });
4426
- form.getValues(); form.setValues(next); form.isValid(); form.focusFirstInvalid();
4427
- form.flush(); form.dispose();
4428
- ```
4429
-
4430
- - A field is `{key, type, label?, description?, default?, required?, readonly?,
4431
- min?, max?, step?, options?, visibleWhen?}`. Without a `label` the field is
4432
- called by its `key`, on its label and in its error messages. Every `FieldTypes` entry has a
4433
- control (text, number, checkbox, select, multiselect, color, range); any other
4434
- `type` needs a renderer, which answers an element or
4435
- `{element, setValue?, dispose?}` and calls `commit(value)` on an edit.
4436
- - An edit is checked by `validateField`. An invalid one shows its error under the
4437
- field and is **not applied**; a valid one calls `onChange` with the field's key.
4438
- `isValid()` is false while a shown row has an error; `focusFirstInvalid()`
4439
- focuses the first such row's control (a renderer's element, or the first
4440
- focusable element inside it) and answers whether there was one.
4441
- - `commitOn: 'input'` (the default) checks text, number, range and colour
4442
- controls on every keystroke; `commitOn: 'change'` checks them once the edit is
4443
- finished (blur, Enter, a released slider), for an `onChange` that is expensive.
4444
- `flush()` commits an edit typed but not yet finished.
4445
- - `visibleWhen: {field, in: [...]}` (or `{field, equals}`) hides the row, never
4446
- rebuilds the form, so the control being typed into keeps its focus.
4447
- - A field with no `key` throws. **The built-in tiles' schemas now use `key` and
4448
- `description`** (they said `name` and `help`, which `validateConfig` and
4449
- `mergeWithDefaults` never read: BUG-0021). A consumer that read `field.name`
4450
- or `field.help` from them reads `field.key` and `field.description`.
4451
-
4452
- The grid's cogwheel emits `grid:config-modal-show`, which now also carries the
4453
- grid's `data`. Nothing listens unless you ask:
4454
- `openTileConfig(eventBus, {mode: 'slide-out', host})` shows the form in a
4455
- `SlideOutPanel` over `host` and saves every finished, valid edit (it runs the
4456
- form with `commitOn: 'change'`, so typing a title is one save, not one per
4457
- keystroke; closing the panel saves an edit still being typed);
4458
- `{mode: 'modal'}` (the default) saves on Save, and refuses Save while a field
4459
- shows an error (the modal stays open and the first invalid field is focused). A request that arrives while a
4460
- modal is open is ignored. It answers a function that unsubscribes and closes the
4461
- slide-out; an open modal is left to the user, keeps its form and still saves on
4462
- Save. A renderer's `commit` on a `readonly` field applies nothing and answers
4463
- `{valid: false, error}`.
4464
-
4465
- ## Styling
4466
-
4467
- Three stylesheets, and that is the whole CSS contract:
4468
-
4469
- | | Specifier | Resolves to | |
4470
- |---|---|---|---|
4471
- | `dist/tokens.css` | `flexdesk/css/tokens` | `css/tokens.css` | Every rule reads `var(--…)` from here. Swap this one file and the shell restyles. |
4472
- | `dist/reset.css` | `flexdesk/css/reset` | `dist/reset.css` | The reset. |
4473
- | `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). |
4474
-
4475
- A bundler, an import-map generator or `require.resolve` reaches each one by its
4476
- specifier, through the package's `exports` map, which closes every other path
4477
- into the package. The map sends `flexdesk/css/tokens` to the source
4478
- `css/tokens.css`, not `dist/tokens.css`. The two are identical in a release,
4479
- since `dist/` is rebuilt in the release commit, but can differ on a working
4480
- checkout. `flexdesk/css/reset` is missing from 0.16.1 and earlier,
4481
- where it fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`.
4482
-
4483
- `createDetailHeader` follows the prefix too: a badge is a `.twm-detail-header__badge`
4484
- pill (a badge's `className` is added beside it, to recolour it), and an action is a
4485
- `.twm-btn` — `variant: 'primary'` adds `.twm-btn--primary`, `'danger'` adds
4486
- `.twm-btn--danger` — that also carries `.twm-detail-header__btn` and
4487
- `.twm-detail-header__btn--<variant>`. The unprefixed `detail-header__badge` and
4488
- `detail-header__btn--<variant>` it emitted before, which no FlexDesk rule styled, are
4489
- gone.
4490
-
4491
- `@flexdesk/editor`'s components keep the class names they were extracted with, and
4492
- `flexdesk.css` styles them: `SplitPaneContainer` (`.split-pane-container`,
4493
- `.split-pane`, a 4px `.split-pane-divider` with a 10px hit target),
4494
- `EditorPane` (`.editor-pane`, `.editor-pane__body`, `.notebook-editor-container`)
4495
- and `NotebookSearchBar` (`.nb-search-bar*`). `setPanes()` adds `.split-pane` to the
4496
- elements you hand it and leaves their own classes alone (it used to replace them,
4497
- which stripped `.editor-pane` off an `EditorPane`).
4498
-
4499
- One exception to the prefix: `HelpModal` (`@flexdesk/widgets`) keeps its older
4500
- `help-` classes (`help-modal-overlay`, `help-modal`, `help-index`, …), and
4501
- `flexdesk.css` styles them — a centred dialog on a dimmed overlay, drawn over
4502
- managed windows. `.help-article` holds your provider's rendered HTML and styles
4503
- its plain elements (headings, code, tables, lists).
4504
-
4505
- Icons are rendered by applying the `material-symbols-outlined` class. FlexDesk does
4506
- not ship the font — supply it from Google Fonts or self-host. Without it, icons fall
4507
- back to their text names and nothing breaks.
4508
-
4509
- ## Build
4510
-
4511
- ```bash
4512
- npm install
4513
- npm run build # -> dist/
4514
- ```
4515
-
4516
- `dist/` is committed. The packaging decision is *prebuilt dist*: this repo owns the
4517
- build so that a consumer with no bundler can load the library through a plain import
4518
- map. See `.gitignore` — it says so there too, because it is the kind of thing someone
4519
- "tidies up" later.
4520
-
4521
- ### The `development` condition resolves in the installed package (0.17.1)
4522
-
4523
- Every JavaScript entry in the `exports` map has two conditions: `import`, the
4524
- prebuilt bundle under `dist/`, and `development`, the source barrel at the
4525
- package root (`core.js`, `host.js`, `wm.js`, `widgets.js`, `charts.js`,
4526
- `tiles.js`, `editor.js`, `canvas.js`, `flow.js`), which re-exports `src/`.
4527
- Up to 0.17.0 those nine barrels were missing from `files`, so the published
4528
- package did not contain them and a resolver that took the `development` branch
4529
- (`require.resolve` under `--conditions=development`, for one) got a path to
4530
- nothing. From 0.17.1 they ship, beside the `src/` they import.
4531
-
4532
- Nothing about which file an `import` resolves to has changed: `import` is
4533
- listed first, so an ES import still gets `dist/`, with or without the
4534
- `development` condition.
41
+ | [Quickstart](docs/quickstart.md) | Install, boot a shell, put a table in a tile |
42
+ | [Components](docs/README.md#components) | One page per component |
43
+ | [Concepts](docs/README.md#concepts) | The host port, domain neutrality, theming, packaging |
44
+ | [Best practices](docs/best-practices.md) | The rules the reference states, in one place |
45
+ | [Changelog](CHANGELOG.md) | What changed in each release |
4535
46
 
4536
- A suite (`tests/exports_packed.test.mjs`) runs `npm pack --dry-run` and fails
4537
- if any target of any condition in the `exports` map is not in the tarball.
47
+ `demo/` holds a runnable standalone demo. The [quickstart](docs/quickstart.md#see-the-full-demo) says how to open it.
4538
48
 
4539
49
  ## Licence
4540
50