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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Tabs, section stacks and scrollbars
|
|
2
|
+
|
|
3
|
+
> **Package:** `@flexdesk/widgets` · [All docs](../README.md)
|
|
4
|
+
|
|
5
|
+
`createTabs` draws one tab strip keyed by your own ids, with or without the panes behind it. `createSectionStack` draws inspector-style titled sections in a column, each opened and closed from its header. `installAutoScrollbars` replaces the browser's scrollbars with thin overlay bars.
|
|
6
|
+
|
|
7
|
+
## Scrollbars
|
|
8
|
+
|
|
9
|
+
`installAutoScrollbars(root)` (from `@flexdesk/widgets`) replaces the browser's
|
|
10
|
+
scrollbars under `root` with thin overlay bars that show while an element is
|
|
11
|
+
hovered or scrolling and fade out afterwards. Native bars are hidden, so no
|
|
12
|
+
element reserves layout width for one. The overlay bars live in one fixed
|
|
13
|
+
layer, so a container that redraws its contents cannot lose its bar, and
|
|
14
|
+
textareas get one too. Containers that already use `installOverlayScrollbar`
|
|
15
|
+
keep their own.
|
|
16
|
+
|
|
17
|
+
`installCollapsibleScrollbars(root = document.body)` gives every
|
|
18
|
+
`.twm-collapsible-content.visible` section under `root` a vertical overlay
|
|
19
|
+
bar: the ones already visible at once, and the ones mounted or expanded
|
|
20
|
+
later. It watches `root` only and returns a disconnect. (It used to take no
|
|
21
|
+
argument and look for a `#twm-fixed-200` that nothing created, so visible
|
|
22
|
+
sections got no bar until a class changed — BUG-0006.)
|
|
23
|
+
|
|
24
|
+
## A pane at the window's edge keeps its bar (0.17.4)
|
|
25
|
+
|
|
26
|
+
An overlay bar whose track is covered (a popover, a menu, a dialog over it)
|
|
27
|
+
is not drawn. The window's own resize handles are not cover: the 6px
|
|
28
|
+
`.twm-resize-handle` strips and corners that `WindowChromeController` lays
|
|
29
|
+
along the edges of a non-maximized window are skipped when `barCovered()`
|
|
30
|
+
looks at what sits over the track, as the overlay layer's own bars are. A
|
|
31
|
+
pane flush against the right or bottom edge shows its bar on hover and while
|
|
32
|
+
scrolling, and a popover over that pane still hides it, handle or not.
|
|
33
|
+
|
|
34
|
+
Until 0.17.4 the right strip (z-index 9999) counted as covering every pane
|
|
35
|
+
at the right edge of a window that was not maximized, so those panes had no
|
|
36
|
+
scrollbar at all (BUG-0036). `tests/autoscroll_resize_handle_not_cover.test.mjs`
|
|
37
|
+
holds it.
|
|
38
|
+
|
|
39
|
+
## A stack of collapsible sections: `createSectionStack` (0.17.0)
|
|
40
|
+
|
|
41
|
+
Inspector-style panel content: titled sections in a column, each opened and
|
|
42
|
+
closed from its header. It is built from the tree's `.twm-collapsible-*`
|
|
43
|
+
pieces plus `.twm-section-stack*` (`@flexdesk/widgets`).
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
const stack = createSectionStack(panel, {
|
|
47
|
+
sections: [
|
|
48
|
+
{ id: 'notes', title: 'Notes', icon: 'edit_note', content: notesEl },
|
|
49
|
+
{ id: 'chat', title: 'Assistant', fill: true,
|
|
50
|
+
actions: [{ icon: 'delete', label: 'Clear', onClick: (id, event) => clear() }] },
|
|
51
|
+
{ id: 'params', title: 'Parameters', defaultExpanded: false },
|
|
52
|
+
],
|
|
53
|
+
key: 'inspector.sections',
|
|
54
|
+
stateStore: { read: (key) => saved[key], write: (key, map) => { saved[key] = map; } },
|
|
55
|
+
});
|
|
56
|
+
stack.body('params').appendChild(paramsEl);
|
|
57
|
+
const off = stack.on('toggled', ({ id, expanded, source }) => { /* … */ });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **The header toggles, its actions do not.** A click anywhere on a header
|
|
61
|
+
opens or closes the section and turns its arrow. A click on one of its
|
|
62
|
+
`actions` runs that action's `onClick(sectionId, event)` and nothing else.
|
|
63
|
+
The toggle is a `<button>`, so Enter and Space work.
|
|
64
|
+
- **`defaultExpanded`** is `true` unless a section says `false`.
|
|
65
|
+
- **One `fill` section** takes the height the others leave, and its body is
|
|
66
|
+
what scrolls (`min-height: 0`). A long conversation scrolls inside its
|
|
67
|
+
section and the panel stays the size of the window. The stack is
|
|
68
|
+
`height: 100%` of its container, so give the container a height. A closed
|
|
69
|
+
fill section is only its header.
|
|
70
|
+
- **Remembering is injected.** `stateStore` is `{ read(key), write(key, map) }`.
|
|
71
|
+
`map` is `{ [sectionId]: expanded }`, and it is written on every open and
|
|
72
|
+
close under `key`. `read` may return a promise. Until it settles the
|
|
73
|
+
sections show their defaults; a section somebody toggled in the meantime
|
|
74
|
+
keeps that choice, and that toggle is written once the read has settled,
|
|
75
|
+
over the stored states of the sections nobody touched. `stack.ready` resolves once the read has been applied. A
|
|
76
|
+
store that throws, rejects or returns anything other than a map leaves every
|
|
77
|
+
section on its `defaultExpanded`. Without a store nothing is remembered.
|
|
78
|
+
- **`setVisible(id, false)`** takes a section off the stack and keeps its
|
|
79
|
+
state, so `setVisible(id, true)` brings it back open or closed as it was.
|
|
80
|
+
`isVisible(id)` reads it.
|
|
81
|
+
- `expand(id)`, `collapse(id)`, `toggle(id)` and `isExpanded(id)` work on
|
|
82
|
+
any section, hidden or not. An unknown id throws.
|
|
83
|
+
- **`toggled`** fires on each change with `{ id, expanded, source }`, where
|
|
84
|
+
`source` is `'user'`, `'api'` or `'restore'` (a stored state that arrived
|
|
85
|
+
after mount). `on('toggled', fn)` returns the function that stops listening.
|
|
86
|
+
- **`dispose()`** removes the stack's listener and every node it made, along
|
|
87
|
+
with any `content` you passed in.
|
|
88
|
+
|
|
89
|
+
`tests/section_stack.test.mjs` covers each of these in jsdom. The fill
|
|
90
|
+
layout was also measured in headless Edge.
|
|
91
|
+
|
|
92
|
+
## Tabs: `createTabs` (0.17.0)
|
|
93
|
+
|
|
94
|
+
One tab strip, from `@flexdesk/widgets`, keyed by whatever ids you give it — not
|
|
95
|
+
by file path (`NotebookTabBar`) and not by a bus event (`createBottomPanelTabs`,
|
|
96
|
+
which is now built on it). It is a strip and nothing else, or a strip with the
|
|
97
|
+
panes behind it.
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
const tabs = createTabs(el, {
|
|
101
|
+
items: [
|
|
102
|
+
{ id: 'inputs', label: 'Inputs', icon: 'tune', title: 'Model inputs' },
|
|
103
|
+
{ id: 'results', label: 'Results', badge: { count: 3, level: 'warning' } },
|
|
104
|
+
{ id: 'draft', label: 'Draft', dirty: true, closable: true },
|
|
105
|
+
],
|
|
106
|
+
active: 'inputs',
|
|
107
|
+
onSelect: (id, item) => …,
|
|
108
|
+
onReselect: (id, item) => …, // optional: a click on the tab in front
|
|
109
|
+
onClose: (id) => tabs.update(items.filter((i) => i.id !== id)), // optional
|
|
110
|
+
onReorder: (ids) => …, // optional: tabs drag
|
|
111
|
+
onContextMenu: (id, event, item) => …, // optional
|
|
112
|
+
mountPane: (item, paneEl, tabs) => ({ onShow() {}, destroy() {} }), // optional: strip only
|
|
113
|
+
compact: false,
|
|
114
|
+
label: 'Views',
|
|
115
|
+
});
|
|
116
|
+
tabs.update(nextItems, 'results');
|
|
117
|
+
tabs.setBadge('results', 7, 'alert');
|
|
118
|
+
tabs.setIndicator('inputs', 'info');
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
| Member | Does |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `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 |
|
|
124
|
+
| `select(id)`, `active()`, `has(id)`, `ids()` | The tab in front; `select` answers false for an id it does not have |
|
|
125
|
+
| `setBadge(id, n, level?)` | A count, `99+` above 99, none at zero; `level` is `info`, `warning` or `alert` (`TAB_LEVELS`) |
|
|
126
|
+
| `setIndicator(id, value)` | A dot: a level, `true` for the neutral one, falsy for none |
|
|
127
|
+
| `instance(id)`, `instances()` | The mounted panes' instances |
|
|
128
|
+
| `destroy()` | Destroys the panes, lets the bus go, removes the markup |
|
|
129
|
+
|
|
130
|
+
- **Panes mount on first selection.** The tab in front at creation counts;
|
|
131
|
+
`eager: true` on an item mounts its pane at once — at creation, or in the
|
|
132
|
+
`update` that brings the item in, before the tab in front is settled. Each
|
|
133
|
+
instance hears `onShow()` whenever its tab comes to the front and `destroy()`
|
|
134
|
+
when its item goes.
|
|
135
|
+
- **`onSelect` hears changes**, not the tab chosen at creation. A click on the
|
|
136
|
+
tab already in front is not a change; `onReselect(id, item)` hears it. An
|
|
137
|
+
`update` that leaves no items empties the front, heard as `onSelect(null, null)`.
|
|
138
|
+
- **The bus is opt-in:** pass `eventBus` *and* `selectEvent: 'views:show'` and
|
|
139
|
+
that event selects `payload.id` (or `selectFrom(payload)`). No event name, no
|
|
140
|
+
subscription.
|
|
141
|
+
- **Keys** follow the ARIA tabs pattern with automatic activation:
|
|
142
|
+
ArrowLeft/ArrowRight (wrapping), Home, End; Delete (and a middle click) closes
|
|
143
|
+
a closable tab. Only the tab in front is in the tab order. When an `update`
|
|
144
|
+
removes the tab that has focus (Delete, then your `onClose` drops it), focus
|
|
145
|
+
moves to the tab now in front instead of falling to the page. Focus that the
|
|
146
|
+
update put somewhere else (a pane that focuses itself in `onShow`) stays there.
|
|
147
|
+
- **Overflow scrolls**, never wraps: a vertical wheel moves the strip sideways and
|
|
148
|
+
the tab coming to the front is scrolled into view (`revealInStrip`,
|
|
149
|
+
`wheelStripSideways`, shared with `NotebookTabBar`).
|
|
150
|
+
- **Classes** are BEM under `block` (default `twm-tabs`): `__tabs`, `__tab`,
|
|
151
|
+
`__tab--on`, `__tab--dirty`, `__tab--closable` (it has a close mark), `__icon`,
|
|
152
|
+
`__label`, `__badge`, `__indicator`, `__dirty`, `__close`, `__body`,
|
|
153
|
+
`--compact`, and `twm-tabs-pane`. A dirty tab shows its dot until hovered,
|
|
154
|
+
then its close mark; one with no close mark keeps the dot. Tabs and panes
|
|
155
|
+
carry the id in `data-tab-id` (`dataKey`), as a string; callbacks and `ids()`
|
|
156
|
+
hand back the id as you gave it, so a numeric id stays a number.
|
|
157
|
+
|
|
158
|
+
`tests/tabs.test.mjs` covers it in jsdom; `tests/bottom_panel_tabs.test.mjs`
|
|
159
|
+
runs on the bottom panel built over it. The bottom panel behaves as it did:
|
|
160
|
+
`api.root` is set inside a pane's `mount`, and a click on the tab in front
|
|
161
|
+
re-shows its pane, fires `onSelect` and is remembered, as `select` does. A
|
|
162
|
+
remembered tab that is already in front when `remember.read` answers is shown
|
|
163
|
+
again: its pane hears `onShow` and `onSelect` fires, and nothing is written back.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Markdown and document outline
|
|
2
|
+
|
|
3
|
+
> **Package:** `@flexdesk/widgets` · [All docs](../README.md)
|
|
4
|
+
|
|
5
|
+
`renderMarkdown` turns a CommonMark-ish subset of Markdown into an HTML string. `createDocumentOutline` lists a long document's sections, numbered, marks the one in view while the document scrolls, and goes to one on a click.
|
|
6
|
+
|
|
7
|
+
## Markdown: `renderMarkdown` (0.17.0)
|
|
8
|
+
|
|
9
|
+
`renderMarkdown(source, options)` (`@flexdesk/widgets`) returns an HTML
|
|
10
|
+
string. It covers a CommonMark-ish subset — ATX headings, paragraphs,
|
|
11
|
+
emphasis, strong, `~~strike~~`, code spans, fenced code, block quotes, nested
|
|
12
|
+
ordered and unordered lists, GFM pipe tables, rules, links, images, autolinks,
|
|
13
|
+
hard breaks and `$…$` / `$$…$$` math — and is **safe by default**:
|
|
14
|
+
|
|
15
|
+
- raw HTML is escaped: `<img src=x onerror=alert(1)>` shows as that text.
|
|
16
|
+
`allowHtml: ['kbd', 'br']` lets named tags through, the tag only and never
|
|
17
|
+
its attributes; a tag that runs or embeds anything (`script`, `iframe`,
|
|
18
|
+
`svg`, `img`, …) throws. An allowed tag left open is closed at the end of its
|
|
19
|
+
paragraph.
|
|
20
|
+
- a link goes out only to `http(s):`, `mailto:` or a `#anchor`, always with
|
|
21
|
+
`rel="noopener noreferrer"`; a `javascript:` link (or any other) renders its
|
|
22
|
+
text and no anchor. An image loads from `http(s):` or a relative path, or
|
|
23
|
+
shows its alt text.
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import { renderMarkdown } from '@flexdesk/widgets';
|
|
27
|
+
|
|
28
|
+
el.innerHTML = renderMarkdown(source, {
|
|
29
|
+
highlight: (code, lang) => myHighlighter(code, lang), // null: escaped
|
|
30
|
+
math: undefined, // default: window.katex when present, else the escaped source
|
|
31
|
+
// false: `$` is text; or (tex, { display }) => html | null
|
|
32
|
+
inlineRules: [{ pattern: /\[\[ref:([\w-]+)\]\]/, render: (m, ctx) => `<a href="#${ctx.escape(m[1])}">…</a>` }],
|
|
33
|
+
blockRules: [{ start: /^:::(\w+)/, end: /^:::$/, render: ({ lines, match, closed }, ctx) => '…' }],
|
|
34
|
+
inline: false, // true: inline markup only, no blocks, no <p>
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
What the caller returns — the highlighter's, the math hook's and the rules'
|
|
39
|
+
HTML — is trusted and goes in as it is; a rule answering `null` declines.
|
|
40
|
+
`ctx` gives a rule `escape(text)` and `inline(text)`. Not in the subset:
|
|
41
|
+
setext headings, indented code blocks, reference links, raw HTML blocks.
|
|
42
|
+
|
|
43
|
+
**Streaming.** Every element it opens it closes, so each prefix of a document
|
|
44
|
+
arriving token by token renders to balanced HTML: an open fence is closed at
|
|
45
|
+
the end of the input, and an emphasis or link whose closer has not arrived is
|
|
46
|
+
still text. `tests/markdown.test.mjs` renders every prefix of a document using
|
|
47
|
+
every construct and checks the tags balance; `tests/help_modal_markdown.test.mjs`
|
|
48
|
+
checks jsdom has nothing to repair.
|
|
49
|
+
|
|
50
|
+
**Help topics in markdown.** A `HelpProvider`'s `loadTopic` may now answer
|
|
51
|
+
`{ title, markdown }`; the modal renders it with `renderMarkdown` and the
|
|
52
|
+
provider's optional `markdownOptions`. `{ title, html }` is unchanged:
|
|
53
|
+
pre-rendered, **trusted** HTML, inserted as it is.
|
|
54
|
+
|
|
55
|
+
**A table of contents for a help topic.** A topic with two or more headings,
|
|
56
|
+
markdown or html, gets its contents between the breadcrumb and the article: a
|
|
57
|
+
`createDocumentOutline` (below) of its `h1`–`h6`, numbered, the one in view
|
|
58
|
+
marked as the content pane scrolls, a click that scrolls to its heading. The
|
|
59
|
+
outline points at the heading elements themselves, so your markup needs no
|
|
60
|
+
ids and the modal adds none; a topic with one heading or none shows no
|
|
61
|
+
contents (the empty `.help-content__toc` is `hidden`). Until now the modal's
|
|
62
|
+
documentation promised this and drew nothing. `tests/help_modal_toc.test.mjs`
|
|
63
|
+
drives it in jsdom (BUG-0010).
|
|
64
|
+
|
|
65
|
+
## A document outline: `createDocumentOutline` (0.17.0)
|
|
66
|
+
|
|
67
|
+
A long document's sections, listed and numbered, the one in view marked while
|
|
68
|
+
the document scrolls, and a click that goes to one (in `@flexdesk/widgets`):
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
const outline = createDocumentOutline(sidebarEl, {
|
|
72
|
+
entries: [{ id: 'intro', level: 1, label: 'Introduction' },
|
|
73
|
+
{ id: 'scope', level: 2, label: 'Scope' }],
|
|
74
|
+
numbering: true, // 1, 1.1, 1.2, 2 …
|
|
75
|
+
maxDepth: 3, // deeper entries are drawn at depth 3
|
|
76
|
+
scrollContainer: articleEl, // the element that scrolls; the spy's root
|
|
77
|
+
resolveTarget: (entry, container) => container.querySelector(`[data-section="${entry.id}"]`),
|
|
78
|
+
rootMargin: '0px 0px -70% 0px', // the default: the top 30% is "in view"
|
|
79
|
+
onSelect: (id, entry) => { /* … */ },
|
|
80
|
+
emptyState: 'No headings',
|
|
81
|
+
});
|
|
82
|
+
outline.setEntries(next); // the document has other sections now
|
|
83
|
+
outline.refresh(); // the same sections, re-rendered: resolve again
|
|
84
|
+
outline.setActive('scope'); outline.getActive();
|
|
85
|
+
const off = outline.onActiveChange((id, entry) => { /* … */ });
|
|
86
|
+
outline.dispose();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Where the entries come from is yours: the outline is told `{id, level, label}`
|
|
90
|
+
and, through `resolveTarget` (by default the element in the container whose
|
|
91
|
+
`id` is the entry's), which element each one stands for. Labels are text.
|
|
92
|
+
Numbers are hierarchical, and a skipped level is one step deeper, not two:
|
|
93
|
+
levels 1, 3, 3, 2 are 1, 1.1, 1.2, 1.3.
|
|
94
|
+
|
|
95
|
+
Of the targets an IntersectionObserver rooted at `scrollContainer` reports in
|
|
96
|
+
view, the one listed first is active; with none in view the last one stays. An
|
|
97
|
+
entry whose target does not resolve is listed, but never watched and never
|
|
98
|
+
active, and `setActive` refuses it (`false`). A click marks its entry at once,
|
|
99
|
+
scrolls its target into view (`scrollOnSelect: false` leaves that to
|
|
100
|
+
`onSelect`) and suppresses the spy while the scroll passes the sections in
|
|
101
|
+
between, until the container's `scrollend` or, without one, `scrollEndTimeout`
|
|
102
|
+
(1000 ms). `dispose()` disconnects the observer and clears that timer. The
|
|
103
|
+
indent per level is `--twm-doc-outline-indent` (12px).
|
|
104
|
+
|
|
105
|
+
`tests/document_outline.test.mjs` drives it in jsdom with a hand-fired
|
|
106
|
+
observer.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Menus
|
|
2
|
+
|
|
3
|
+
> **Package:** `@flexdesk/widgets` · [All docs](../README.md)
|
|
4
|
+
|
|
5
|
+
`createMenuBar` draws File / Edit / View menus from one declarative model, with pointer and keyboard behaviour and the WAI-ARIA menubar roles, and plugs into the shell. `ActionDropdown` draws a dropdown of action buttons from a trigger.
|
|
6
|
+
|
|
7
|
+
## A menu bar: `createMenuBar` (0.17.0)
|
|
8
|
+
|
|
9
|
+
File / Edit / View menus from one declarative model, instead of hand-built
|
|
10
|
+
buttons and `showContextMenu` calls. A menu is `{ id, label, items }`; an item
|
|
11
|
+
is `{ id, label, shortcut?, disabled?, checked?, radio?, submenu? }` or
|
|
12
|
+
`{ separator: true }`:
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
import { createMenuBar, MenuBar } from '@flexdesk/widgets';
|
|
16
|
+
|
|
17
|
+
const bar = createMenuBar(document.getElementById('menus'), {
|
|
18
|
+
menus: [
|
|
19
|
+
{ id: 'file', label: 'File', items: [
|
|
20
|
+
{ id: 'open', label: 'Open…', shortcut: 'Ctrl+O' },
|
|
21
|
+
{ id: 'recent', label: 'Open recent', submenu: () => recentItems() }, // rebuilt on every open
|
|
22
|
+
{ separator: true },
|
|
23
|
+
{ id: 'save', label: 'Save', shortcut: 'Ctrl+S', disabled: true },
|
|
24
|
+
] },
|
|
25
|
+
{ id: 'view', label: 'View', items: [
|
|
26
|
+
{ id: 'grid', label: 'Show grid', checked: true }, // a check item
|
|
27
|
+
{ id: 'light', label: 'Light', radio: 'theme', checked: true }, // a radio group
|
|
28
|
+
{ id: 'dark', label: 'Dark', radio: 'theme', checked: false },
|
|
29
|
+
] },
|
|
30
|
+
],
|
|
31
|
+
onAction: (id, { checked }) => run(id, checked),
|
|
32
|
+
});
|
|
33
|
+
bar.setEnabled('save', true); // patches an open menu in place
|
|
34
|
+
bar.setChecked('dark', true); // …and unchecks the rest of the radio group
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`createMenuBar(el, opts)` returns a `MenuBar` (also exported, for
|
|
38
|
+
`instanceof`). Its methods: `open(menuId, { focusItem })`, `close()`,
|
|
39
|
+
`focus()`, `isOpen()`, `isEnabled(id)`, `isChecked(id)`, `setEnabled(id, on)`,
|
|
40
|
+
`setChecked(id, on)`, `commands()` and `dispose()`.
|
|
41
|
+
|
|
42
|
+
- **Pointer.** Clicking a title opens its menu; while one is open, hovering
|
|
43
|
+
another title switches to it. Hovering an item with a submenu opens it.
|
|
44
|
+
- **Keyboard.** Alt (on its own) or F10 focuses the bar. Left/Right move across
|
|
45
|
+
menus, Up/Down/Home/End within one, Right opens a submenu and Left closes it,
|
|
46
|
+
Enter/Space activate. Esc closes one level; the last Esc gives focus back to
|
|
47
|
+
what had it. Disabled items and separators are skipped, and never reach
|
|
48
|
+
`onAction`.
|
|
49
|
+
- **Roles.** The WAI-ARIA menubar pattern: `menubar`, `menu`, `menuitem`,
|
|
50
|
+
`menuitemcheckbox`, `menuitemradio`, with `aria-checked`, `aria-disabled`
|
|
51
|
+
and `aria-expanded`.
|
|
52
|
+
- `shortcut` is a label: the bar binds no key for it.
|
|
53
|
+
- `bar.commands()` returns the model as palette rows (`{ id, label, shortcut,
|
|
54
|
+
context: 'File › Open recent', action }`), so the palette and the bar list the
|
|
55
|
+
same verbs.
|
|
56
|
+
|
|
57
|
+
In a shell, pass the model as `menuBar` and the element as `chrome.menuBar`
|
|
58
|
+
(each needs the other; either alone throws at boot);
|
|
59
|
+
`shell.chrome.menuBar` is that `MenuBar`, and its enabled items follow the palette's
|
|
60
|
+
own `commands` unless `menuBar.palette` is `false`:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const shell = await createShell({
|
|
64
|
+
// …
|
|
65
|
+
menuBar: { menus, onAction },
|
|
66
|
+
chrome: { menuBar: document.getElementById('menus') },
|
|
67
|
+
});
|
|
68
|
+
shell.chrome.menuBar.setEnabled('save', dirty);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The bar is `.twm-menubar` (not the older `.twm-menu-bar` icon strip).
|
|
72
|
+
`tests/menu_bar.test.mjs` drives it in jsdom; `tests/menu_bar_css.test.mjs`
|
|
73
|
+
holds its stylesheet.
|
|
74
|
+
|
|
75
|
+
## Action dropdowns: separators (0.17.0)
|
|
76
|
+
|
|
77
|
+
`new ActionDropdown({ trigger, options, onSelect })` draws one button per
|
|
78
|
+
option. An entry `{ separator: true }` in `options` draws a divider instead: a
|
|
79
|
+
`<div role="separator" class="twm-action-dropdown-separator">` that takes no
|
|
80
|
+
focus, that ArrowUp/ArrowDown pass over, and that never reaches `onSelect`.
|
|
81
|
+
A separator only separates: one at the start or end of the list, or right
|
|
82
|
+
after another, is dropped, so groups that come out empty leave no stray or
|
|
83
|
+
doubled rule. `setOptions` follows the same rules, and an option's
|
|
84
|
+
`data-index` stays its index in `options`.
|
|
85
|
+
|
|
86
|
+
`tests/action_dropdown_separators.test.mjs` drives it in jsdom.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Notifications and progress
|
|
2
|
+
|
|
3
|
+
> **Package:** `@flexdesk/widgets` · [All docs](../README.md)
|
|
4
|
+
|
|
5
|
+
Toasts, a history of them behind a bell, and an alert log. A toast is the moment; the alert log keeps what came in until it is dismissed. Progress bars, lists and phases change in place, and a toast can track a job with one.
|
|
6
|
+
|
|
7
|
+
## Progress: bars, lists and phases (0.17.0)
|
|
8
|
+
|
|
9
|
+
Three classes in `widgets`, each building its own element (`.element`, appended
|
|
10
|
+
to `host` when one is given) and changing it **in place**: an update twenty
|
|
11
|
+
times a second gets the same nodes back every time, so hover, tooltips and the
|
|
12
|
+
fill's transition survive. Names and labels are text.
|
|
13
|
+
|
|
14
|
+
| Class | Does |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `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` |
|
|
17
|
+
| `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 |
|
|
18
|
+
| `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()` |
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
const list = new ProgressList(panel, { items: [] });
|
|
22
|
+
job.on('tick', (workers) => list.setItems(workers.map((w) => ({
|
|
23
|
+
key: w.id, name: w.label, completed: w.done, total: w.total, state: w.state }))));
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`showRunProgressDialog` and `showComputingWindow` take `progress` (`true`, or
|
|
27
|
+
ProgressBar options) and `steps`, fixed when the dialog opens. Their handles
|
|
28
|
+
gain `setProgress(next)` and `setStep(which, opts)`, which throw when the dialog
|
|
29
|
+
was opened without `progress` or `steps` respectively, and **`result`**, a
|
|
30
|
+
Promise that settles once with
|
|
31
|
+
`{ status, message }`: `complete({ ok })` gives `done` or `error` (the bar and
|
|
32
|
+
the phases show it), `cancelled()` gives `cancelled`, and so does **closing the
|
|
33
|
+
dialog while it runs** (by its ×, Escape, `close()`, or for `showComputingWindow`
|
|
34
|
+
opening another). Closing never calls `onCancel`; only the Stop / Cancel button
|
|
35
|
+
does, as before. `showComputingWindow` also gains `complete` and `cancelled`,
|
|
36
|
+
after which it shows a Close button.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
const dlg = showRunProgressDialog({ title: 'Export', steps: ['Prepare', 'Render', 'Write'],
|
|
40
|
+
progress: { showElapsed: true }, onCancel: () => job.stop() });
|
|
41
|
+
dlg.setStep(1); dlg.setProgress(0.4);
|
|
42
|
+
const { status } = await dlg.result; // 'done' | 'error' | 'cancelled'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`tests/progress.test.mjs` holds all of it in jsdom.
|
|
46
|
+
|
|
47
|
+
## Notifications: `NotificationCenter`
|
|
48
|
+
|
|
49
|
+
Toasts, from `toast:show` on the bus (or `toastInfo` / `toastWarn` /
|
|
50
|
+
`toastError` / `toastSuccess`) or `show()`. A payload with `persistent: true`
|
|
51
|
+
stays until its × is clicked. `history` keeps every notification shown, and
|
|
52
|
+
`NotificationHistory` puts it behind a bell in the bottom bar (one entry per toast;
|
|
53
|
+
`subscribe(fn)` hears each one, 0.17.3).
|
|
54
|
+
|
|
55
|
+
*New in 0.11.0:* **`overflow: 'collapse'`.** By default, a toast past
|
|
56
|
+
`maxVisible` (3) pushes the oldest off the screen. With `'collapse'`, the toasts
|
|
57
|
+
on screen stay, and the rest wait behind one **"+N more"** toast whose count
|
|
58
|
+
follows them. Each comes in as a visible one closes. A sticky alert is never
|
|
59
|
+
pushed off unseen. Clicking the summary calls `summaryAction(held)` and emits
|
|
60
|
+
`notification:summary` with `{held}`, so a history panel can open. Every toast
|
|
61
|
+
is in `history` as it arrives, waiting or not.
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
const nc = new NotificationCenter({ eventBus, maxVisible: 3, overflow: 'collapse',
|
|
65
|
+
summaryAction: () => history.open() });
|
|
66
|
+
nc.mount(document.body);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
*New in 0.17.0:* **the toast helpers read no global.** `toastInfo` / `toastWarn` /
|
|
70
|
+
`toastError` / `toastSuccess` emit on the bus of the `NotificationCenter` mounted
|
|
71
|
+
last; its `dispose()` hands them back. `setToastBus(bus)` points them at another
|
|
72
|
+
bus (and returns the one it replaced), `setToastBus(null)` back at the console,
|
|
73
|
+
and `getToastBus()` says which is current. With no bus — before any centre is
|
|
74
|
+
mounted — a helper logs to the console and throws nothing. Until 0.17.0 they
|
|
75
|
+
looked for one application's bus on a window global.
|
|
76
|
+
|
|
77
|
+
### Progress toasts (0.17.0)
|
|
78
|
+
|
|
79
|
+
A toast can track a job. `show({ progress })` takes a 0..1 fraction,
|
|
80
|
+
`{ value, total }`, or `null` for indeterminate, and draws a `ProgressBar` in
|
|
81
|
+
the toast; `status` is a line under the message and `counts` a label beside the
|
|
82
|
+
bar. A progress toast is persistent, has a collapse toggle (or `collapsed: true`),
|
|
83
|
+
and is not closed by a click on its body or on one of its actions while it runs.
|
|
84
|
+
|
|
85
|
+
`update(id, patch)` changes only that toast's nodes: `progress`, `status`,
|
|
86
|
+
`counts`, `collapsed`, `message`, `title`, `description`, `severity`. No other
|
|
87
|
+
toast is redrawn, by an update or by toasts arriving and leaving, so a bar, a
|
|
88
|
+
collapsed state and a hover survive (a toast whose title or description appears
|
|
89
|
+
or goes is rebuilt in its place). `update()` also reaches a toast held behind
|
|
90
|
+
"+N more", and `progress` on a plain toast turns it into a running progress
|
|
91
|
+
toast. The toast **runs until a patch gives it an outcome severity**: `success`
|
|
92
|
+
ends the bar done, `error` / `fatal` error; any other severity only restyles it.
|
|
93
|
+
Once finished the bar keeps that state, whatever severity comes later. A
|
|
94
|
+
`durationMs` in a patch to a toast that is not running (re)starts its
|
|
95
|
+
auto-dismiss, and `0` keeps it until closed.
|
|
96
|
+
|
|
97
|
+
With `overflow: 'collapse'`, a running progress toast is never held behind
|
|
98
|
+
"+N more" and never counted in it (a held toast that starts running through
|
|
99
|
+
`update()` comes on screen at once). It counts toward `maxVisible`, so when the
|
|
100
|
+
screen is full the newest other toast steps back into the queue (first in line
|
|
101
|
+
to return). Once finished it folds like any other toast. With `'drop'`, the
|
|
102
|
+
oldest toast that is not a running job leaves.
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
const id = nc.show({ title: 'Batch', message: 'Running', progress: 0, counts: '0 / 200',
|
|
106
|
+
actions: [{ label: 'Stop', handler: () => job.stop() }] });
|
|
107
|
+
job.on('tick', (done) => nc.update(id, { progress: { value: done, total: 200 },
|
|
108
|
+
counts: `${done} / 200`, status: job.current }));
|
|
109
|
+
job.on('end', () => nc.update(id, { progress: 1, severity: 'success', message: 'Done', durationMs: 3000 }));
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`tests/notification_progress.test.mjs` holds it in jsdom.
|
|
113
|
+
|
|
114
|
+
### Toast cards are styled and clickable (0.17.2)
|
|
115
|
+
|
|
116
|
+
A toast card is `.twm-notification` with `.twm-notification--<severity>`
|
|
117
|
+
(`info`, `success`, `warn`, `error`, `fatal`), and the "+N more" summary is
|
|
118
|
+
`.twm-notification.twm-notification--summary`. `flexdesk.css` gives the card its
|
|
119
|
+
background, a left border in the severity's colour (`--color-success`,
|
|
120
|
+
`--color-warning`, `--color-danger`; info keeps the blue) and the entry
|
|
121
|
+
animation `twm-notification-in`, which the sheet now defines. The card sets
|
|
122
|
+
`pointer-events: auto` over the root's `none`, so its buttons and its
|
|
123
|
+
click-to-close work.
|
|
124
|
+
|
|
125
|
+
Until 0.17.2 the card carried the bare `notification` / `notification--<severity>`
|
|
126
|
+
classes, which no FlexDesk sheet styled: the card had no background and no
|
|
127
|
+
border, and inherited `pointer-events: none`, so its buttons could not be
|
|
128
|
+
clicked. A consumer stylesheet that targeted `.notification` should target
|
|
129
|
+
`.twm-notification` instead. The live region is `.twm-notification-center__live-region`.
|
|
130
|
+
`tests/notification_toast_styles.test.mjs` holds the classes, the cascade and
|
|
131
|
+
the sheets.
|
|
132
|
+
|
|
133
|
+
### One toast, one history entry, one unread (0.17.3)
|
|
134
|
+
|
|
135
|
+
`history` holds **one entry per toast**, however it was raised: `show()`, the
|
|
136
|
+
`warn()` / `error()` / `info()` / `success()` helpers, `toast:show`,
|
|
137
|
+
`notification:show` or `log:entry`. A toast that leaves (timeout, ×, overflow,
|
|
138
|
+
`clearAll`) marks its own entry `dismissed: true` with the `reason`, and adds
|
|
139
|
+
no entry. **`nc.subscribe(fn)`** calls `fn(entry, center)` once for each entry
|
|
140
|
+
added, after it is in `history` (not for a `skipHistory` toast, and not for a
|
|
141
|
+
dismissal), and answers the unsubscribe; `dispose()` drops every listener.
|
|
142
|
+
|
|
143
|
+
`NotificationHistory` follows the centre through `subscribe()`: every entry
|
|
144
|
+
recorded is one unread on the bell until the popover is opened, also past
|
|
145
|
+
`historyLimit`. It no longer watches the bus for toasts, so its
|
|
146
|
+
`notificationCenter` is a `NotificationCenter` (or anything with `history` and
|
|
147
|
+
`subscribe`).
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const off = nc.subscribe((entry) => console.log('recorded', entry.severity, entry.message));
|
|
151
|
+
nc.warn('Disk almost full'); // the bell says 1
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Until 0.17.3 the bell counted only on bus events, so a toast raised through
|
|
155
|
+
`show()` / `warn()` / `error()` / `info()` never raised its badge, and a dismissal
|
|
156
|
+
was pushed as a second entry: a toast that timed out was in `history` twice
|
|
157
|
+
and counted twice. `tests/notification_history_follows_center.test.mjs` holds it.
|
|
158
|
+
|
|
159
|
+
### Alerts with history: a log, a pane, a tab host and a bell (0.14.0)
|
|
160
|
+
|
|
161
|
+
A toast is the moment. The **alert log** keeps what came in until it is
|
|
162
|
+
dismissed, and four pieces draw it. Each works without the others:
|
|
163
|
+
|
|
164
|
+
| Piece | Entry point | Does |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `createAlertLog({ source, limit })` | `widgets` | Entries `{id, at, level, title, body?, ref?, read}`, newest first; `add`, `receive`, `markRead`, `dismiss`, `unreadCount`, `worstUnread`, `subscribe`, `loadMore` |
|
|
167
|
+
| `localSource({ read, write })` | `widgets` | Keeps the whole log in one stored value (localStorage, a workspace file) |
|
|
168
|
+
| `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* |
|
|
169
|
+
| `createBottomPanelTabs(el, { panes, remember, store, eventBus })` | `wm` | The `.twm-bp` tab strip, on [`createTabs`](layout-widgets.md#tabs-createtabs-0170): `select`, `setBadge(id, n, level)`, `bottompanel:show {tab}` |
|
|
170
|
+
| `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 |
|
|
171
|
+
|
|
172
|
+
A **source** is where entries live: `load({offset, limit})` answers
|
|
173
|
+
`{entries, more?, unread?}`, plus `markRead(ids | null)` and, optionally,
|
|
174
|
+
`dismiss(ids | null)`. Without `dismiss`, the pane offers no Dismiss or Clear.
|
|
175
|
+
Levels are `info`, `warning` and `alert`; `warn`, `error` and `danger` are read as
|
|
176
|
+
those.
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
const log = createAlertLog({ source: {
|
|
180
|
+
load: ({ offset, limit }) => api.notifications({ offset, limit }),
|
|
181
|
+
markRead: (ids) => (ids ? Promise.all(ids.map(api.readNotification)) : api.readAll()),
|
|
182
|
+
} });
|
|
183
|
+
const tabs = createBottomPanelTabs(panelEl, { eventBus, panes: [
|
|
184
|
+
{ id: 'alerts', label: 'Alerts', eager: true,
|
|
185
|
+
mount: (el, store, api) => mountAlertsPane(el, { log, onOpen: openRef,
|
|
186
|
+
onBadge: ({ unread, level }) => api.setBadge('alerts', unread, level) }) },
|
|
187
|
+
] });
|
|
188
|
+
new NotificationHistory({ notificationCenter: nc, eventBus, alertLog: log, mountPoint: statusBarEl }).mount();
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
An eager pane, and the first one shown, mount inside `createBottomPanelTabs`
|
|
192
|
+
before it returns. Reach the host through `mount`'s `api` argument, not the
|
|
193
|
+
variable being assigned. Without `alertLog` and `mountPoint`, `NotificationHistory`
|
|
194
|
+
is the bell it was.
|