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.
- package/CHANGELOG.md +100 -63
- package/README.md +27 -4517
- package/css/base.css +18 -1
- package/css/flexdesk.css +18 -1
- package/dist/{chunk-4A7SJC3T.js → chunk-RKNFBX5K.js} +15 -4
- package/dist/{chunk-4A7SJC3T.js.map → chunk-RKNFBX5K.js.map} +2 -2
- package/dist/flexdesk.css +18 -1
- package/dist/tiles.js +1 -1
- package/dist/widgets.js +59 -26
- package/dist/widgets.js.map +2 -2
- package/docs/README.md +97 -0
- package/docs/best-practices.md +45 -0
- package/docs/components/charts.md +211 -0
- package/docs/components/command-palette.md +102 -0
- package/docs/components/conversation.md +72 -0
- package/docs/components/data-table-grid.md +328 -0
- package/docs/components/data-table.md +596 -0
- package/docs/components/editor.md +22 -0
- package/docs/components/flow-canvas-editor.md +482 -0
- package/docs/components/flow-editors.md +176 -0
- package/docs/components/flow-lane-editor.md +165 -0
- package/docs/components/flow-outline-editor.md +176 -0
- package/docs/components/forms-and-dialogs.md +294 -0
- package/docs/components/input-fields.md +72 -0
- package/docs/components/keymap.md +143 -0
- package/docs/components/layout-widgets.md +163 -0
- package/docs/components/markdown-and-outline.md +106 -0
- package/docs/components/menus.md +86 -0
- package/docs/components/notifications.md +194 -0
- package/docs/components/panels-and-desktops.md +127 -0
- package/docs/components/record-list-and-fact-table.md +128 -0
- package/docs/components/settings-view.md +112 -0
- package/docs/components/shell.md +189 -0
- package/docs/components/slide-out-panel.md +68 -0
- package/docs/components/tiles.md +210 -0
- package/docs/components/wizard.md +54 -0
- package/docs/concepts/building-and-packaging.md +33 -0
- package/docs/concepts/domain-neutrality.md +110 -0
- package/docs/concepts/host-port.md +137 -0
- package/docs/concepts/packages-and-entry-points.md +32 -0
- package/docs/concepts/peer-dependencies.md +42 -0
- package/docs/concepts/state-guard.md +58 -0
- package/docs/concepts/styling.md +47 -0
- package/docs/quickstart.md +179 -0
- package/package.json +2 -1
- package/src/ui/components/notification_history.js +17 -24
- package/src/ui/components/slide_out_panel.js +14 -3
- package/src/ui/notification_center.js +41 -3
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
3669
|
-
| `
|
|
3670
|
-
| `
|
|
3671
|
-
| `
|
|
3672
|
-
| `
|
|
3673
|
-
| `
|
|
3674
|
-
| `
|
|
3675
|
-
| `
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
4154
|
-
|
|
|
4155
|
-
|
|
|
4156
|
-
|
|
|
4157
|
-
|
|
|
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
|
-
|
|
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
|
|