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.
@@ -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 = document.querySelector('.twm-global-bottom-bar .twm-bar-right');
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.#togglePanel();
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 = Math.max(0, historyLen - this.lastSeenCount);
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
  },