flexdesk 0.12.1 → 0.14.0
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 +86 -1
- package/css/flexdesk.css +176 -0
- package/dist/charts.js +4 -73
- package/dist/charts.js.map +3 -3
- package/dist/{chunk-2LEJQD4H.js → chunk-IYMNDPWH.js} +109 -2
- package/dist/chunk-IYMNDPWH.js.map +7 -0
- package/dist/flexdesk.css +176 -0
- package/dist/flow.js +3 -0
- package/dist/flow.js.map +2 -2
- package/dist/host.js +138 -0
- package/dist/host.js.map +3 -3
- package/dist/tiles.js +1752 -1031
- package/dist/tiles.js.map +4 -4
- package/dist/widgets.js +563 -5
- 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/charts/chart_types.js +40 -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 +827 -0
- package/src/tiles/tile_grid.js +4 -3
- 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/notification_history.js +54 -4
- package/src/ui/utils/auto_scrollbars.js +30 -0
- package/dist/chunk-2LEJQD4H.js.map +0 -7
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ══ THE ALERTS PANE ═══════════════════════════════════════════════════════
|
|
3
|
+
*
|
|
4
|
+
* An alert log (`alert_log.js`) drawn as the bottom panel's Alerts tab: level
|
|
5
|
+
* chips with counts, a text filter, entries grouped by day with relative
|
|
6
|
+
* times, an unread mark, Open and Dismiss on each row, Mark all read and Clear
|
|
7
|
+
* for the lot (FlexDesk STORY-0019, from bugdesk's `alerts.js`). The same
|
|
8
|
+
* layout whether the log is local or pages a server: *Load older* appears
|
|
9
|
+
* when the source has more.
|
|
10
|
+
*
|
|
11
|
+
* mountAlertsPane(el, { log, onOpen: (entry) => …, onBadge: ({ unread, level }) => … })
|
|
12
|
+
*
|
|
13
|
+
* OPTIONAL, for an embedder with rules of its own (bugdesk): `rules` is
|
|
14
|
+
* `{ list() → [{ id, name, level, meta?, off? }], onEdit?, foot? }` — a column
|
|
15
|
+
* of them on the left that narrows the log to one rule (entries carry
|
|
16
|
+
* `ruleId`) — and `stillMatches(entry)` → true / false / null, drawn as "still
|
|
17
|
+
* matches" or "no longer matches" on each row.
|
|
18
|
+
*
|
|
19
|
+
* NEVER REBUILT UNDER THE POINTER. A log changes on its own — a push from the
|
|
20
|
+
* server, a rule firing, the minute tick — and a row repainted between a
|
|
21
|
+
* mousedown and its click loses the click (Tables' recorded trap). While a
|
|
22
|
+
* button is down inside the pane a repaint waits, and runs once it is up.
|
|
23
|
+
* The bar (chips, the filter, the buttons) is built once and updated in
|
|
24
|
+
* place, so typing in the filter never loses the caret either.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { ALERT_LEVELS, ago, dayLabel, normalizeLevel } from './alert_log.js';
|
|
28
|
+
|
|
29
|
+
const CHIPS = [['all', 'All'], ['alert', 'Alert'], ['warning', 'Warning'], ['info', 'Info']];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {HTMLElement} host
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {object} opts.log `createAlertLog(…)`
|
|
35
|
+
* @param {Function}[opts.onOpen] `(entry)` — Open; the entry is marked read first
|
|
36
|
+
* @param {Function}[opts.onBadge] `({ unread, level })` after every change — a tab's badge
|
|
37
|
+
* @param {object} [opts.rules] `{ list, onEdit?, foot? }`
|
|
38
|
+
* @param {Function}[opts.stillMatches] `(entry) → boolean | null`
|
|
39
|
+
* @param {Function}[opts.describe] `(entry) → string` — a small chip on the row (bugdesk: the store)
|
|
40
|
+
* @param {string} [opts.status] the footer line
|
|
41
|
+
* @param {object} [opts.empty] `{ none, filtered }` — the two empty texts
|
|
42
|
+
* @returns {{ refresh, destroy, root }}
|
|
43
|
+
*/
|
|
44
|
+
export function mountAlertsPane(host, {
|
|
45
|
+
log, onOpen = null, onBadge = null, rules = null, stillMatches = null, describe = null,
|
|
46
|
+
status = '', empty = {},
|
|
47
|
+
} = {}) {
|
|
48
|
+
const doc = host.ownerDocument;
|
|
49
|
+
const el = (tag, cls, text) => {
|
|
50
|
+
const n = doc.createElement(tag);
|
|
51
|
+
if (cls) n.className = cls;
|
|
52
|
+
if (text !== undefined) n.textContent = text;
|
|
53
|
+
return n;
|
|
54
|
+
};
|
|
55
|
+
const icon = (name) => el('span', 'material-symbols-outlined', name);
|
|
56
|
+
|
|
57
|
+
let level = 'all';
|
|
58
|
+
let rule = null;
|
|
59
|
+
let q = '';
|
|
60
|
+
let pointerDown = false;
|
|
61
|
+
let pending = false;
|
|
62
|
+
let destroyed = false;
|
|
63
|
+
|
|
64
|
+
// ── the frame, built once ────────────────────────────────────────────
|
|
65
|
+
const root = el('div', 'twm-alerts');
|
|
66
|
+
let ruleList = null;
|
|
67
|
+
if (rules) {
|
|
68
|
+
const side = el('div', 'twm-alerts__rules');
|
|
69
|
+
const head = el('div', 'twm-alerts__head');
|
|
70
|
+
head.append(el('span', 'twm-alerts__caps', 'RULES'), el('span', 'twm-alerts__spacer'));
|
|
71
|
+
if (rules.onEdit) {
|
|
72
|
+
const edit = el('button', 'twm-alerts__link', 'Edit rules');
|
|
73
|
+
edit.type = 'button';
|
|
74
|
+
edit.dataset.act = 'edit-rules';
|
|
75
|
+
head.append(edit);
|
|
76
|
+
}
|
|
77
|
+
ruleList = el('div', 'twm-alerts__rulelist');
|
|
78
|
+
side.append(head, ruleList);
|
|
79
|
+
if (rules.foot) side.append(el('div', 'twm-alerts__foot', rules.foot));
|
|
80
|
+
root.append(side);
|
|
81
|
+
}
|
|
82
|
+
const main = el('div', 'twm-alerts__main');
|
|
83
|
+
const bar = el('div', 'twm-alerts__bar');
|
|
84
|
+
const chips = el('div', 'twm-alerts__chips');
|
|
85
|
+
chips.setAttribute('role', 'group');
|
|
86
|
+
chips.setAttribute('aria-label', 'Level');
|
|
87
|
+
const chipEls = new Map();
|
|
88
|
+
for (const [key, label] of CHIPS) {
|
|
89
|
+
const chip = el('button', 'twm-alerts__chip');
|
|
90
|
+
chip.type = 'button';
|
|
91
|
+
chip.dataset.level = key;
|
|
92
|
+
chip.append(doc.createTextNode(`${label} `), el('span', 'twm-alerts__count', '0'));
|
|
93
|
+
chips.append(chip);
|
|
94
|
+
chipEls.set(key, chip);
|
|
95
|
+
}
|
|
96
|
+
const input = el('input', 'twm-alerts__q');
|
|
97
|
+
input.type = 'text';
|
|
98
|
+
input.autocomplete = 'off';
|
|
99
|
+
input.spellcheck = false;
|
|
100
|
+
input.placeholder = 'Filter';
|
|
101
|
+
input.setAttribute('aria-label', 'Filter alerts');
|
|
102
|
+
const readAll = el('button', 'twm-btn twm-alerts__btn', 'Mark all read');
|
|
103
|
+
readAll.type = 'button';
|
|
104
|
+
readAll.dataset.act = 'read-all';
|
|
105
|
+
const clear = el('button', 'twm-btn twm-alerts__btn', 'Clear');
|
|
106
|
+
clear.type = 'button';
|
|
107
|
+
clear.dataset.act = 'clear';
|
|
108
|
+
bar.append(chips, input, el('span', 'twm-alerts__spacer'), readAll, clear);
|
|
109
|
+
const list = el('div', 'twm-alerts__log');
|
|
110
|
+
const foot = el('div', 'twm-alerts__status', status);
|
|
111
|
+
foot.hidden = !status;
|
|
112
|
+
main.append(bar, list, foot);
|
|
113
|
+
root.append(main);
|
|
114
|
+
host.append(root);
|
|
115
|
+
|
|
116
|
+
// ── what is shown ───────────────────────────────────────────────────
|
|
117
|
+
const byRule = () => (rule ? log.entries().filter((e) => String(e.ruleId) === rule) : log.entries());
|
|
118
|
+
const shown = () => {
|
|
119
|
+
const needle = q.trim().toLowerCase();
|
|
120
|
+
return byRule()
|
|
121
|
+
.filter((e) => level === 'all' || e.level === level)
|
|
122
|
+
.filter((e) => !needle || `${e.ref ?? ''} ${e.title} ${e.body ?? ''} ${e.rule ?? ''}`
|
|
123
|
+
.toLowerCase().includes(needle));
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function row(entry, now) {
|
|
127
|
+
const lv = ALERT_LEVELS[normalizeLevel(entry.level)];
|
|
128
|
+
const r = el('div', `twm-alerts__row twm-alerts__row--${entry.level}${entry.read ? '' : ' twm-alerts__row--unread'}`);
|
|
129
|
+
r.dataset.id = entry.id;
|
|
130
|
+
const mark = el('span', 'twm-alerts__unread');
|
|
131
|
+
mark.setAttribute('aria-label', entry.read ? 'Read' : 'Unread');
|
|
132
|
+
const ic = icon(lv.icon);
|
|
133
|
+
ic.classList.add('twm-alerts__icon');
|
|
134
|
+
ic.title = lv.label;
|
|
135
|
+
const time = el('span', 'twm-alerts__time', ago(entry.at, now));
|
|
136
|
+
time.title = entry.at.toLocaleString();
|
|
137
|
+
const text = el('span', 'twm-alerts__text');
|
|
138
|
+
text.append(el('span', 'twm-alerts__title', entry.title));
|
|
139
|
+
if (entry.body) text.append(el('span', 'twm-alerts__body', entry.body));
|
|
140
|
+
const cells = [mark, ic, time];
|
|
141
|
+
if (rules) cells.push(el('span', 'twm-alerts__rule', entry.rule ?? ''));
|
|
142
|
+
const tag = describe?.(entry);
|
|
143
|
+
cells.push(el('span', tag ? 'twm-alerts__tag' : 'twm-alerts__tag twm-alerts__tag--none', tag ?? ''));
|
|
144
|
+
const ref = el(entry.ref && onOpen ? 'button' : 'span', 'twm-alerts__ref', entry.ref ?? '');
|
|
145
|
+
if (ref.tagName === 'BUTTON') { ref.type = 'button'; ref.dataset.open = entry.id; }
|
|
146
|
+
cells.push(ref, text);
|
|
147
|
+
if (stillMatches) {
|
|
148
|
+
const still = stillMatches(entry);
|
|
149
|
+
cells.push(el('span', `twm-alerts__state${still === false ? ' twm-alerts__state--gone' : ''}`,
|
|
150
|
+
still === true ? 'still matches' : still === false ? 'no longer matches' : ''));
|
|
151
|
+
}
|
|
152
|
+
const acts = el('span', 'twm-alerts__acts');
|
|
153
|
+
if (onOpen) {
|
|
154
|
+
const open = el('button', 'twm-alerts__icon-btn');
|
|
155
|
+
open.type = 'button';
|
|
156
|
+
open.dataset.open = entry.id;
|
|
157
|
+
open.title = 'Open';
|
|
158
|
+
open.setAttribute('aria-label', 'Open');
|
|
159
|
+
open.append(icon('open_in_new'));
|
|
160
|
+
acts.append(open);
|
|
161
|
+
}
|
|
162
|
+
if (log.canDismiss()) {
|
|
163
|
+
const dis = el('button', 'twm-alerts__icon-btn');
|
|
164
|
+
dis.type = 'button';
|
|
165
|
+
dis.dataset.dismiss = entry.id;
|
|
166
|
+
dis.title = 'Dismiss';
|
|
167
|
+
dis.setAttribute('aria-label', 'Dismiss');
|
|
168
|
+
dis.append(icon('close'));
|
|
169
|
+
acts.append(dis);
|
|
170
|
+
}
|
|
171
|
+
cells.push(acts);
|
|
172
|
+
r.append(...cells);
|
|
173
|
+
return r;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function paint() {
|
|
177
|
+
if (destroyed) return;
|
|
178
|
+
if (pointerDown) { pending = true; return; }
|
|
179
|
+
pending = false;
|
|
180
|
+
const now = new Date();
|
|
181
|
+
const all = log.entries();
|
|
182
|
+
const scoped = byRule();
|
|
183
|
+
|
|
184
|
+
for (const [key, chip] of chipEls) {
|
|
185
|
+
const on = level === key;
|
|
186
|
+
chip.classList.toggle('twm-alerts__chip--on', on);
|
|
187
|
+
chip.setAttribute('aria-pressed', String(on));
|
|
188
|
+
chip.querySelector('.twm-alerts__count').textContent =
|
|
189
|
+
String(scoped.filter((e) => key === 'all' || e.level === key).length);
|
|
190
|
+
}
|
|
191
|
+
readAll.disabled = !all.some((e) => !e.read) && log.unreadCount() === 0;
|
|
192
|
+
clear.hidden = !log.canDismiss();
|
|
193
|
+
clear.disabled = !all.length;
|
|
194
|
+
|
|
195
|
+
if (ruleList) {
|
|
196
|
+
const items = [{ id: null, name: 'All alerts', meta: String(all.length) }, ...(rules.list?.() ?? [])];
|
|
197
|
+
ruleList.replaceChildren(...items.map((r) => {
|
|
198
|
+
const key = r.id === null ? null : String(r.id);
|
|
199
|
+
const b = el('button', `twm-alerts__rulebtn${rule === key ? ' twm-alerts__rulebtn--on' : ''}${r.off ? ' twm-alerts__rulebtn--off' : ''}`);
|
|
200
|
+
b.type = 'button';
|
|
201
|
+
b.dataset.rule = key ?? '';
|
|
202
|
+
b.setAttribute('aria-pressed', String(rule === key));
|
|
203
|
+
b.append(el('span', `twm-alerts__dot${r.level ? ` twm-alerts__dot--${normalizeLevel(r.level)}` : ''}`),
|
|
204
|
+
el('span', 'twm-alerts__rulename', r.name || 'Unnamed rule'),
|
|
205
|
+
el('span', 'twm-alerts__meta', r.meta ?? ''));
|
|
206
|
+
return b;
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const rows = [];
|
|
211
|
+
let lastDay = '';
|
|
212
|
+
for (const entry of shown()) {
|
|
213
|
+
const day = dayLabel(entry.at, now);
|
|
214
|
+
if (day !== lastDay) rows.push(el('div', 'twm-alerts__day', day));
|
|
215
|
+
lastDay = day;
|
|
216
|
+
rows.push(row(entry, now));
|
|
217
|
+
}
|
|
218
|
+
if (!rows.length) {
|
|
219
|
+
rows.push(el('div', 'twm-alerts__none', !log.loaded() ? 'Loading…'
|
|
220
|
+
: all.length ? (empty.filtered ?? 'No alerts match.') : (empty.none ?? 'Nothing yet.')));
|
|
221
|
+
}
|
|
222
|
+
if (log.hasMore()) {
|
|
223
|
+
const older = el('button', 'twm-btn twm-alerts__more', 'Load older');
|
|
224
|
+
older.type = 'button';
|
|
225
|
+
older.dataset.act = 'more';
|
|
226
|
+
rows.push(older);
|
|
227
|
+
}
|
|
228
|
+
const scroll = list.scrollTop;
|
|
229
|
+
list.replaceChildren(...rows);
|
|
230
|
+
list.scrollTop = scroll;
|
|
231
|
+
onBadge?.({ unread: log.unreadCount(), level: log.worstUnread() });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── input ───────────────────────────────────────────────────────────
|
|
235
|
+
const onDown = () => { pointerDown = true; };
|
|
236
|
+
const onUp = () => {
|
|
237
|
+
if (!pointerDown) return;
|
|
238
|
+
// After the click this mouseup belongs to has been dispatched.
|
|
239
|
+
setTimeout(() => {
|
|
240
|
+
pointerDown = false;
|
|
241
|
+
if (pending) paint();
|
|
242
|
+
}, 0);
|
|
243
|
+
};
|
|
244
|
+
root.addEventListener('mousedown', onDown, true);
|
|
245
|
+
root.addEventListener('pointerdown', onDown, true);
|
|
246
|
+
doc.addEventListener('mouseup', onUp, true);
|
|
247
|
+
doc.addEventListener('pointerup', onUp, true);
|
|
248
|
+
doc.addEventListener('pointercancel', onUp, true);
|
|
249
|
+
|
|
250
|
+
root.addEventListener('click', (event) => {
|
|
251
|
+
const t = event.target;
|
|
252
|
+
const chip = t.closest('[data-level]');
|
|
253
|
+
if (chip) { level = chip.dataset.level; paint(); return; }
|
|
254
|
+
const r = t.closest('[data-rule]');
|
|
255
|
+
if (r) { rule = r.dataset.rule || null; paint(); return; }
|
|
256
|
+
const open = t.closest('[data-open]');
|
|
257
|
+
if (open) {
|
|
258
|
+
const entry = log.get(open.dataset.open);
|
|
259
|
+
if (!entry) return;
|
|
260
|
+
log.markRead([entry.id]);
|
|
261
|
+
onOpen?.(entry);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const dis = t.closest('[data-dismiss]');
|
|
265
|
+
if (dis) { log.dismiss([dis.dataset.dismiss]); return; }
|
|
266
|
+
const act = t.closest('[data-act]')?.dataset.act;
|
|
267
|
+
if (act === 'read-all') log.markRead();
|
|
268
|
+
else if (act === 'clear') log.dismiss();
|
|
269
|
+
else if (act === 'more') log.loadMore();
|
|
270
|
+
else if (act === 'edit-rules') rules?.onEdit?.();
|
|
271
|
+
});
|
|
272
|
+
input.addEventListener('input', () => { q = input.value; paint(); });
|
|
273
|
+
|
|
274
|
+
const unsubscribe = log.subscribe(() => paint());
|
|
275
|
+
// "5 min ago" moves on its own.
|
|
276
|
+
const timer = setInterval(paint, 60000);
|
|
277
|
+
paint();
|
|
278
|
+
log.load?.();
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
root,
|
|
282
|
+
refresh: paint,
|
|
283
|
+
destroy() {
|
|
284
|
+
if (destroyed) return;
|
|
285
|
+
destroyed = true;
|
|
286
|
+
clearInterval(timer);
|
|
287
|
+
unsubscribe();
|
|
288
|
+
doc.removeEventListener('mouseup', onUp, true);
|
|
289
|
+
doc.removeEventListener('pointerup', onUp, true);
|
|
290
|
+
doc.removeEventListener('pointercancel', onUp, true);
|
|
291
|
+
root.remove();
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -4,13 +4,33 @@
|
|
|
4
4
|
* Bell icon in the global-bottom-bar that opens a popover listing past notifications.
|
|
5
5
|
* Reads from NotificationCenter.history (no modifications to NotificationCenter needed).
|
|
6
6
|
* Tracks unread count via lastSeenCount delta and updates a badge on the bell icon.
|
|
7
|
+
*
|
|
8
|
+
* THREE OPTIONS, all additive (FlexDesk STORY-0020); without them it is the bell
|
|
9
|
+
* it always was:
|
|
10
|
+
*
|
|
11
|
+
* mountPoint the element the bell is appended to. Without it, the hard-coded
|
|
12
|
+
* `.twm-global-bottom-bar .twm-bar-right`, which only FlexDesk's own
|
|
13
|
+
* bottom bar has.
|
|
14
|
+
* alertLog an alert log (`alert_log.js`). The badge is then the log's unread
|
|
15
|
+
* count, in the worst unread level's colour, and nothing when all is
|
|
16
|
+
* read — not a delta of a toast history the user may never have
|
|
17
|
+
* opened. A click opens the bottom panel's Alerts tab instead of the
|
|
18
|
+
* popover, and the toasts' "+N more" (`notification:summary`) does too.
|
|
19
|
+
* alertsTab the tab a click asks for, `bottompanel:show { tab }`; 'alerts'.
|
|
20
|
+
* onOpen a click, instead of the bus event — for an embedder with no bus,
|
|
21
|
+
* or whose panel must be opened before a tab can be chosen.
|
|
7
22
|
*/
|
|
8
23
|
|
|
9
24
|
export class NotificationHistory {
|
|
10
|
-
constructor({ notificationCenter, eventBus, logger
|
|
25
|
+
constructor({ notificationCenter, eventBus, logger, mountPoint = null, alertLog = null,
|
|
26
|
+
alertsTab = 'alerts', onOpen = null } = {}) {
|
|
11
27
|
this.notificationCenter = notificationCenter;
|
|
12
28
|
this.eventBus = eventBus || null;
|
|
13
29
|
this.logger = logger || console;
|
|
30
|
+
this.mountPoint = mountPoint;
|
|
31
|
+
this.alertLog = alertLog;
|
|
32
|
+
this.alertsTab = alertsTab;
|
|
33
|
+
this.onOpen = typeof onOpen === 'function' ? onOpen : null;
|
|
14
34
|
|
|
15
35
|
this.triggerEl = null;
|
|
16
36
|
this.badgeEl = null;
|
|
@@ -20,7 +40,8 @@ export class NotificationHistory {
|
|
|
20
40
|
}
|
|
21
41
|
|
|
22
42
|
mount() {
|
|
23
|
-
const barRight =
|
|
43
|
+
const barRight = this.mountPoint
|
|
44
|
+
|| document.querySelector('.twm-global-bottom-bar .twm-bar-right');
|
|
24
45
|
if (!barRight) {
|
|
25
46
|
this.logger.warn?.('[NotificationHistory] .bar-right not found');
|
|
26
47
|
return;
|
|
@@ -45,9 +66,20 @@ export class NotificationHistory {
|
|
|
45
66
|
|
|
46
67
|
this.triggerEl.addEventListener('click', (e) => {
|
|
47
68
|
e.stopPropagation();
|
|
48
|
-
this.#
|
|
69
|
+
if (this.alertLog) this.#openAlerts();
|
|
70
|
+
else this.#togglePanel();
|
|
49
71
|
});
|
|
50
72
|
|
|
73
|
+
if (this.alertLog) {
|
|
74
|
+
this.triggerEl.setAttribute('data-tooltip', 'Alerts');
|
|
75
|
+
this.triggerEl.setAttribute('aria-label', 'Alerts');
|
|
76
|
+
this._subscriptions.push(this.alertLog.subscribe(() => this.#updateBadge()));
|
|
77
|
+
const summary = this.eventBus?.on?.('notification:summary', () => this.#openAlerts());
|
|
78
|
+
if (summary) this._subscriptions.push(() => summary.dispose?.());
|
|
79
|
+
this.#updateBadge();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
51
83
|
// Sync initial count so existing history doesn't show as unread
|
|
52
84
|
this.lastSeenCount = this.notificationCenter?.history?.length ?? 0;
|
|
53
85
|
|
|
@@ -81,12 +113,30 @@ export class NotificationHistory {
|
|
|
81
113
|
});
|
|
82
114
|
}
|
|
83
115
|
|
|
116
|
+
/** The Alerts tab, asked for on the bus — or the embedder's own verb. */
|
|
117
|
+
#openAlerts() {
|
|
118
|
+
if (this.onOpen) {
|
|
119
|
+
try { this.onOpen(); } catch (err) { this.logger.warn?.('[NotificationHistory] onOpen failed', err); }
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.eventBus?.emit?.('bottompanel:show', { tab: this.alertsTab });
|
|
123
|
+
}
|
|
124
|
+
|
|
84
125
|
#updateBadge() {
|
|
85
126
|
const historyLen = this.notificationCenter?.history?.length ?? 0;
|
|
86
|
-
const unread =
|
|
127
|
+
const unread = this.alertLog
|
|
128
|
+
? this.alertLog.unreadCount()
|
|
129
|
+
: Math.max(0, historyLen - this.lastSeenCount);
|
|
87
130
|
|
|
88
131
|
if (!this.badgeEl) return;
|
|
89
132
|
|
|
133
|
+
if (this.alertLog) {
|
|
134
|
+
const level = unread > 0 ? this.alertLog.worstUnread() : null;
|
|
135
|
+
this.badgeEl.className = 'twm-notification-history__badge'
|
|
136
|
+
+ (level ? ` twm-notification-history__badge--${level}` : '');
|
|
137
|
+
this.triggerEl?.classList.toggle('twm-notification-history__trigger--unread', unread > 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
90
140
|
if (unread > 0) {
|
|
91
141
|
this.badgeEl.textContent = unread > 99 ? '99+' : String(unread);
|
|
92
142
|
this.badgeEl.style.display = '';
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
* it actually overflows. They fade out a moment after the pointer leaves
|
|
22
22
|
* or the scrolling stops. Nested scroll areas each show their own.
|
|
23
23
|
* - The thumb drags and the track pages, as a native bar does.
|
|
24
|
+
* - The layer is above everything, so a bar would also draw over whatever
|
|
25
|
+
* covers its element — a popover, a menu, a dialog. A bar whose track is
|
|
26
|
+
* covered anywhere is not drawn; it comes back when it is uncovered.
|
|
24
27
|
*
|
|
25
28
|
* Discovery is lazy: the pointer and scroll events name the elements worth
|
|
26
29
|
* measuring, so nothing walks the DOM looking for scroll areas.
|
|
@@ -49,6 +52,22 @@ export function thumbGeometry(scrollSize, clientSize, scrollPos, trackLen) {
|
|
|
49
52
|
return { size, pos };
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Whether something else covers `el` at any of the points a bar runs along.
|
|
57
|
+
* Pure, so it can be tested without a layout engine.
|
|
58
|
+
*
|
|
59
|
+
* @param {Element} el
|
|
60
|
+
* @param {Element[][]} stacks what `elementsFromPoint` gave at each point,
|
|
61
|
+
* topmost first. The overlay layer's own bars are skipped.
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function barCovered(el, stacks) {
|
|
65
|
+
return stacks.some((stack) => {
|
|
66
|
+
const top = (stack || []).find((h) => !h.closest?.('.twm-autoscroll-layer'));
|
|
67
|
+
return Boolean(top) && top !== el && !el.contains(top);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
/** Whether an element scrolls on an axis: its overflow allows it and its
|
|
53
72
|
* content is larger than its box. */
|
|
54
73
|
function scrollsOn(el, axis) {
|
|
@@ -151,6 +170,9 @@ export function installAutoScrollbars(root = document.body, { exclude = null } =
|
|
|
151
170
|
return rec;
|
|
152
171
|
};
|
|
153
172
|
|
|
173
|
+
const coveredAlong = (el, points) => typeof doc.elementsFromPoint === 'function'
|
|
174
|
+
&& barCovered(el, points.map(([x, y]) => doc.elementsFromPoint(x, y)));
|
|
175
|
+
|
|
154
176
|
/** Place and size an element's bars; drop the element when it is gone. */
|
|
155
177
|
const place = (el) => {
|
|
156
178
|
const rec = managed.get(el);
|
|
@@ -178,12 +200,16 @@ export function installAutoScrollbars(root = document.body, { exclude = null } =
|
|
|
178
200
|
const g = thumbGeometry(el.scrollHeight * z, el.clientHeight * z, el.scrollTop * z, len);
|
|
179
201
|
if (!g) { bar.style.display = 'none'; continue; }
|
|
180
202
|
Object.assign(thumb.style, { top: `${g.pos}px`, height: `${g.size}px`, left: '0', width: '100%' });
|
|
203
|
+
const x = right - BAR / 2 - 1;
|
|
204
|
+
if (coveredAlong(el, [[x, top + 2], [x, top + len / 2], [x, bottom - 2]])) bar.style.display = 'none';
|
|
181
205
|
} else {
|
|
182
206
|
const len = right - left;
|
|
183
207
|
Object.assign(bar.style, { left: `${left}px`, top: `${bottom - BAR - 1}px`, width: `${len}px`, height: `${BAR}px` });
|
|
184
208
|
const g = thumbGeometry(el.scrollWidth * z, el.clientWidth * z, el.scrollLeft * z, len);
|
|
185
209
|
if (!g) { bar.style.display = 'none'; continue; }
|
|
186
210
|
Object.assign(thumb.style, { left: `${g.pos}px`, width: `${g.size}px`, top: '0', height: '100%' });
|
|
211
|
+
const y = bottom - BAR / 2 - 1;
|
|
212
|
+
if (coveredAlong(el, [[left + 2, y], [left + len / 2, y], [right - 2, y]])) bar.style.display = 'none';
|
|
187
213
|
}
|
|
188
214
|
}
|
|
189
215
|
};
|
|
@@ -256,6 +282,9 @@ export function installAutoScrollbars(root = document.body, { exclude = null } =
|
|
|
256
282
|
doc.addEventListener('mouseout', onLeaveWindow, true);
|
|
257
283
|
doc.addEventListener('scroll', onScroll, true);
|
|
258
284
|
win.addEventListener('resize', onResize);
|
|
285
|
+
// A popover or dialog takes the focus as it opens, often over a bar that
|
|
286
|
+
// is showing because the pointer has not moved: look again once it is drawn.
|
|
287
|
+
doc.addEventListener('focusin', onResize, true);
|
|
259
288
|
|
|
260
289
|
return {
|
|
261
290
|
dispose() {
|
|
@@ -263,6 +292,7 @@ export function installAutoScrollbars(root = document.body, { exclude = null } =
|
|
|
263
292
|
doc.removeEventListener('mouseout', onLeaveWindow, true);
|
|
264
293
|
doc.removeEventListener('scroll', onScroll, true);
|
|
265
294
|
win.removeEventListener('resize', onResize);
|
|
295
|
+
doc.removeEventListener('focusin', onResize, true);
|
|
266
296
|
for (const el of [...managed.keys()]) drop(el);
|
|
267
297
|
root.classList.remove(ROOT_CLASS);
|
|
268
298
|
},
|