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,58 @@
|
|
|
1
|
+
# State guard
|
|
2
|
+
|
|
3
|
+
> **Package:** `@flexdesk/core` · [All docs](../README.md)
|
|
4
|
+
|
|
5
|
+
## The state guard: who wrote that attribute (0.17.0)
|
|
6
|
+
|
|
7
|
+
`StateGuardService` blocks (`strict`) or reports (`warn`) `data-*` writes that
|
|
8
|
+
are on no allow-list. A module override lets one module write a key nobody
|
|
9
|
+
else may:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { StateGuardService, DEFAULT_STATE_GUARD_CONFIG } from '@flexdesk/core';
|
|
13
|
+
|
|
14
|
+
const guard = new StateGuardService({ eventBus, config: { ...DEFAULT_STATE_GUARD_CONFIG, mode: 'strict' } });
|
|
15
|
+
guard.allow('src/app/my_widget.js', ['data-foo']); // a literal substring of the module, or a RegExp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A write is attributed to the frame that made it: the URL or path of the file
|
|
19
|
+
that called `setAttribute`, `removeAttribute` or `dataset`, without line and
|
|
20
|
+
column, whatever your directory layout. A FlexDesk widget's write is the
|
|
21
|
+
widget's, not that of the app module that called the widget. To attribute
|
|
22
|
+
writes further up the stack instead, pass `moduleFramePattern`: the first
|
|
23
|
+
frame from the writer up whose location matches names the module, by the
|
|
24
|
+
pattern's first capture group if it has one.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
config: { ...DEFAULT_STATE_GUARD_CONFIG, moduleFramePattern: /(src\/app\/[^/]+\.js)/ }
|
|
28
|
+
// a write made inside @flexdesk/widgets on behalf of src/app/my_widget.js
|
|
29
|
+
// is now attributed to 'src/app/my_widget.js'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The defaults are `mode: 'warn'`, no module overrides and no frame pattern.
|
|
33
|
+
Before 0.17.0 the module was only found under a `js_new/` path, the defaults
|
|
34
|
+
carried one application's override, and the mode read one application's debug
|
|
35
|
+
global; choose strict mode in your own code.
|
|
36
|
+
|
|
37
|
+
### The defaults cover FlexDesk's own keys (0.17.0)
|
|
38
|
+
|
|
39
|
+
`DEFAULT_STATE_GUARD_CONFIG` includes every data key FlexDesk's own widgets
|
|
40
|
+
write, so the shell, dialogs, forms, tooltips, tiles and flow editors run under
|
|
41
|
+
`mode: 'strict'` with no violation. The list also carries some keys that
|
|
42
|
+
FlexDesk itself does not write. Add your application's keys on top:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
const guard = new StateGuardService({ eventBus, logger, config: {
|
|
46
|
+
...DEFAULT_STATE_GUARD_CONFIG,
|
|
47
|
+
allowedDatasetKeys: [...DEFAULT_STATE_GUARD_CONFIG.allowedDatasetKeys, 'order-id'],
|
|
48
|
+
} });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
An allow-list key may be spelled either way: `orderId` and `order-id` are the
|
|
52
|
+
same key, in the allow-list and in module overrides
|
|
53
|
+
(`allow(pattern, attributes, { datasetKeys })` and `config.moduleOverrides`)
|
|
54
|
+
alike. camelCase maps to kebab-case as the DOM does for ordinary keys: each
|
|
55
|
+
capital letter becomes `-` plus its lower-case form. So `rowID` is `row-i-d`
|
|
56
|
+
(what `dataset.rowID` writes), not `rowid`; to allow `data-id`, list `id`.
|
|
57
|
+
Before 0.17.0 a camelCase entry never matched, and some entries named keys no
|
|
58
|
+
element carries.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Theming and CSS
|
|
2
|
+
|
|
3
|
+
> **Stylesheets:** `flexdesk/css/tokens`, `flexdesk/css/reset`, `flexdesk/css` · [All docs](../README.md)
|
|
4
|
+
|
|
5
|
+
## Styling
|
|
6
|
+
|
|
7
|
+
Three stylesheets, and that is the whole CSS contract:
|
|
8
|
+
|
|
9
|
+
| | Specifier | Resolves to | |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| `dist/tokens.css` | `flexdesk/css/tokens` | `css/tokens.css` | Every rule reads `var(--…)` from here. Swap this one file and the shell restyles. |
|
|
12
|
+
| `dist/reset.css` | `flexdesk/css/reset` | `dist/reset.css` | The reset. |
|
|
13
|
+
| `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). |
|
|
14
|
+
|
|
15
|
+
A bundler, an import-map generator or `require.resolve` reaches each one by its
|
|
16
|
+
specifier, through the package's `exports` map, which closes every other path
|
|
17
|
+
into the package. The map sends `flexdesk/css/tokens` to the source
|
|
18
|
+
`css/tokens.css`, not `dist/tokens.css`. The two are identical in a release,
|
|
19
|
+
since `dist/` is rebuilt in the release commit, but can differ on a working
|
|
20
|
+
checkout. `flexdesk/css/reset` is missing from 0.16.1 and earlier,
|
|
21
|
+
where it fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`.
|
|
22
|
+
|
|
23
|
+
`createDetailHeader` follows the prefix too: a badge is a `.twm-detail-header__badge`
|
|
24
|
+
pill (a badge's `className` is added beside it, to recolour it), and an action is a
|
|
25
|
+
`.twm-btn` — `variant: 'primary'` adds `.twm-btn--primary`, `'danger'` adds
|
|
26
|
+
`.twm-btn--danger` — that also carries `.twm-detail-header__btn` and
|
|
27
|
+
`.twm-detail-header__btn--<variant>`. The unprefixed `detail-header__badge` and
|
|
28
|
+
`detail-header__btn--<variant>` it emitted before, which no FlexDesk rule styled, are
|
|
29
|
+
gone.
|
|
30
|
+
|
|
31
|
+
`@flexdesk/editor`'s components keep the class names they were extracted with, and
|
|
32
|
+
`flexdesk.css` styles them: `SplitPaneContainer` (`.split-pane-container`,
|
|
33
|
+
`.split-pane`, a 4px `.split-pane-divider` with a 10px hit target),
|
|
34
|
+
`EditorPane` (`.editor-pane`, `.editor-pane__body`, `.notebook-editor-container`)
|
|
35
|
+
and `NotebookSearchBar` (`.nb-search-bar*`). `setPanes()` adds `.split-pane` to the
|
|
36
|
+
elements you hand it and leaves their own classes alone (it used to replace them,
|
|
37
|
+
which stripped `.editor-pane` off an `EditorPane`).
|
|
38
|
+
|
|
39
|
+
One exception to the prefix: `HelpModal` (`@flexdesk/widgets`) keeps its older
|
|
40
|
+
`help-` classes (`help-modal-overlay`, `help-modal`, `help-index`, …), and
|
|
41
|
+
`flexdesk.css` styles them — a centred dialog on a dimmed overlay, drawn over
|
|
42
|
+
managed windows. `.help-article` holds your provider's rendered HTML and styles
|
|
43
|
+
its plain elements (headings, code, tables, lists).
|
|
44
|
+
|
|
45
|
+
Icons are rendered by applying the `material-symbols-outlined` class. FlexDesk does
|
|
46
|
+
not ship the font — supply it from Google Fonts or self-host. Without it, icons fall
|
|
47
|
+
back to their text names and nothing breaks.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Quickstart
|
|
2
|
+
|
|
3
|
+
From an empty folder to a shell with a table in it. You need a text editor and a static file server. There is no bundler, no framework and no build step.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install flexdesk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The package ships a prebuilt `dist/`. Copy it into your site (the examples below call the copy `vendor/flexdesk/`) and load it through an import map:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
mkdir -p vendor && cp -r node_modules/flexdesk/dist vendor/flexdesk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Under a bundler you need none of that. Import the entries as `flexdesk/core`, `flexdesk/host`, `flexdesk/wm`, `flexdesk/widgets` (and `flexdesk/charts`, `flexdesk/tiles`, `flexdesk/editor`, `flexdesk/canvas`, `flexdesk/flow`), and the stylesheets as `flexdesk/css/tokens`, `flexdesk/css/reset` and `flexdesk/css`. The rest of these docs write the entries as `@flexdesk/wm` and so on, which is the name the import map below gives them.
|
|
18
|
+
|
|
19
|
+
## The page
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<!doctype html>
|
|
23
|
+
<html lang="en">
|
|
24
|
+
<head>
|
|
25
|
+
<meta charset="utf-8" />
|
|
26
|
+
<title>My first shell</title>
|
|
27
|
+
<script type="importmap">
|
|
28
|
+
{ "imports": {
|
|
29
|
+
"@flexdesk/core": "./vendor/flexdesk/core.js",
|
|
30
|
+
"@flexdesk/host": "./vendor/flexdesk/host.js",
|
|
31
|
+
"@flexdesk/wm": "./vendor/flexdesk/wm.js",
|
|
32
|
+
"@flexdesk/widgets": "./vendor/flexdesk/widgets.js"
|
|
33
|
+
} }
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<!-- Optional: the icon font. Without it, icons show their text names. -->
|
|
37
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" />
|
|
38
|
+
|
|
39
|
+
<!-- The whole CSS contract, in this order. -->
|
|
40
|
+
<link href="./vendor/flexdesk/tokens.css" rel="stylesheet" />
|
|
41
|
+
<link href="./vendor/flexdesk/reset.css" rel="stylesheet" />
|
|
42
|
+
<link href="./vendor/flexdesk/flexdesk.css" rel="stylesheet" />
|
|
43
|
+
|
|
44
|
+
<style>
|
|
45
|
+
html, body { height: 100%; margin: 0; }
|
|
46
|
+
body {
|
|
47
|
+
background: var(--surface-0);
|
|
48
|
+
color: var(--text-primary);
|
|
49
|
+
font-family: var(--font-family-sans);
|
|
50
|
+
display: flex; flex-direction: column;
|
|
51
|
+
}
|
|
52
|
+
#nav { flex: 0 0 auto; }
|
|
53
|
+
#app { flex: 1 1 0; min-height: 0; position: relative; } /* the shell fills this */
|
|
54
|
+
</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body class="twm-body">
|
|
57
|
+
<div id="nav"></div>
|
|
58
|
+
<div id="app"></div>
|
|
59
|
+
<script type="module" src="./main.js"></script>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The three stylesheets are the whole CSS contract. `tokens.css` defines every colour, space and font the rules read, `reset.css` is the reset, and `flexdesk.css` is the library. It reads the tokens but does not define them, so without `tokens.css` its colours, spacing and fonts are undefined. See [Theming and CSS](concepts/styling.md#styling).
|
|
65
|
+
|
|
66
|
+
FlexDesk does not ship the icon font (Material Symbols Outlined). Load it from Google Fonts or self-host it. Nothing else breaks without it.
|
|
67
|
+
|
|
68
|
+
## Hello, shell
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
import { createShell, createTaxonomy, createEntityCatalog } from '@flexdesk/wm';
|
|
72
|
+
import { createHost } from '@flexdesk/host';
|
|
73
|
+
import { DataTable } from '@flexdesk/widgets';
|
|
74
|
+
|
|
75
|
+
const store = new Map();
|
|
76
|
+
|
|
77
|
+
const shell = await createShell({
|
|
78
|
+
root: document.getElementById('app'),
|
|
79
|
+
|
|
80
|
+
// YOUR ontology. FlexDesk has none.
|
|
81
|
+
taxonomy: createTaxonomy({
|
|
82
|
+
root: 'cosmos',
|
|
83
|
+
kinds: {
|
|
84
|
+
cosmos: { label: 'Cosmos', icon: 'public', isTopNav: true, sources: ['planet'] },
|
|
85
|
+
planet: { label: 'Planet', icon: 'circle' },
|
|
86
|
+
moon: {
|
|
87
|
+
label: 'Moon',
|
|
88
|
+
// "jupiter/europa" — your naming convention, not the library's.
|
|
89
|
+
ancestors: ({ id }) => {
|
|
90
|
+
const i = String(id).indexOf('/');
|
|
91
|
+
return i > 0 ? [{ kind: 'planet', id: String(id).slice(0, i) }] : [];
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
entities: createEntityCatalog({ sources: [/* what the palette searches */] }),
|
|
98
|
+
content: { planet: (hostEl, props) => { /* render a tile */ } },
|
|
99
|
+
|
|
100
|
+
// Where persistence goes. An in-memory Map is a perfectly good host.
|
|
101
|
+
host: createHost({
|
|
102
|
+
state: {
|
|
103
|
+
read: async (k) => store.get(k) ?? null,
|
|
104
|
+
write: async (k, v) => (store.set(k, v), true),
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
// Chrome is ELEMENTS, not selectors. The library never guesses at your markup.
|
|
109
|
+
chrome: { topNav: document.getElementById('nav') },
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A working version of exactly this — planets and moons, a mock host, no backend —
|
|
114
|
+
lives in `demo/`.
|
|
115
|
+
|
|
116
|
+
Save that as `main.js`. What each part is:
|
|
117
|
+
|
|
118
|
+
- **`taxonomy`** is your ontology: the kinds of thing your application has and how they nest. FlexDesk has none of its own.
|
|
119
|
+
- **`entities`** is what the command palette (Ctrl+K) searches.
|
|
120
|
+
- **`content`** is one factory per kind. It renders that kind into a tile.
|
|
121
|
+
- **`host`** is where persistence goes. An in-memory `Map` is a perfectly good host; [the host port](concepts/host-port.md#the-host-port) covers the real ones.
|
|
122
|
+
- **`chrome`** is the elements the shell paints into, not selectors. The library never guesses at your markup.
|
|
123
|
+
|
|
124
|
+
## Put a table in a tile
|
|
125
|
+
|
|
126
|
+
Give the root kind a content factory that mounts a [DataTable](components/data-table.md). `DataTable` is in `@flexdesk/widgets`, which the first snippet already imports.
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
const PLANETS = [
|
|
130
|
+
['Mercury', '0', '0.39'],
|
|
131
|
+
['Venus', '0', '0.72'],
|
|
132
|
+
['Earth', '1', '1.00'],
|
|
133
|
+
['Mars', '2', '1.52'],
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const planetsTable = (hostEl) => {
|
|
137
|
+
// A tile body is a flex column. Give the table a box that fills it, in normal flow:
|
|
138
|
+
// absolute positioning would let the table escape the tile.
|
|
139
|
+
const box = document.createElement('div');
|
|
140
|
+
box.style.cssText = 'height:100%; display:flex; flex-direction:column; min-height:0;';
|
|
141
|
+
hostEl.appendChild(box);
|
|
142
|
+
DataTable.mount(box, {
|
|
143
|
+
headers: ['Planet', 'Moons', 'Distance (AU)'],
|
|
144
|
+
rows: PLANETS,
|
|
145
|
+
sortable: true,
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// …and in createShell({ … }), in place of the planet factory:
|
|
150
|
+
content: {
|
|
151
|
+
cosmos: planetsTable,
|
|
152
|
+
// The three panels ask for content by these names. Without a factory they show a placeholder.
|
|
153
|
+
'panel:left': (hostEl) => { hostEl.textContent = 'Left panel'; },
|
|
154
|
+
'panel:right': (hostEl) => { hostEl.textContent = 'Right panel'; },
|
|
155
|
+
'panel:bottom': (hostEl) => { hostEl.textContent = 'Bottom panel'; },
|
|
156
|
+
},
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`cosmos` is the root kind and a top-nav kind, so the shell opens it first. Click a header to sort. The placeholder a panel shows when nothing is registered for it reads "no factory registered yet": the registry working, though it looks like a bug.
|
|
160
|
+
|
|
161
|
+
## See the full demo
|
|
162
|
+
|
|
163
|
+
`demo/` in the repository is a complete version of this page: planets and moons, an in-memory host, panels, desktops and the command palette, with no backend. It loads `dist/`, and ES modules do not load from `file://`, so serve the repository root with any static file server and open `/demo/`:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
git clone https://github.com/nor-os/FlexDesk.git
|
|
167
|
+
cd FlexDesk
|
|
168
|
+
python3 -m http.server 8080 # or any static server
|
|
169
|
+
# then open http://localhost:8080/demo/
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The other pages in `demo/` are one-component pages: `datatable_070.html`, `records.html`, `flow_canvas.html`, `flow_outline.html`, `flow_lanes.html`, `flow_kit.html` and `flow_modeler.html`.
|
|
173
|
+
|
|
174
|
+
## Where next
|
|
175
|
+
|
|
176
|
+
- **A component.** Each has a page: [Shell and navigation](components/shell.md), [DataTable](components/data-table.md), [Forms and dialogs](components/forms-and-dialogs.md), and the rest in the [component index](README.md#components).
|
|
177
|
+
- **How the pieces fit.** [Packages and entry points](concepts/packages-and-entry-points.md), the [host port](concepts/host-port.md), [domain neutrality](concepts/domain-neutrality.md) and [theming](concepts/styling.md).
|
|
178
|
+
- **Doing it well.** [Best practices](best-practices.md).
|
|
179
|
+
- **What changed.** The [changelog](../CHANGELOG.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexdesk",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
4
4
|
"description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"canvas.js",
|
|
29
29
|
"flow.js",
|
|
30
30
|
"css",
|
|
31
|
+
"docs",
|
|
31
32
|
"README.md",
|
|
32
33
|
"CHANGELOG.md",
|
|
33
34
|
"LICENSE",
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* NotificationHistory - Bottom bar notification history panel
|
|
3
3
|
*
|
|
4
4
|
* Bell icon in the global-bottom-bar that opens a popover listing past notifications.
|
|
5
|
-
* Reads from NotificationCenter.history
|
|
6
|
-
*
|
|
5
|
+
* Reads from NotificationCenter.history and follows it through
|
|
6
|
+
* `notificationCenter.subscribe()` (0.17.3): every entry the centre records — a
|
|
7
|
+
* toast from `show()`/`warn()`/... or from the bus — is one unread, until the
|
|
8
|
+
* popover is opened. The bus is not watched for toasts.
|
|
7
9
|
*
|
|
8
10
|
* THREE OPTIONS, all additive (FlexDesk STORY-0020); without them it is the bell
|
|
9
11
|
* it always was:
|
|
@@ -34,7 +36,8 @@ export class NotificationHistory {
|
|
|
34
36
|
|
|
35
37
|
this.triggerEl = null;
|
|
36
38
|
this.badgeEl = null;
|
|
37
|
-
|
|
39
|
+
/** Entries recorded since the popover was last opened. */
|
|
40
|
+
this.unreadCount = 0;
|
|
38
41
|
this._popoverCleanup = null;
|
|
39
42
|
this._subscriptions = [];
|
|
40
43
|
}
|
|
@@ -80,10 +83,14 @@ export class NotificationHistory {
|
|
|
80
83
|
return;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
//
|
|
84
|
-
this.
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
// What is in the history at mount is not unread.
|
|
87
|
+
this.unreadCount = 0;
|
|
88
|
+
const unsubscribe = this.notificationCenter?.subscribe?.(() => {
|
|
89
|
+
this.unreadCount += 1;
|
|
90
|
+
this.#updateBadge();
|
|
91
|
+
});
|
|
92
|
+
if (typeof unsubscribe === 'function') this._subscriptions.push(unsubscribe);
|
|
93
|
+
this.#updateBadge();
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
unmount() {
|
|
@@ -99,20 +106,6 @@ export class NotificationHistory {
|
|
|
99
106
|
this.badgeEl = null;
|
|
100
107
|
}
|
|
101
108
|
|
|
102
|
-
#subscribeEvents() {
|
|
103
|
-
if (!this.eventBus) return;
|
|
104
|
-
|
|
105
|
-
// Update badge whenever a new notification comes in
|
|
106
|
-
const badgeUpdate = () => queueMicrotask(() => this.#updateBadge());
|
|
107
|
-
const events = ['toast:show', 'notification:show', 'log:entry',
|
|
108
|
-
'workspace:save:failed', 'workspace:import:failed'];
|
|
109
|
-
|
|
110
|
-
events.forEach(name => {
|
|
111
|
-
const handle = this.eventBus.on(name, badgeUpdate);
|
|
112
|
-
this._subscriptions.push(() => handle?.dispose?.());
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
109
|
/** The Alerts tab, asked for on the bus — or the embedder's own verb. */
|
|
117
110
|
#openAlerts() {
|
|
118
111
|
if (this.onOpen) {
|
|
@@ -126,7 +119,7 @@ export class NotificationHistory {
|
|
|
126
119
|
const historyLen = this.notificationCenter?.history?.length ?? 0;
|
|
127
120
|
const unread = this.alertLog
|
|
128
121
|
? this.alertLog.unreadCount()
|
|
129
|
-
: Math.
|
|
122
|
+
: Math.min(this.unreadCount, historyLen);
|
|
130
123
|
|
|
131
124
|
if (!this.badgeEl) return;
|
|
132
125
|
|
|
@@ -164,7 +157,7 @@ export class NotificationHistory {
|
|
|
164
157
|
this.#closePanel();
|
|
165
158
|
|
|
166
159
|
// Mark all as seen
|
|
167
|
-
this.
|
|
160
|
+
this.unreadCount = 0;
|
|
168
161
|
this.#updateBadge();
|
|
169
162
|
|
|
170
163
|
const panel = document.createElement('div');
|
|
@@ -197,7 +190,7 @@ export class NotificationHistory {
|
|
|
197
190
|
header.querySelector('.twm-notification-history__clear-btn')
|
|
198
191
|
.addEventListener('click', () => {
|
|
199
192
|
this.notificationCenter.history.splice(0);
|
|
200
|
-
this.
|
|
193
|
+
this.unreadCount = 0;
|
|
201
194
|
this.#updateBadge();
|
|
202
195
|
this.#renderList(list);
|
|
203
196
|
});
|
|
@@ -58,7 +58,7 @@ export class SlideOutPanel extends ComponentBase {
|
|
|
58
58
|
header.className = 'twm-slide-out-panel__header';
|
|
59
59
|
|
|
60
60
|
this.#headerIcon = document.createElement('span');
|
|
61
|
-
this.#headerIcon.className = 'material-symbols-outlined';
|
|
61
|
+
this.#headerIcon.className = 'twm-slide-out-panel__icon material-symbols-outlined';
|
|
62
62
|
|
|
63
63
|
this.#headerTitle = document.createElement('span');
|
|
64
64
|
this.#headerTitle.className = 'twm-slide-out-panel__title';
|
|
@@ -206,13 +206,24 @@ export class SlideOutPanel extends ComponentBase {
|
|
|
206
206
|
* engine that predates it), and the width subtracted is the panel's
|
|
207
207
|
* laid-out one, which `min-width` may hold above the requested
|
|
208
208
|
* width. Rounding happens in screen pixels, so at 100% the output is
|
|
209
|
-
* exactly what it always was.
|
|
209
|
+
* exactly what it always was.
|
|
210
|
+
*
|
|
211
|
+
* NEVER WIDER THAN THE HOST (BUG-0035). The right edge is pinned, so a
|
|
212
|
+
* panel wider than its host (700px by default, `min-width: 400px`)
|
|
213
|
+
* reached past the host's left edge across the neighbouring pane. The
|
|
214
|
+
* host's width, in the panel's CSS pixels, goes on the panel as
|
|
215
|
+
* `--twm-slide-out-host-w`, which the stylesheet uses as `max-width`
|
|
216
|
+
* and as the ceiling of the 400px `min-width`; it is set BEFORE the
|
|
217
|
+
* width is read so the read sees the capped layout, and the width is
|
|
218
|
+
* capped here too for an engine that lays nothing out. */
|
|
210
219
|
#trackGeometry = () => {
|
|
211
220
|
if (!this.#open || !this.#panel || !this.#container) return;
|
|
212
221
|
const r = this.#container.getBoundingClientRect();
|
|
213
222
|
const zoom = this.#panel.currentCSSZoom || 1;
|
|
214
|
-
const
|
|
223
|
+
const hostWidth = r.width / zoom;
|
|
215
224
|
const s = this.#panel.style;
|
|
225
|
+
s.setProperty('--twm-slide-out-host-w', `${hostWidth}px`);
|
|
226
|
+
const width = Math.min(this.#panel.offsetWidth || this.#width, hostWidth);
|
|
216
227
|
s.position = 'fixed';
|
|
217
228
|
s.top = `${Math.round(r.top) / zoom}px`;
|
|
218
229
|
s.left = `${Math.round(r.right - width * zoom) / zoom}px`;
|
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
* collapsed state and a hovered button survive. A running progress toast is
|
|
28
28
|
* never held behind "+N more" (and never dropped by 'drop'); it counts toward
|
|
29
29
|
* `maxVisible`, so it takes the place of the newest other toast.
|
|
30
|
+
*
|
|
31
|
+
* History subscribers (0.17.3)
|
|
32
|
+
* ----------------------------
|
|
33
|
+
* `history` holds one entry per toast, whichever way it came (`show()`, the
|
|
34
|
+
* `warn()`/`error()`/... helpers, `toast:show`, `notification:show`,
|
|
35
|
+
* `log:entry`). A toast that leaves marks its own entry `dismissed` with a
|
|
36
|
+
* `reason`; it adds none. `subscribe(fn)` calls `fn(entry, center)` for each
|
|
37
|
+
* entry added, so a history view follows the centre rather than the bus.
|
|
30
38
|
*/
|
|
31
39
|
import { ComponentBase } from './base/component_base.js';
|
|
32
40
|
import { getSetting } from '../core/settings.js';
|
|
@@ -55,6 +63,7 @@ const SUPPRESSED_NAMESPACE_TITLES = new Set(['bootstrap', 'app']);
|
|
|
55
63
|
export class NotificationCenter extends ComponentBase {
|
|
56
64
|
#items;
|
|
57
65
|
#summaryEl;
|
|
66
|
+
#historyListeners;
|
|
58
67
|
|
|
59
68
|
constructor({
|
|
60
69
|
eventBus,
|
|
@@ -90,6 +99,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
90
99
|
/** id -> the drawn toast's nodes, for the toasts on screen. */
|
|
91
100
|
this.#items = new Map();
|
|
92
101
|
this.#summaryEl = null;
|
|
102
|
+
this.#historyListeners = new Set();
|
|
93
103
|
|
|
94
104
|
this.container = null;
|
|
95
105
|
this.rootEl = null;
|
|
@@ -151,6 +161,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
151
161
|
dispose() {
|
|
152
162
|
this.#clearTimers();
|
|
153
163
|
this.#unsubscribeEvents();
|
|
164
|
+
this.#historyListeners.clear();
|
|
154
165
|
// Give the toast helpers back to the console — unless someone has
|
|
155
166
|
// pointed them at another bus since this centre mounted.
|
|
156
167
|
if (this.eventBus && getToastBus() === this.eventBus) setToastBus(null);
|
|
@@ -190,7 +201,7 @@ export class NotificationCenter extends ComponentBase {
|
|
|
190
201
|
if (idx !== -1) {
|
|
191
202
|
const [removed] = this.notifications.splice(idx, 1);
|
|
192
203
|
this.#stopTimer(removed.id);
|
|
193
|
-
this.#
|
|
204
|
+
this.#recordDismissal(removed, 'overflow');
|
|
194
205
|
}
|
|
195
206
|
}
|
|
196
207
|
|
|
@@ -207,13 +218,13 @@ export class NotificationCenter extends ComponentBase {
|
|
|
207
218
|
const h = this.held.findIndex((item) => item.id === notificationId);
|
|
208
219
|
if (h === -1) return false;
|
|
209
220
|
const [gone] = this.held.splice(h, 1);
|
|
210
|
-
this.#
|
|
221
|
+
this.#recordDismissal(gone, reason);
|
|
211
222
|
this.#render();
|
|
212
223
|
return true;
|
|
213
224
|
}
|
|
214
225
|
const [removed] = this.notifications.splice(idx, 1);
|
|
215
226
|
this.#stopTimer(notificationId);
|
|
216
|
-
this.#
|
|
227
|
+
this.#recordDismissal(removed, reason);
|
|
217
228
|
// A place on screen came free: the oldest held-back toast takes it.
|
|
218
229
|
this.#rebalance();
|
|
219
230
|
this.#render();
|
|
@@ -475,12 +486,39 @@ export class NotificationCenter extends ComponentBase {
|
|
|
475
486
|
this.activeTimers.clear();
|
|
476
487
|
}
|
|
477
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Hear of every entry added to `history`, by `show()`, `warn()`/`error()`/
|
|
491
|
+
* `info()`/`success()` and the bus alike: `listener(entry, center)`, once
|
|
492
|
+
* per toast, after the entry is in `history`. A dismissal changes the
|
|
493
|
+
* toast's own entry and is not an entry of its own. Answers the unsubscribe.
|
|
494
|
+
*/
|
|
495
|
+
subscribe(listener) {
|
|
496
|
+
if (typeof listener !== 'function') return () => {};
|
|
497
|
+
this.#historyListeners.add(listener);
|
|
498
|
+
return () => { this.#historyListeners.delete(listener); };
|
|
499
|
+
}
|
|
500
|
+
|
|
478
501
|
#recordHistory(entry) {
|
|
479
502
|
if (entry.skipHistory) return;
|
|
480
503
|
this.history.push(entry);
|
|
481
504
|
if (this.history.length > this.historyLimit) {
|
|
482
505
|
this.history.shift();
|
|
483
506
|
}
|
|
507
|
+
[...this.#historyListeners].forEach((listener) => {
|
|
508
|
+
try {
|
|
509
|
+
listener(entry, this);
|
|
510
|
+
} catch (err) {
|
|
511
|
+
this.logger?.warn?.('twm-notification-center', 'History listener failed', { err });
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** A toast that left marks its own entry: one toast, one entry. */
|
|
517
|
+
#recordDismissal(notification, reason) {
|
|
518
|
+
if (notification.skipHistory) return;
|
|
519
|
+
const at = this.history.findLastIndex((entry) => entry.id === notification.id);
|
|
520
|
+
if (at === -1) return;
|
|
521
|
+
this.history[at] = { ...notification, dismissed: true, reason };
|
|
484
522
|
}
|
|
485
523
|
|
|
486
524
|
/** A progress toast that has not been given an outcome yet. */
|
|
@@ -58,12 +58,15 @@ export function thumbGeometry(scrollSize, clientSize, scrollPos, trackLen) {
|
|
|
58
58
|
*
|
|
59
59
|
* @param {Element} el
|
|
60
60
|
* @param {Element[][]} stacks what `elementsFromPoint` gave at each point,
|
|
61
|
-
* topmost first. The overlay layer's own bars are skipped
|
|
61
|
+
* topmost first. The overlay layer's own bars are skipped, and so are the
|
|
62
|
+
* window's `.twm-resize-handle` edge strips (BUG-0036): they lie over every
|
|
63
|
+
* pane flush against a window edge and are chrome, not cover.
|
|
62
64
|
* @returns {boolean}
|
|
63
65
|
*/
|
|
64
66
|
export function barCovered(el, stacks) {
|
|
67
|
+
const passThrough = (h) => h.closest?.('.twm-autoscroll-layer') || h.closest?.('.twm-resize-handle');
|
|
65
68
|
return stacks.some((stack) => {
|
|
66
|
-
const top = (stack || []).find((h) => !h
|
|
69
|
+
const top = (stack || []).find((h) => !passThrough(h));
|
|
67
70
|
return Boolean(top) && top !== el && !el.contains(top);
|
|
68
71
|
});
|
|
69
72
|
}
|
|
@@ -277,6 +280,16 @@ export function installAutoScrollbars(root = document.body, { exclude = null } =
|
|
|
277
280
|
cancelAnimationFrame(raf);
|
|
278
281
|
raf = requestAnimationFrame(() => { for (const el of managed.keys()) place(el); });
|
|
279
282
|
};
|
|
283
|
+
// An element taken out of the page (a managed window closed) takes its
|
|
284
|
+
// bars with it at once. They live in the layer, not in the element, so
|
|
285
|
+
// otherwise they would stay on screen through the hide delay and fade.
|
|
286
|
+
const sweep = new MutationObserver((mutations) => {
|
|
287
|
+
if (!managed.size || !mutations.some((m) => m.removedNodes.length)) return;
|
|
288
|
+
for (const el of [...managed.keys()]) {
|
|
289
|
+
if (!el.isConnected) { drop(el); hoverChain.delete(el); }
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
sweep.observe(root, { childList: true, subtree: true });
|
|
280
293
|
|
|
281
294
|
doc.addEventListener('mouseover', onOver, true);
|
|
282
295
|
doc.addEventListener('mouseout', onLeaveWindow, true);
|
|
@@ -293,6 +306,7 @@ export function installAutoScrollbars(root = document.body, { exclude = null } =
|
|
|
293
306
|
doc.removeEventListener('scroll', onScroll, true);
|
|
294
307
|
win.removeEventListener('resize', onResize);
|
|
295
308
|
doc.removeEventListener('focusin', onResize, true);
|
|
309
|
+
sweep.disconnect();
|
|
296
310
|
for (const el of [...managed.keys()]) drop(el);
|
|
297
311
|
root.classList.remove(ROOT_CLASS);
|
|
298
312
|
},
|