flexdesk 0.17.2 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -63
  2. package/README.md +27 -4517
  3. package/css/base.css +18 -1
  4. package/css/flexdesk.css +18 -1
  5. package/dist/{chunk-4A7SJC3T.js → chunk-RKNFBX5K.js} +15 -4
  6. package/dist/{chunk-4A7SJC3T.js.map → chunk-RKNFBX5K.js.map} +2 -2
  7. package/dist/flexdesk.css +18 -1
  8. package/dist/tiles.js +1 -1
  9. package/dist/widgets.js +59 -26
  10. package/dist/widgets.js.map +2 -2
  11. package/docs/README.md +97 -0
  12. package/docs/best-practices.md +45 -0
  13. package/docs/components/charts.md +211 -0
  14. package/docs/components/command-palette.md +102 -0
  15. package/docs/components/conversation.md +72 -0
  16. package/docs/components/data-table-grid.md +328 -0
  17. package/docs/components/data-table.md +596 -0
  18. package/docs/components/editor.md +22 -0
  19. package/docs/components/flow-canvas-editor.md +482 -0
  20. package/docs/components/flow-editors.md +176 -0
  21. package/docs/components/flow-lane-editor.md +165 -0
  22. package/docs/components/flow-outline-editor.md +176 -0
  23. package/docs/components/forms-and-dialogs.md +294 -0
  24. package/docs/components/input-fields.md +72 -0
  25. package/docs/components/keymap.md +143 -0
  26. package/docs/components/layout-widgets.md +163 -0
  27. package/docs/components/markdown-and-outline.md +106 -0
  28. package/docs/components/menus.md +86 -0
  29. package/docs/components/notifications.md +194 -0
  30. package/docs/components/panels-and-desktops.md +127 -0
  31. package/docs/components/record-list-and-fact-table.md +128 -0
  32. package/docs/components/settings-view.md +112 -0
  33. package/docs/components/shell.md +189 -0
  34. package/docs/components/slide-out-panel.md +68 -0
  35. package/docs/components/tiles.md +210 -0
  36. package/docs/components/wizard.md +54 -0
  37. package/docs/concepts/building-and-packaging.md +33 -0
  38. package/docs/concepts/domain-neutrality.md +110 -0
  39. package/docs/concepts/host-port.md +137 -0
  40. package/docs/concepts/packages-and-entry-points.md +32 -0
  41. package/docs/concepts/peer-dependencies.md +42 -0
  42. package/docs/concepts/state-guard.md +58 -0
  43. package/docs/concepts/styling.md +47 -0
  44. package/docs/quickstart.md +179 -0
  45. package/package.json +2 -1
  46. package/src/ui/components/notification_history.js +17 -24
  47. package/src/ui/components/slide_out_panel.js +14 -3
  48. package/src/ui/notification_center.js +41 -3
  49. package/src/ui/utils/auto_scrollbars.js +16 -2
@@ -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.