flexdesk 0.13.0 → 0.14.1
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/README.md +72 -0
- package/css/flexdesk.css +99 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-M2L4XW2U.js → chunk-FBGP5KN5.js} +33 -21
- package/dist/chunk-FBGP5KN5.js.map +7 -0
- package/dist/{chunk-IYMNDPWH.js → chunk-RQM5Z2MU.js} +2 -2
- package/dist/flexdesk.css +99 -0
- package/dist/flow.js +4 -1
- package/dist/flow.js.map +2 -2
- package/dist/host.js +138 -0
- package/dist/host.js.map +3 -3
- package/dist/tiles.js +11 -3
- package/dist/tiles.js.map +2 -2
- package/dist/widgets.js +564 -6
- package/dist/widgets.js.map +3 -3
- package/dist/wm.js +146 -0
- package/dist/wm.js.map +3 -3
- package/package.json +1 -1
- package/src/flow/kit/dom.js +6 -0
- package/src/host/photino_host.js +176 -0
- package/src/host/photino_host.md +98 -0
- package/src/tiles/chart_tiles.js +14 -1
- package/src/tiling/bottom_panel_tabs.js +201 -0
- package/src/ui/components/alert_log.js +274 -0
- package/src/ui/components/alerts_pane.js +294 -0
- package/src/ui/components/data_table.js +32 -14
- package/src/ui/components/notification_history.js +54 -4
- package/src/ui/utils/auto_scrollbars.js +30 -0
- package/dist/chunk-M2L4XW2U.js.map +0 -7
- /package/dist/{chunk-IYMNDPWH.js.map → chunk-RQM5Z2MU.js.map} +0 -0
package/package.json
CHANGED
package/src/flow/kit/dom.js
CHANGED
|
@@ -154,6 +154,10 @@ export function openPopover({ anchor, content, label, className = '', onClose =
|
|
|
154
154
|
handle.close('escape');
|
|
155
155
|
};
|
|
156
156
|
const onResize = () => place();
|
|
157
|
+
// Placed before its content is drawn — a picker fills its list after this
|
|
158
|
+
// returns — so it is placed again whenever its size changes, or it would
|
|
159
|
+
// run past the window's edge.
|
|
160
|
+
const sized = typeof ResizeObserver === 'function' ? new ResizeObserver(() => place()) : null;
|
|
157
161
|
|
|
158
162
|
const handle = {
|
|
159
163
|
el: pop,
|
|
@@ -168,6 +172,7 @@ export function openPopover({ anchor, content, label, className = '', onClose =
|
|
|
168
172
|
document.removeEventListener('mousedown', onPress, true);
|
|
169
173
|
pop.removeEventListener('keydown', onKey);
|
|
170
174
|
window.removeEventListener('resize', onResize);
|
|
175
|
+
sized?.disconnect();
|
|
171
176
|
pop.remove();
|
|
172
177
|
if (ACTIVE === handle) ACTIVE = null;
|
|
173
178
|
// Focus that LEFT the popover (a Tab out of it) is going somewhere the
|
|
@@ -185,6 +190,7 @@ export function openPopover({ anchor, content, label, className = '', onClose =
|
|
|
185
190
|
}, 0);
|
|
186
191
|
pop.addEventListener('keydown', onKey);
|
|
187
192
|
window.addEventListener('resize', onResize);
|
|
193
|
+
sized?.observe(pop);
|
|
188
194
|
place();
|
|
189
195
|
(focus || pop).focus?.({ preventScroll: true });
|
|
190
196
|
return handle;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* photino_host.js — the host port over a Photino.NET window.
|
|
3
|
+
*
|
|
4
|
+
* Photino gives the page exactly two things: `window.external.sendMessage(text)`
|
|
5
|
+
* and `window.external.receiveMessage(callback)`. Strings in, strings out, no
|
|
6
|
+
* replies. This adapter puts a request/reply protocol over them, matched by id:
|
|
7
|
+
*
|
|
8
|
+
* page → host {"id": 7, "cap": "window", "op": "setBounds", "args": [{"x":0,"y":0,"width":1200,"height":800}]}
|
|
9
|
+
* host → page {"id": 7, "ok": true, "value": null}
|
|
10
|
+
* host → page {"id": 8, "ok": false, "error": "no such op"}
|
|
11
|
+
* host → page {"event": "window.maximizedChanged", "value": true} // unsolicited, no id
|
|
12
|
+
*
|
|
13
|
+
* and offers the port's `window`, `dialogs` and `state` capabilities on top.
|
|
14
|
+
* The C# side — one dispatcher on the window's web-message handler, every op
|
|
15
|
+
* with its args and reply — is documented in `photino_host.md` beside this file.
|
|
16
|
+
*
|
|
17
|
+
* Like pywebview_host.js it knows the transport and NOTHING about the app: an
|
|
18
|
+
* embedder that keeps some capability elsewhere (BugDesk keeps `state` on its
|
|
19
|
+
* own server) passes that capability in, or `null` to leave it out.
|
|
20
|
+
*
|
|
21
|
+
* In a browser tab there is no `window.external.sendMessage`, and
|
|
22
|
+
* createPhotinoHost() returns NULL_HOST — the fallback every FlexDesk consumer
|
|
23
|
+
* already has to survive.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHost, NULL_HOST } from './host.js';
|
|
27
|
+
|
|
28
|
+
/** True when `ext` (default: `window.external`) is a Photino bridge. */
|
|
29
|
+
export function isPhotinoWindow(ext = (typeof window !== 'undefined' ? window.external : undefined)) {
|
|
30
|
+
return !!ext && typeof ext.sendMessage === 'function' && typeof ext.receiveMessage === 'function';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The bare request/reply layer. `call(cap, op, ...args)` sends one request and
|
|
35
|
+
* resolves with the reply's `value` (`request(cap, op, args, {wait})` is the
|
|
36
|
+
* same with a timeout of its own). It REJECTS when the host answers
|
|
37
|
+
* `ok: false` (with the host's error) and when no reply arrives within
|
|
38
|
+
* `timeoutMs`. A reply for an id nobody is waiting on, a message that is not
|
|
39
|
+
* JSON, and anything that is neither a reply nor an event are dropped.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} opts
|
|
42
|
+
* @param {{sendMessage: Function, receiveMessage: Function}} opts.external
|
|
43
|
+
* @param {number} [opts.timeoutMs=10000]
|
|
44
|
+
* @param {(name: string, value: any) => void} [opts.onEvent] id-less `{event, value}` messages
|
|
45
|
+
* @param {object} [opts.logger]
|
|
46
|
+
*/
|
|
47
|
+
export function createPhotinoTransport({ external, timeoutMs = 10000, onEvent = null, logger = console }) {
|
|
48
|
+
const pending = new Map();
|
|
49
|
+
let seq = 0;
|
|
50
|
+
external.receiveMessage((text) => {
|
|
51
|
+
let m;
|
|
52
|
+
try { m = typeof text === 'string' ? JSON.parse(text) : text; } catch { return; }
|
|
53
|
+
if (!m || typeof m !== 'object') return;
|
|
54
|
+
if (m.id == null) {
|
|
55
|
+
if (typeof m.event === 'string' && onEvent) {
|
|
56
|
+
try { onEvent(m.event, m.value); }
|
|
57
|
+
catch (e) { logger.warn?.('[host] photino event handler failed', m.event, e); }
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const p = pending.get(m.id);
|
|
62
|
+
if (!p) return;
|
|
63
|
+
pending.delete(m.id);
|
|
64
|
+
clearTimeout(p.timer);
|
|
65
|
+
if (m.ok) p.resolve(m.value === undefined ? null : m.value);
|
|
66
|
+
else p.reject(new Error(m.error || `${p.what}: refused by the host`));
|
|
67
|
+
});
|
|
68
|
+
// `wait` is this request's timeout in ms; 0 waits for as long as it takes.
|
|
69
|
+
const request = (cap, op, args = [], { wait = timeoutMs } = {}) => new Promise((resolve, reject) => {
|
|
70
|
+
const id = ++seq;
|
|
71
|
+
const what = `${cap}.${op}`;
|
|
72
|
+
const timer = wait > 0 ? setTimeout(() => {
|
|
73
|
+
if (pending.delete(id)) reject(new Error(`${what}: no answer from the host within ${wait} ms`));
|
|
74
|
+
}, wait) : null;
|
|
75
|
+
pending.set(id, { resolve, reject, timer, what });
|
|
76
|
+
try {
|
|
77
|
+
external.sendMessage(JSON.stringify({ id, cap, op, args }));
|
|
78
|
+
} catch (e) {
|
|
79
|
+
pending.delete(id);
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
reject(e);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const call = (cap, op, ...args) => request(cap, op, args);
|
|
85
|
+
return { call, request, pending: () => pending.size };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a Host backed by a Photino window.
|
|
90
|
+
*
|
|
91
|
+
* The capability methods follow pywebview_host.js's contract, so a consumer
|
|
92
|
+
* cannot tell the two apart: they DEGRADE instead of rejecting. A failed query
|
|
93
|
+
* reads as false / null, a failed fire-and-forget is logged, and a save that
|
|
94
|
+
* the host refused or never answered resolves `null` — the host.js meaning of
|
|
95
|
+
* "unavailable", which every caller turns into a Blob download.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} [opts]
|
|
98
|
+
* @param {object} [opts.external=window.external] the Photino bridge (a fake one in tests)
|
|
99
|
+
* @param {number} [opts.timeoutMs=10000]
|
|
100
|
+
* @param {(name: string, value: any) => void} [opts.onEvent]
|
|
101
|
+
* @param {object} [opts.logger=console]
|
|
102
|
+
* @param {object|null} [opts.window] replace the window capability, or `null` to leave it out
|
|
103
|
+
* @param {object|null} [opts.dialogs] replace the dialogs capability, or `null` to leave it out
|
|
104
|
+
* @param {object|null} [opts.state] replace the state capability, or `null` to leave it out
|
|
105
|
+
* @returns {Readonly<object>} a Host, or NULL_HOST outside a Photino window
|
|
106
|
+
*/
|
|
107
|
+
export function createPhotinoHost(opts = {}) {
|
|
108
|
+
const {
|
|
109
|
+
external = (typeof window !== 'undefined' ? window.external : undefined),
|
|
110
|
+
timeoutMs = 10000,
|
|
111
|
+
onEvent = null,
|
|
112
|
+
logger = console,
|
|
113
|
+
} = opts;
|
|
114
|
+
if (!isPhotinoWindow(external)) return NULL_HOST;
|
|
115
|
+
|
|
116
|
+
const { call, request } = createPhotinoTransport({ external, timeoutMs, onEvent, logger });
|
|
117
|
+
const warn = (cap, op, e) => logger.warn?.('[host] photino call failed', `${cap}.${op}`, e);
|
|
118
|
+
const soft = async (fallback, cap, op, ...args) => {
|
|
119
|
+
try { return await call(cap, op, ...args); }
|
|
120
|
+
catch (e) { warn(cap, op, e); return fallback; }
|
|
121
|
+
};
|
|
122
|
+
const fire = (cap, op, ...args) => { void soft(null, cap, op, ...args); };
|
|
123
|
+
|
|
124
|
+
const defaults = {
|
|
125
|
+
window: {
|
|
126
|
+
// Photino draws a decorated, native window on every OS: FlexDesk's
|
|
127
|
+
// own min/max/close buttons would be a second set.
|
|
128
|
+
chrome: () => 'native',
|
|
129
|
+
minimize: () => { fire('window', 'minimize'); },
|
|
130
|
+
close: () => { fire('window', 'close'); },
|
|
131
|
+
isMaximized: async () => (await soft(false, 'window', 'isMaximized')) === true,
|
|
132
|
+
setMaximized: async (on) => (await soft(false, 'window', 'setMaximized', !!on)) === true,
|
|
133
|
+
isFullscreen: async () => (await soft(false, 'window', 'isFullscreen')) === true,
|
|
134
|
+
toggleFullscreen: async () => (await soft(false, 'window', 'toggleFullscreen')) === true,
|
|
135
|
+
async getBounds() {
|
|
136
|
+
const b = await soft(null, 'window', 'getBounds');
|
|
137
|
+
if (!b || typeof b !== 'object') return null;
|
|
138
|
+
return { x: b.x, y: b.y, width: b.width, height: b.height };
|
|
139
|
+
},
|
|
140
|
+
// Not async, like pywebview's: a rAF hot path during a drag; nobody awaits.
|
|
141
|
+
setBounds({ x = null, y = null, width = null, height = null } = {}) {
|
|
142
|
+
fire('window', 'setBounds', { x, y, width, height });
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
dialogs: {
|
|
146
|
+
// `{ok: true, path}` | `{ok: false, cancelled: true}` | `{ok: false, error}`,
|
|
147
|
+
// or null when the host refused: fall back to a download. NO TIMEOUT —
|
|
148
|
+
// the dialog is open for as long as the user takes, and giving up on it
|
|
149
|
+
// would download the file a second time behind the dialog.
|
|
150
|
+
async saveFile({ data, filename, kind } = {}) {
|
|
151
|
+
let r = null;
|
|
152
|
+
try { r = await request('dialogs', 'saveFile', [{ data, filename, kind }], { wait: 0 }); }
|
|
153
|
+
catch (e) { warn('dialogs', 'saveFile', e); }
|
|
154
|
+
return r && typeof r === 'object' ? r : null;
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
state: {
|
|
158
|
+
async read(key) {
|
|
159
|
+
const v = await soft(null, 'state', 'read', key);
|
|
160
|
+
if (typeof v !== 'string') return v ?? null;
|
|
161
|
+
try { return JSON.parse(v); }
|
|
162
|
+
catch (e) { logger.warn?.('[host] bad JSON for state key', key, e); return null; }
|
|
163
|
+
},
|
|
164
|
+
async write(key, value) {
|
|
165
|
+
return (await soft(false, 'state', 'write', key, value)) !== false;
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const caps = {};
|
|
171
|
+
for (const name of Object.keys(defaults)) {
|
|
172
|
+
if (!(name in opts)) caps[name] = defaults[name];
|
|
173
|
+
else if (opts[name] != null) caps[name] = opts[name];
|
|
174
|
+
}
|
|
175
|
+
return createHost(caps);
|
|
176
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# The Photino host: the C# side
|
|
2
|
+
|
|
3
|
+
`createPhotinoHost()` (`photino_host.js`) talks to a Photino.NET window over
|
|
4
|
+
`window.external.sendMessage` / `receiveMessage`. This is what the window has to
|
|
5
|
+
answer. One handler on the window's web messages does all of it.
|
|
6
|
+
|
|
7
|
+
## Messages
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
page → host {"id": 7, "cap": "window", "op": "setBounds", "args": [{"x":0,"y":0,"width":1200,"height":800}]}
|
|
11
|
+
host → page {"id": 7, "ok": true, "value": null}
|
|
12
|
+
host → page {"id": 8, "ok": false, "error": "unknown request window.bogus"}
|
|
13
|
+
host → page {"event": "window.maximizedChanged", "value": true}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- Every request gets exactly one reply with the same `id`. A request that gets
|
|
17
|
+
none is given up after 10 s (`timeoutMs`). `dialogs.saveFile` is the
|
|
18
|
+
exception: the page waits for it without a timeout, because the dialog stays
|
|
19
|
+
open as long as the user takes. Always answer it.
|
|
20
|
+
- `ok: false` means "I can't": the adapter logs it and degrades, exactly as it
|
|
21
|
+
does with pywebview. Use it for unknown ops and for failures.
|
|
22
|
+
- A message with no `id` and an `event` name is an event. The adapter hands it
|
|
23
|
+
to the `onEvent(name, value)` option; it never answers a request.
|
|
24
|
+
- Anything that is not JSON, or is a reply to an id nobody waits for, is
|
|
25
|
+
ignored, so the window may share the channel with other messages.
|
|
26
|
+
|
|
27
|
+
## Ops
|
|
28
|
+
|
|
29
|
+
| `cap.op` | `args` | reply `value` | Photino |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| `window.minimize` | `[]` | anything | `Minimized = true` |
|
|
32
|
+
| `window.close` | `[]` | anything | `Close()` |
|
|
33
|
+
| `window.isMaximized` | `[]` | `bool` | `Maximized` |
|
|
34
|
+
| `window.setMaximized` | `[bool]` | `bool`, the state now | `Maximized = on` |
|
|
35
|
+
| `window.isFullscreen` | `[]` | `bool` | `FullScreen` |
|
|
36
|
+
| `window.toggleFullscreen` | `[]` | `bool`, the state now | `FullScreen = !FullScreen` |
|
|
37
|
+
| `window.getBounds` | `[]` | `{x, y, width, height}` | `Location`, `Size` |
|
|
38
|
+
| `window.setBounds` | `[{x, y, width, height}]`, any of them `null` = leave alone | anything | `Location`, `Size` |
|
|
39
|
+
| `dialogs.saveFile` | `[{data, filename, kind}]` | see below | `ShowSaveFile(...)` + write |
|
|
40
|
+
| `state.read` | `[key]` | the stored value, or `null` | a file per key |
|
|
41
|
+
| `state.write` | `[key, value]` | `true` | a file per key |
|
|
42
|
+
|
|
43
|
+
`window.chrome()` is answered by the adapter itself (`'native'`): Photino draws a
|
|
44
|
+
native title bar, so FlexDesk draws no buttons of its own. `startNativeDrag` is
|
|
45
|
+
not offered.
|
|
46
|
+
|
|
47
|
+
**`dialogs.saveFile`.** `kind` is the file type the caller is saving, `'csv'`
|
|
48
|
+
or `'png'` today. `data` is the file's text for `csv` and the file's bytes,
|
|
49
|
+
base64, for `png` (decode it before writing). Show a save dialog suggesting
|
|
50
|
+
`filename`, write the file, and reply with one of:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
{"ok": true, "path": "C:\\Users\\me\\Documents\\rows.csv"} saved
|
|
54
|
+
{"ok": false, "cancelled": true} the user cancelled: nothing is said
|
|
55
|
+
{"ok": false, "error": "Access to the path is denied."} shown to the user
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
as the reply's `value` (the envelope's own `ok` stays `true`). A reply with the
|
|
59
|
+
envelope's `ok: false`, or none at all, makes `saveFile` resolve `null`, and the
|
|
60
|
+
caller downloads the file the browser way instead.
|
|
61
|
+
|
|
62
|
+
**`state`.** `value` is any JSON value; store it and give it back unchanged. A
|
|
63
|
+
string value that holds JSON is parsed on the way back, for hosts that store
|
|
64
|
+
the text.
|
|
65
|
+
|
|
66
|
+
## A dispatcher
|
|
67
|
+
|
|
68
|
+
```csharp
|
|
69
|
+
window.RegisterWebMessageReceivedHandler((sender, message) =>
|
|
70
|
+
{
|
|
71
|
+
var w = (PhotinoWindow)sender!;
|
|
72
|
+
using var doc = JsonDocument.Parse(message);
|
|
73
|
+
var req = doc.RootElement;
|
|
74
|
+
if (!req.TryGetProperty("id", out var id)) return;
|
|
75
|
+
var args = req.GetProperty("args");
|
|
76
|
+
string reply;
|
|
77
|
+
try
|
|
78
|
+
{
|
|
79
|
+
object? value = (req.GetProperty("cap").GetString(), req.GetProperty("op").GetString()) switch
|
|
80
|
+
{
|
|
81
|
+
("window", "isMaximized") => w.Maximized,
|
|
82
|
+
("window", "setMaximized") => w.Maximized = args[0].GetBoolean(),
|
|
83
|
+
("window", "getBounds") => new { x = w.Left, y = w.Top, width = w.Width, height = w.Height },
|
|
84
|
+
// ... the rest of the table
|
|
85
|
+
var (c, o) => throw new InvalidOperationException($"unknown request {c}.{o}"),
|
|
86
|
+
};
|
|
87
|
+
reply = JsonSerializer.Serialize(new { id = JsonSerializer.Deserialize<object>(id.GetRawText()), ok = true, value });
|
|
88
|
+
}
|
|
89
|
+
catch (Exception ex)
|
|
90
|
+
{
|
|
91
|
+
reply = JsonSerializer.Serialize(new { id = JsonSerializer.Deserialize<object>(id.GetRawText()), ok = false, error = ex.Message });
|
|
92
|
+
}
|
|
93
|
+
w.SendWebMessage(reply);
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The handler runs on the window's thread; a save dialog shown from it is modal to
|
|
98
|
+
the window, which is what you want.
|
package/src/tiles/chart_tiles.js
CHANGED
|
@@ -528,9 +528,22 @@ export class ChartTile extends TileBase {
|
|
|
528
528
|
try {
|
|
529
529
|
const P = await ensurePlotly();
|
|
530
530
|
const uri = await P.toImage(this._plotEl, { format: 'png', scale: 2 });
|
|
531
|
+
const filename = `${slug(this.config.title || this.constructor.TITLE)}.png`;
|
|
532
|
+
// The native Save dialog when the host has one (a desktop window).
|
|
533
|
+
// `saveFile` answers null when there is no dialog behind it, which
|
|
534
|
+
// means "download it yourself", not a failed save.
|
|
535
|
+
const dialogs = this.host?.dialogs;
|
|
536
|
+
const result = dialogs
|
|
537
|
+
? await dialogs.saveFile({ data: uri.split(',')[1], filename, kind: 'png' })
|
|
538
|
+
: null;
|
|
539
|
+
if (result) {
|
|
540
|
+
if (result.ok) this._notify('Download', `Chart saved to ${result.path}`, 'success');
|
|
541
|
+
else if (!result.cancelled) this._notify('Download failed', result.error || 'The image could not be saved', 'error');
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
531
544
|
const a = document.createElement('a');
|
|
532
545
|
a.href = uri;
|
|
533
|
-
a.download =
|
|
546
|
+
a.download = filename;
|
|
534
547
|
a.click();
|
|
535
548
|
} catch (err) {
|
|
536
549
|
this._notify('Download failed', `The image could not be made: ${err?.message || err}`, 'error');
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ══ THE BOTTOM PANEL'S TAB HOST ══════════════════════════════════════════
|
|
3
|
+
*
|
|
4
|
+
* The `.twm-bp*` CSS shipped for a long time with no JS behind it, and two
|
|
5
|
+
* embedders wrote the same strip on top of it: bugdesk (Alerts and Changes,
|
|
6
|
+
* with a count on each tab) and Tables (SQL, Database, History, Activity, each
|
|
7
|
+
* pane with storage of its own). This is the two merged, so the third embedder
|
|
8
|
+
* writes none of it (FlexDesk STORY-0018).
|
|
9
|
+
*
|
|
10
|
+
* const tabs = createBottomPanelTabs(host, {
|
|
11
|
+
* panes: [{ id: 'alerts', label: 'Alerts', eager: true, mount(el, store, api) { … } }],
|
|
12
|
+
* remember: { read: () => saved, write: (id) => … },
|
|
13
|
+
* eventBus,
|
|
14
|
+
* });
|
|
15
|
+
* tabs.setBadge('alerts', 3, 'warning');
|
|
16
|
+
* eventBus.emit('bottompanel:show', { tab: 'alerts' });
|
|
17
|
+
*
|
|
18
|
+
* A PANE is `{ id, label, mount(el, store, api), eager? }`. `mount` answers an
|
|
19
|
+
* instance, or nothing; the host calls the instance's `onShow()` each time its
|
|
20
|
+
* tab comes to the front, `persist()` before the panel goes, `destroy()` when
|
|
21
|
+
* it is gone for good. A pane is mounted the first time it is shown — or at
|
|
22
|
+
* once, with `eager: true`, when its tab carries a count that must be right
|
|
23
|
+
* before anyone opens it (bugdesk's Alerts). Those two mount INSIDE this call,
|
|
24
|
+
* before it has answered: a pane reaches the host through its `api` argument
|
|
25
|
+
* (`api.setBadge(…)`), never through the variable the call is being assigned to.
|
|
26
|
+
*
|
|
27
|
+
* `store` is the pane's OWN persistence, from the embedder's `store(pane)`, or
|
|
28
|
+
* null. COLLAPSING A PANEL DESTROYS ITS CONTENT (the tile leaf goes, and its
|
|
29
|
+
* GC calls `destroy`), so a pane holding unsaved state — half-written SQL —
|
|
30
|
+
* saves it itself; the host cannot know what that is.
|
|
31
|
+
*
|
|
32
|
+
* `remember` keeps the tab last in front, in whatever store the embedder has
|
|
33
|
+
* (bugdesk: localStorage; Tables: its server blob, beside the panel's height).
|
|
34
|
+
* Its `read` may answer a promise. A remembered tab no longer offered falls
|
|
35
|
+
* back to the first; and A TAB A CALLER ALREADY CHOSE WINS — the read is a
|
|
36
|
+
* round trip, and a panel opened BY a button for one tab used to be put back
|
|
37
|
+
* on the remembered one when the read answered (Tables, 2026-10-12).
|
|
38
|
+
*
|
|
39
|
+
* `bottompanel:show` on the embedder's bus, with `{ tab }`, brings a tab to the
|
|
40
|
+
* front (a toast's "+N more" asks for Alerts). The host only selects; whether
|
|
41
|
+
* the panel itself is open is the embedder's (a tile it toggles), so the bus
|
|
42
|
+
* event is the embedder's to hear too.
|
|
43
|
+
*
|
|
44
|
+
* The strip is never rebuilt: a select toggles classes on the same buttons, so
|
|
45
|
+
* a click that lands while a tab is being selected keeps its target.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** Badge levels, worst last. Any other value draws the neutral badge. */
|
|
49
|
+
export const BADGE_LEVELS = Object.freeze(['info', 'warning', 'alert']);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {HTMLElement} container
|
|
53
|
+
* @param {object} opts
|
|
54
|
+
* @param {Array} opts.panes `{ id, label, mount(el, store, api), eager? }`
|
|
55
|
+
* @param {object} [opts.remember] `{ read(): id|Promise<id>, write(id) }`
|
|
56
|
+
* @param {Function}[opts.store] `(pane) => { read, write } | null` — a pane's own storage
|
|
57
|
+
* @param {object} [opts.eventBus] hears `bottompanel:show`
|
|
58
|
+
* @param {Function}[opts.onSelect] `(id, pane)` — e.g. the tile's title
|
|
59
|
+
* @param {string} [opts.initial] the tab in front before `remember` answers
|
|
60
|
+
*/
|
|
61
|
+
export function createBottomPanelTabs(container, {
|
|
62
|
+
panes = [], remember = null, store = null, eventBus = null, onSelect = null, initial = null,
|
|
63
|
+
} = {}) {
|
|
64
|
+
const doc = container.ownerDocument;
|
|
65
|
+
const root = doc.createElement('div');
|
|
66
|
+
root.className = 'twm-bp';
|
|
67
|
+
const strip = doc.createElement('div');
|
|
68
|
+
strip.className = 'twm-bp__tabs';
|
|
69
|
+
strip.setAttribute('role', 'tablist');
|
|
70
|
+
const body = doc.createElement('div');
|
|
71
|
+
body.className = 'twm-bp__body';
|
|
72
|
+
root.append(strip, body);
|
|
73
|
+
container.appendChild(root);
|
|
74
|
+
|
|
75
|
+
const byId = new Map(panes.map((pane) => [pane.id, pane]));
|
|
76
|
+
const tabs = new Map();
|
|
77
|
+
const mounted = new Map();
|
|
78
|
+
let active = null;
|
|
79
|
+
let chosen = false;
|
|
80
|
+
let destroyed = false;
|
|
81
|
+
|
|
82
|
+
for (const pane of panes) {
|
|
83
|
+
const tab = doc.createElement('button');
|
|
84
|
+
tab.type = 'button';
|
|
85
|
+
tab.className = 'twm-bp__tab';
|
|
86
|
+
tab.dataset.pane = pane.id;
|
|
87
|
+
tab.setAttribute('role', 'tab');
|
|
88
|
+
tab.setAttribute('aria-selected', 'false');
|
|
89
|
+
const label = doc.createElement('span');
|
|
90
|
+
label.className = 'twm-bp__label';
|
|
91
|
+
label.textContent = pane.label;
|
|
92
|
+
const badge = doc.createElement('span');
|
|
93
|
+
badge.className = 'twm-bp__badge';
|
|
94
|
+
badge.hidden = true;
|
|
95
|
+
tab.append(label, badge);
|
|
96
|
+
tab.addEventListener('click', () => api.select(pane.id));
|
|
97
|
+
strip.appendChild(tab);
|
|
98
|
+
tabs.set(pane.id, tab);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mount(id) {
|
|
102
|
+
if (mounted.has(id)) return mounted.get(id);
|
|
103
|
+
const pane = byId.get(id);
|
|
104
|
+
const el = doc.createElement('div');
|
|
105
|
+
el.className = 'twm-bp-pane';
|
|
106
|
+
el.dataset.pane = id;
|
|
107
|
+
el.setAttribute('role', 'tabpanel');
|
|
108
|
+
el.hidden = id !== active;
|
|
109
|
+
body.appendChild(el);
|
|
110
|
+
const entry = { el, instance: null };
|
|
111
|
+
mounted.set(id, entry);
|
|
112
|
+
// ONE PANE THAT THROWS IS ONE EMPTY TAB, not a dead panel: the strip
|
|
113
|
+
// and every other pane go on working.
|
|
114
|
+
try {
|
|
115
|
+
entry.instance = pane.mount?.(el, store ? store(pane) : null, api) ?? null;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`[bottom-panel] pane "${id}" failed to mount`, err);
|
|
118
|
+
}
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function show(id) {
|
|
123
|
+
if (destroyed || !byId.has(id)) return false;
|
|
124
|
+
active = id;
|
|
125
|
+
for (const [key, tab] of tabs) {
|
|
126
|
+
const on = key === id;
|
|
127
|
+
tab.classList.toggle('twm-bp__tab--on', on);
|
|
128
|
+
tab.setAttribute('aria-selected', String(on));
|
|
129
|
+
}
|
|
130
|
+
for (const [key, entry] of mounted) entry.el.hidden = key !== id;
|
|
131
|
+
mount(id).instance?.onShow?.();
|
|
132
|
+
onSelect?.(id, byId.get(id));
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const api = {
|
|
137
|
+
root,
|
|
138
|
+
/** Bring a tab to the front, and remember it. */
|
|
139
|
+
select(id) {
|
|
140
|
+
if (!show(id)) return;
|
|
141
|
+
chosen = true;
|
|
142
|
+
try { remember?.write?.(id); } catch { /* storage refused: not ours to fix */ }
|
|
143
|
+
},
|
|
144
|
+
/** The tab in front, or null. */
|
|
145
|
+
active: () => active,
|
|
146
|
+
/** `count` on a tab, coloured by `level`; nothing at zero. */
|
|
147
|
+
setBadge(id, count, level = null) {
|
|
148
|
+
const badge = tabs.get(id)?.querySelector('.twm-bp__badge');
|
|
149
|
+
if (!badge) return;
|
|
150
|
+
const n = Number(count) || 0;
|
|
151
|
+
badge.hidden = n <= 0;
|
|
152
|
+
badge.textContent = n > 99 ? '99+' : String(Math.max(n, 0));
|
|
153
|
+
badge.className = 'twm-bp__badge'
|
|
154
|
+
+ (BADGE_LEVELS.includes(level) ? ` twm-bp__badge--${level}` : '');
|
|
155
|
+
},
|
|
156
|
+
/** A mounted pane's live instance, or null — how one tab hands work to
|
|
157
|
+
* another. `select` it first, which mounts it. */
|
|
158
|
+
instance: (id) => mounted.get(id)?.instance ?? null,
|
|
159
|
+
/** The panel is on screen again (its tile re-mounted): tell the pane in front. */
|
|
160
|
+
reshow() {
|
|
161
|
+
if (active) mounted.get(active)?.instance?.onShow?.();
|
|
162
|
+
},
|
|
163
|
+
/** Before the panel is torn down: every mounted pane saves. */
|
|
164
|
+
async persist() {
|
|
165
|
+
for (const entry of mounted.values()) await entry.instance?.persist?.();
|
|
166
|
+
},
|
|
167
|
+
/** Gone for good: panes save, then are destroyed; the bus is let go. */
|
|
168
|
+
async destroy() {
|
|
169
|
+
if (destroyed) return;
|
|
170
|
+
await api.persist();
|
|
171
|
+
destroyed = true;
|
|
172
|
+
try { subscription?.dispose?.(); } catch { /* bus gone */ }
|
|
173
|
+
for (const entry of mounted.values()) {
|
|
174
|
+
try { entry.instance?.destroy?.(); } catch (err) {
|
|
175
|
+
console.warn('[bottom-panel] pane destroy failed', err);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
mounted.clear();
|
|
179
|
+
root.remove();
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
for (const pane of panes) if (pane.eager) mount(pane.id);
|
|
184
|
+
|
|
185
|
+
const subscription = eventBus?.on?.('bottompanel:show',
|
|
186
|
+
(payload) => api.select(payload?.tab ?? panes[0]?.id)) ?? null;
|
|
187
|
+
|
|
188
|
+
show(byId.has(initial) ? initial : panes[0]?.id);
|
|
189
|
+
|
|
190
|
+
if (remember?.read) {
|
|
191
|
+
Promise.resolve()
|
|
192
|
+
.then(() => remember.read())
|
|
193
|
+
.then((saved) => {
|
|
194
|
+
if (chosen || destroyed) return;
|
|
195
|
+
if (byId.has(saved)) show(saved);
|
|
196
|
+
})
|
|
197
|
+
.catch(() => { /* nothing remembered */ });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return api;
|
|
201
|
+
}
|