@nommos/core 0.0.44 → 0.0.45
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 +83 -0
- package/package.json +1 -1
- package/src/lib/constants.d.ts +8 -7
- package/src/lib/constants.js +6 -5
- package/src/lib/constants.js.map +1 -1
- package/src/lib/popup-inbox.d.ts +35 -1
- package/src/lib/popup-inbox.js +94 -26
- package/src/lib/popup-inbox.js.map +1 -1
- package/src/lib/popup-viewer.d.ts +107 -54
- package/src/lib/popup-viewer.js +479 -287
- package/src/lib/popup-viewer.js.map +1 -1
- package/src/lib/relative-time.d.ts +40 -0
- package/src/lib/relative-time.js +59 -0
- package/src/lib/relative-time.js.map +1 -0
- package/src/lib/types.d.ts +11 -0
package/src/lib/popup-viewer.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { POPUP_ANCHOR_SELECTOR,
|
|
1
|
+
import { POPUP_ANCHOR_SELECTOR, POPUP_MOBILE_BREAKPOINT_PX, POPUP_SCHEMA_VERSION, POPUP_SOUND_KEY, } from './constants';
|
|
2
2
|
import { escapeHtml, sanitizeUrl } from './dom-safe';
|
|
3
3
|
import { resolveLanguage } from './i18n';
|
|
4
|
+
import { formatAbsolute, formatRelative } from './relative-time';
|
|
4
5
|
const HOST_ID = 'nommos-popup-host';
|
|
5
6
|
const LAUNCHER_HOST_ID = 'nommos-popup-launcher';
|
|
6
|
-
/**
|
|
7
|
-
const
|
|
7
|
+
/** Tray height budget, and the figure the flip decision is measured against. */
|
|
8
|
+
const TRAY_MAX_HEIGHT_PX = 420;
|
|
9
|
+
const TRAY_WIDTH_PX = 340;
|
|
10
|
+
/** Breathing room between the bell and the panel, and between the panel and the viewport edge. */
|
|
11
|
+
const TRAY_GAP_PX = 10;
|
|
8
12
|
/** Module-level so the "you have more than one anchor" advice is given once per page, not per render. */
|
|
9
13
|
let warnedMultipleAnchors = false;
|
|
10
14
|
/**
|
|
@@ -26,37 +30,68 @@ const BELL_ICON = `
|
|
|
26
30
|
* <p>There is deliberately no "cancel" label. Closing already has three affordances — the × chip,
|
|
27
31
|
* the backdrop and Escape — and a fourth labelled button sat beside the template's own CTAs as a
|
|
28
32
|
* peer action rather than an escape hatch. `dismiss` is the only chrome action, and it is terminal.
|
|
33
|
+
*
|
|
34
|
+
* <p>The relative-time forms are compact ("11h", not "11 hours ago") for two reasons: a tray row has
|
|
35
|
+
* the width for one and not the other, and the compact forms happen to be plural-invariant in all
|
|
36
|
+
* three languages, which is what lets this table stay a table instead of pulling in plural rules.
|
|
29
37
|
*/
|
|
30
38
|
const POPUP_COPY = {
|
|
31
39
|
en: {
|
|
32
40
|
close: 'Close',
|
|
33
41
|
dismiss: "Don't show again",
|
|
34
|
-
prev: 'Previous message',
|
|
35
|
-
next: 'Next message',
|
|
36
42
|
dialog: 'Message',
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
launcher: 'Notifications',
|
|
44
|
+
launcherUnread: 'Notifications ({count} unread)',
|
|
45
|
+
tray: 'Notifications',
|
|
46
|
+
trayEmpty: "You're all caught up",
|
|
47
|
+
markAllSeen: 'Mark all as seen',
|
|
48
|
+
dismissRow: 'Dismiss {title}',
|
|
49
|
+
soundOn: 'Mute notification sounds',
|
|
50
|
+
soundOff: 'Unmute notification sounds',
|
|
51
|
+
unread: 'Unread',
|
|
52
|
+
timeNow: 'now',
|
|
53
|
+
timeMinutes: '{n}m',
|
|
54
|
+
timeHours: '{n}h',
|
|
55
|
+
timeYesterday: 'yesterday',
|
|
56
|
+
timeDays: '{n}d',
|
|
40
57
|
},
|
|
41
58
|
fr: {
|
|
42
59
|
close: 'Fermer',
|
|
43
60
|
dismiss: 'Ne plus afficher',
|
|
44
|
-
prev: 'Message précédent',
|
|
45
|
-
next: 'Message suivant',
|
|
46
61
|
dialog: 'Message',
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
launcher: 'Notifications',
|
|
63
|
+
launcherUnread: 'Notifications ({count} non lues)',
|
|
64
|
+
tray: 'Notifications',
|
|
65
|
+
trayEmpty: 'Vous êtes à jour',
|
|
66
|
+
markAllSeen: 'Tout marquer comme lu',
|
|
67
|
+
dismissRow: 'Ignorer {title}',
|
|
68
|
+
soundOn: 'Couper les sons de notification',
|
|
69
|
+
soundOff: 'Activer les sons de notification',
|
|
70
|
+
unread: 'Non lu',
|
|
71
|
+
timeNow: "à l'instant",
|
|
72
|
+
timeMinutes: '{n} min',
|
|
73
|
+
timeHours: '{n} h',
|
|
74
|
+
timeYesterday: 'hier',
|
|
75
|
+
timeDays: '{n} j',
|
|
50
76
|
},
|
|
51
77
|
es: {
|
|
52
78
|
close: 'Cerrar',
|
|
53
79
|
dismiss: 'No volver a mostrar',
|
|
54
|
-
prev: 'Mensaje anterior',
|
|
55
|
-
next: 'Mensaje siguiente',
|
|
56
80
|
dialog: 'Mensaje',
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
launcher: 'Notificaciones',
|
|
82
|
+
launcherUnread: 'Notificaciones ({count} sin leer)',
|
|
83
|
+
tray: 'Notificaciones',
|
|
84
|
+
trayEmpty: 'Estás al día',
|
|
85
|
+
markAllSeen: 'Marcar todo como leído',
|
|
86
|
+
dismissRow: 'Descartar {title}',
|
|
87
|
+
soundOn: 'Silenciar los sonidos de notificación',
|
|
88
|
+
soundOff: 'Activar los sonidos de notificación',
|
|
89
|
+
unread: 'Sin leer',
|
|
90
|
+
timeNow: 'ahora',
|
|
91
|
+
timeMinutes: '{n} min',
|
|
92
|
+
timeHours: '{n} h',
|
|
93
|
+
timeYesterday: 'ayer',
|
|
94
|
+
timeDays: '{n} d',
|
|
60
95
|
},
|
|
61
96
|
};
|
|
62
97
|
export class PopupViewer {
|
|
@@ -67,28 +102,46 @@ export class PopupViewer {
|
|
|
67
102
|
this.state = 'hidden';
|
|
68
103
|
this.launcherHost = null;
|
|
69
104
|
this.launcherRoot = null;
|
|
70
|
-
/** The element the launcher is
|
|
105
|
+
/** The element the launcher is mounted into. `null` means no anchor, so no launcher at all. */
|
|
71
106
|
this.anchor = null;
|
|
72
107
|
this.domObserver = null;
|
|
73
108
|
this.remountQueued = false;
|
|
74
109
|
/** Resolved once per viewer: the visitor's language does not change mid-page. */
|
|
75
|
-
this.
|
|
110
|
+
this.lang = resolveLanguage();
|
|
111
|
+
this.copy = POPUP_COPY[this.lang];
|
|
76
112
|
this.items = [];
|
|
77
|
-
/**
|
|
113
|
+
/** Which popup the modal is showing. Set by {@link open}; the tray is what moves between them. */
|
|
78
114
|
this.index = 0;
|
|
79
|
-
/**
|
|
80
|
-
* Where the visitor has navigated to, which runs ahead of {@link index} while a load is in
|
|
81
|
-
* flight. Without it, two quick taps on `›` both step from the same rendered index and the
|
|
82
|
-
* second is swallowed.
|
|
83
|
-
*/
|
|
84
|
-
this.pendingIndex = 0;
|
|
85
|
-
/** Guards against a slow content load landing after a later move and rewinding the carousel. */
|
|
86
|
-
this.swapToken = 0;
|
|
87
115
|
this.lastFocused = null;
|
|
88
|
-
this.touchStartX = null;
|
|
89
116
|
this.audio = null;
|
|
117
|
+
/**
|
|
118
|
+
* Tray visibility, deliberately separate from {@link ViewerState}.
|
|
119
|
+
*
|
|
120
|
+
* <p>That enum models the modal's animated open/close and its awaited content load. The tray has
|
|
121
|
+
* neither, and folding it in would force every `state !== 'visible'` guard in this file to be
|
|
122
|
+
* re-reasoned for a surface those guards were never about.
|
|
123
|
+
*/
|
|
124
|
+
this.trayOpen = false;
|
|
90
125
|
this.onKeyDown = (event) => this.handleKeyDown(event);
|
|
91
126
|
this.onViewportChange = () => this.renderBodyIfVisible();
|
|
127
|
+
/**
|
|
128
|
+
* Registered on `document` only while the tray is open, in the capture phase.
|
|
129
|
+
*
|
|
130
|
+
* <p>`event.target` is useless here: a click inside a shadow root retargets to the host by the
|
|
131
|
+
* time it reaches `document`. `composedPath()` is the only reliable cross-boundary test.
|
|
132
|
+
*/
|
|
133
|
+
this.onDocumentPointerDown = (event) => {
|
|
134
|
+
if (!this.trayOpen)
|
|
135
|
+
return;
|
|
136
|
+
if (this.launcherHost && event.composedPath().includes(this.launcherHost))
|
|
137
|
+
return;
|
|
138
|
+
this.closeTray();
|
|
139
|
+
};
|
|
140
|
+
/** The tray is positioned from the bell's box, so it has to follow the bell. */
|
|
141
|
+
this.onTrayReflow = () => {
|
|
142
|
+
if (this.trayOpen)
|
|
143
|
+
this.positionTray();
|
|
144
|
+
};
|
|
92
145
|
// `pagehide` rather than `beforeunload`: it fires on mobile backgrounding and is bfcache-safe,
|
|
93
146
|
// where `beforeunload` is skipped on both.
|
|
94
147
|
this.onPageHide = () => this.handlePageHide();
|
|
@@ -97,12 +150,11 @@ export class PopupViewer {
|
|
|
97
150
|
setItems(items) {
|
|
98
151
|
this.items = items;
|
|
99
152
|
this.index = items.length === 0 ? 0 : Math.min(this.index, items.length - 1);
|
|
100
|
-
// A reordered or shortened inbox invalidates any navigation still in flight.
|
|
101
|
-
this.pendingIndex = this.index;
|
|
102
153
|
if (items.length === 0) {
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// layout around the gap
|
|
154
|
+
// With no anchor there is no launcher and no tray, so an empty inbox leaves nothing that
|
|
155
|
+
// could ever be shown — remove every trace. Anchored is different: the tenant reserved that
|
|
156
|
+
// slot, and vacating it collapses their layout around the gap, so the launcher stays mounted
|
|
157
|
+
// and the tray renders its empty state.
|
|
106
158
|
if (this.resolveAnchor() === null) {
|
|
107
159
|
this.teardown();
|
|
108
160
|
return;
|
|
@@ -112,6 +164,11 @@ export class PopupViewer {
|
|
|
112
164
|
}
|
|
113
165
|
this.ensureHost();
|
|
114
166
|
this.renderLauncher();
|
|
167
|
+
// Emptying the list while the tray is open swaps in the empty state rather than yanking the
|
|
168
|
+
// panel out from under the visitor — they are standing in it, and it was very likely their own
|
|
169
|
+
// dismissal that emptied it.
|
|
170
|
+
if (this.trayOpen)
|
|
171
|
+
this.renderTray();
|
|
115
172
|
if (this.state === 'visible') {
|
|
116
173
|
void this.renderCurrent();
|
|
117
174
|
}
|
|
@@ -126,6 +183,10 @@ export class PopupViewer {
|
|
|
126
183
|
window.removeEventListener('orientationchange', this.onViewportChange);
|
|
127
184
|
window.removeEventListener('pagehide', this.onPageHide);
|
|
128
185
|
}
|
|
186
|
+
// Before the hosts go: a leaked document listener holding a dead shadow root outlives the
|
|
187
|
+
// viewer it belonged to and answers for it.
|
|
188
|
+
this.detachTrayListeners();
|
|
189
|
+
this.trayOpen = false;
|
|
129
190
|
this.domObserver?.disconnect();
|
|
130
191
|
this.domObserver = null;
|
|
131
192
|
this.host?.remove();
|
|
@@ -137,7 +198,6 @@ export class PopupViewer {
|
|
|
137
198
|
this.anchor = null;
|
|
138
199
|
this.state = 'hidden';
|
|
139
200
|
this.index = 0;
|
|
140
|
-
this.pendingIndex = 0;
|
|
141
201
|
this.items = [];
|
|
142
202
|
}
|
|
143
203
|
isVisible() {
|
|
@@ -149,8 +209,11 @@ export class PopupViewer {
|
|
|
149
209
|
const target = id === undefined ? 0 : this.items.findIndex((item) => item.id === id);
|
|
150
210
|
if (target < 0)
|
|
151
211
|
return;
|
|
212
|
+
// The modal wins unconditionally. A HIGH/CRITICAL push arriving while the tray is open is
|
|
213
|
+
// interruptive by definition, and leaving both on screen would stack a panel over a backdrop.
|
|
214
|
+
if (this.trayOpen)
|
|
215
|
+
this.closeTray({ restoreFocus: false });
|
|
152
216
|
this.index = target;
|
|
153
|
-
this.pendingIndex = target;
|
|
154
217
|
this.ensureHost();
|
|
155
218
|
this.state = 'opening';
|
|
156
219
|
this.lastFocused = typeof document !== 'undefined' ? document.activeElement : null;
|
|
@@ -179,9 +242,9 @@ export class PopupViewer {
|
|
|
179
242
|
if (this.lastFocused instanceof HTMLElement) {
|
|
180
243
|
this.lastFocused.focus();
|
|
181
244
|
}
|
|
182
|
-
// Closing is
|
|
183
|
-
//
|
|
184
|
-
//
|
|
245
|
+
// Closing is not dismissing: the entry stays in the inbox, is never reported as dismissed, and
|
|
246
|
+
// can be reopened from the tray. Without an anchor there is no tray, so the way back is the
|
|
247
|
+
// next page load or the host app calling `openViewer`.
|
|
185
248
|
if (item)
|
|
186
249
|
this.callbacks.onClosed(item);
|
|
187
250
|
}
|
|
@@ -198,8 +261,6 @@ export class PopupViewer {
|
|
|
198
261
|
this.root = this.host.attachShadow({ mode: 'open' });
|
|
199
262
|
this.root.innerHTML = `<style>${CHROME_CSS}</style><div id="overlay" hidden></div>`;
|
|
200
263
|
this.root.addEventListener('click', (event) => this.handleClick(event));
|
|
201
|
-
this.root.addEventListener('touchstart', (event) => this.handleTouchStart(event), { passive: true });
|
|
202
|
-
this.root.addEventListener('touchend', (event) => this.handleTouchEnd(event), { passive: true });
|
|
203
264
|
document.addEventListener('keydown', this.onKeyDown);
|
|
204
265
|
window.addEventListener('resize', this.onViewportChange);
|
|
205
266
|
window.addEventListener('orientationchange', this.onViewportChange);
|
|
@@ -226,14 +287,18 @@ export class PopupViewer {
|
|
|
226
287
|
this.launcherHost = document.createElement('div');
|
|
227
288
|
this.launcherHost.id = LAUNCHER_HOST_ID;
|
|
228
289
|
this.launcherRoot = this.launcherHost.attachShadow({ mode: 'open' });
|
|
229
|
-
|
|
290
|
+
// Two sibling slots written once, because the two halves re-render on different schedules. The
|
|
291
|
+
// launcher is rewritten by every setItems, open, close, arrival and anchor change; doing that
|
|
292
|
+
// to a shared parent would tear an open tray out mid-interaction, along with its focus.
|
|
293
|
+
this.launcherRoot.innerHTML =
|
|
294
|
+
`<style>${LAUNCHER_CSS}</style><div id="launcher-slot"></div><div id="tray" hidden></div>`;
|
|
230
295
|
// `handleClick` dispatches on ids and `data-action` and does not care which root the event
|
|
231
296
|
// came from, so the second root reuses it verbatim.
|
|
232
297
|
this.launcherRoot.addEventListener('click', (event) => this.handleClick(event));
|
|
233
298
|
this.observeDom();
|
|
234
299
|
}
|
|
235
300
|
/**
|
|
236
|
-
* @returns the integrator's chosen mount point, or null
|
|
301
|
+
* @returns the integrator's chosen mount point, or null when they have not marked one.
|
|
237
302
|
*/
|
|
238
303
|
resolveAnchor() {
|
|
239
304
|
if (typeof document === 'undefined')
|
|
@@ -247,20 +312,32 @@ export class PopupViewer {
|
|
|
247
312
|
}
|
|
248
313
|
return matches[0] ?? null;
|
|
249
314
|
}
|
|
250
|
-
/**
|
|
315
|
+
/**
|
|
316
|
+
* Places the launcher host inside the integrator's anchor, or detaches it when there is none.
|
|
317
|
+
*
|
|
318
|
+
* <p>There is no floating fallback. Parking a bell in the corner of someone else's page is a
|
|
319
|
+
* decision only they can make: it lands on their layout, competes with their own widgets, and
|
|
320
|
+
* appears without them having asked for it. So the launcher exists exactly where a tenant marked
|
|
321
|
+
* a slot for it, and nowhere else.
|
|
322
|
+
*
|
|
323
|
+
* <p>Popups still arrive and still auto-open without an anchor — only the persistent bell and its
|
|
324
|
+
* tray are withheld. The cost is that a popup the visitor closes has no way back until the next
|
|
325
|
+
* page load, unless the host app calls {@code openViewer} itself.
|
|
326
|
+
*/
|
|
251
327
|
mountLauncher() {
|
|
252
328
|
if (!this.launcherHost || typeof document === 'undefined')
|
|
253
329
|
return;
|
|
254
330
|
const anchor = this.resolveAnchor();
|
|
255
|
-
|
|
256
|
-
|
|
331
|
+
if (!anchor) {
|
|
332
|
+
this.anchor = null;
|
|
333
|
+
this.closeTray({ restoreFocus: false });
|
|
334
|
+
this.launcherHost.remove();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (this.anchor === anchor && this.launcherHost.parentNode === anchor)
|
|
257
338
|
return;
|
|
258
339
|
this.anchor = anchor;
|
|
259
|
-
|
|
260
|
-
this.launcherHost.setAttribute('data-anchored', '');
|
|
261
|
-
else
|
|
262
|
-
this.launcherHost.removeAttribute('data-anchored');
|
|
263
|
-
parent.appendChild(this.launcherHost); // appendChild moves it; no explicit remove needed.
|
|
340
|
+
anchor.appendChild(this.launcherHost); // appendChild moves it; no explicit remove needed.
|
|
264
341
|
}
|
|
265
342
|
/**
|
|
266
343
|
* Follows the anchor for the life of the page. A one-shot query at init would almost always miss
|
|
@@ -281,9 +358,14 @@ export class PopupViewer {
|
|
|
281
358
|
this.remountQueued = false;
|
|
282
359
|
if (!this.launcherHost)
|
|
283
360
|
return;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
361
|
+
// Covers the anchor appearing late, being swapped on a route change, and disappearing —
|
|
362
|
+
// the last of which must take the launcher down with it rather than strand it in the body.
|
|
363
|
+
//
|
|
364
|
+
// The `isConnected` half is gated on there *being* an anchor on purpose: with none, the
|
|
365
|
+
// host is deliberately detached, and an ungated check would read its own handiwork as work
|
|
366
|
+
// still to do and re-render on every mutation the tenant's app makes.
|
|
367
|
+
const anchor = this.resolveAnchor();
|
|
368
|
+
if (anchor !== this.anchor || (anchor !== null && !this.launcherHost.isConnected)) {
|
|
287
369
|
this.renderLauncher();
|
|
288
370
|
}
|
|
289
371
|
};
|
|
@@ -295,19 +377,16 @@ export class PopupViewer {
|
|
|
295
377
|
this.domObserver.observe(document.body, { childList: true, subtree: true });
|
|
296
378
|
}
|
|
297
379
|
/**
|
|
298
|
-
*
|
|
299
|
-
* defaults, and that is deliberate.
|
|
380
|
+
* Draws the bell, but only where the tenant asked for one.
|
|
300
381
|
*
|
|
301
|
-
* <p
|
|
302
|
-
*
|
|
303
|
-
* it disappears. The cost is real: a read-but-undismissed popup then has no way back for the rest
|
|
304
|
-
* of the session, short of the host app calling {@link PopupInbox.openViewer}.
|
|
382
|
+
* <p>With no anchor there is no launcher at all — see {@link mountLauncher} for why there is no
|
|
383
|
+
* corner fallback.
|
|
305
384
|
*
|
|
306
|
-
* <p
|
|
307
|
-
* so vacating it leaves a hole their layout collapses around — a flex
|
|
308
|
-
* applies to a zero-width item — and
|
|
309
|
-
* than as
|
|
310
|
-
*
|
|
385
|
+
* <p>Where there is an anchor the bell is permanent, including once everything has been read. The
|
|
386
|
+
* tenant reserved that slot, so vacating it leaves a hole their layout collapses around — a flex
|
|
387
|
+
* or grid {@code gap} still applies to a zero-width item — and chrome that disappears reads as
|
|
388
|
+
* broken rather than as tidy. Persisting is also what keeps a way back to a popup closed by
|
|
389
|
+
* accident.
|
|
311
390
|
*/
|
|
312
391
|
renderLauncher() {
|
|
313
392
|
this.ensureLauncherHost();
|
|
@@ -315,17 +394,11 @@ export class PopupViewer {
|
|
|
315
394
|
const slot = this.launcherRoot?.getElementById('launcher-slot');
|
|
316
395
|
if (!slot)
|
|
317
396
|
return;
|
|
318
|
-
|
|
319
|
-
const anchored = this.anchor !== null;
|
|
320
|
-
if (!anchored && (unread === 0 || this.launcherHidden())) {
|
|
397
|
+
if (this.anchor === null) {
|
|
321
398
|
slot.innerHTML = '';
|
|
322
399
|
return;
|
|
323
400
|
}
|
|
324
|
-
|
|
325
|
-
// leaves a hole in their layout, and the corner-clutter it exists to solve isn't there.
|
|
326
|
-
const hide = anchored
|
|
327
|
-
? ''
|
|
328
|
-
: `<button id="launcher-hide" data-action="hide-launcher" aria-label="${escapeHtml(this.copy.hideLauncher)}">×</button>`;
|
|
401
|
+
const unread = this.items.filter((item) => item.status !== 'VIEWED').length;
|
|
329
402
|
// With nothing unread there is nothing to draw the eye to, so the badge and the wiggle both
|
|
330
403
|
// drop away and the bell sits quietly among the tenant's own header icons.
|
|
331
404
|
const badge = unread > 0 ? `<span id="badge">${escapeHtml(String(unread))}</span>` : '';
|
|
@@ -335,45 +408,191 @@ export class PopupViewer {
|
|
|
335
408
|
: this.copy.launcher;
|
|
336
409
|
slot.innerHTML = `
|
|
337
410
|
<div id="launcher-wrap" part="launcher-wrap">
|
|
338
|
-
<button id="launcher" part="launcher"${attention} aria-label="${escapeHtml(label)}"
|
|
411
|
+
<button id="launcher" part="launcher"${attention} aria-label="${escapeHtml(label)}"
|
|
412
|
+
aria-haspopup="dialog" aria-expanded="${this.trayOpen}" aria-controls="tray">
|
|
339
413
|
${BELL_ICON}
|
|
340
414
|
${badge}
|
|
341
415
|
</button>
|
|
342
|
-
${hide}
|
|
343
416
|
</div>`;
|
|
344
417
|
}
|
|
418
|
+
// ── Notification tray ──────────────────────────────────────────────────────
|
|
345
419
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
420
|
+
* The list of what is waiting, and the only way to move between popups.
|
|
421
|
+
*
|
|
422
|
+
* <p>It lives in the launcher's shadow root rather than the modal's because it has to track the
|
|
423
|
+
* bell, and {@link observeDom} re-parents that host into and out of the tenant's header on SPA
|
|
424
|
+
* route changes. In the modal's root it would need a permanent coordinate sync to follow.
|
|
425
|
+
*
|
|
426
|
+
* <p>Opening it is emphatically <em>not</em> opening a popup: no row here calls
|
|
427
|
+
* {@code POST /view}, and nothing marks a popup `VIEWED`. Seeing a title in a list is not reading
|
|
428
|
+
* the message, and conflating the two would make every campaign report a 100% open rate.
|
|
349
429
|
*/
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return false;
|
|
356
|
-
}
|
|
430
|
+
toggleTray() {
|
|
431
|
+
if (this.trayOpen)
|
|
432
|
+
this.closeTray();
|
|
433
|
+
else
|
|
434
|
+
this.openTray();
|
|
357
435
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
436
|
+
openTray() {
|
|
437
|
+
if (this.trayOpen || !this.launcherRoot)
|
|
438
|
+
return;
|
|
439
|
+
this.trayOpen = true;
|
|
440
|
+
this.renderTray();
|
|
441
|
+
this.positionTray();
|
|
442
|
+
this.attachTrayListeners();
|
|
443
|
+
this.syncLauncherExpanded();
|
|
444
|
+
const first = this.launcherRoot.querySelector('.np-row-open');
|
|
445
|
+
(first ?? this.launcherRoot.getElementById('tray'))?.focus();
|
|
446
|
+
this.callbacks.onTrayOpened([...this.items], this.items.filter((item) => item.status !== 'VIEWED').length);
|
|
447
|
+
}
|
|
448
|
+
closeTray(options = {}) {
|
|
449
|
+
if (!this.trayOpen)
|
|
450
|
+
return;
|
|
451
|
+
this.trayOpen = false;
|
|
452
|
+
this.detachTrayListeners();
|
|
453
|
+
this.launcherRoot?.getElementById('tray')?.setAttribute('hidden', '');
|
|
454
|
+
this.syncLauncherExpanded();
|
|
455
|
+
if (options.restoreFocus !== false) {
|
|
456
|
+
this.launcherRoot?.getElementById('launcher')?.focus();
|
|
364
457
|
}
|
|
365
|
-
|
|
366
|
-
|
|
458
|
+
}
|
|
459
|
+
/** Kept off {@link renderLauncher} so toggling the tray does not rebuild the bell. */
|
|
460
|
+
syncLauncherExpanded() {
|
|
461
|
+
this.launcherRoot?.getElementById('launcher')?.setAttribute('aria-expanded', String(this.trayOpen));
|
|
462
|
+
}
|
|
463
|
+
attachTrayListeners() {
|
|
464
|
+
if (typeof document === 'undefined')
|
|
465
|
+
return;
|
|
466
|
+
document.addEventListener('pointerdown', this.onDocumentPointerDown, true);
|
|
467
|
+
// Capture, because the scroll that moves the bell is very often on an inner container rather
|
|
468
|
+
// than the window, and those do not bubble.
|
|
469
|
+
window.addEventListener('scroll', this.onTrayReflow, { capture: true, passive: true });
|
|
470
|
+
window.addEventListener('resize', this.onTrayReflow);
|
|
471
|
+
}
|
|
472
|
+
detachTrayListeners() {
|
|
473
|
+
if (typeof document === 'undefined')
|
|
474
|
+
return;
|
|
475
|
+
document.removeEventListener('pointerdown', this.onDocumentPointerDown, true);
|
|
476
|
+
window.removeEventListener('scroll', this.onTrayReflow, true);
|
|
477
|
+
window.removeEventListener('resize', this.onTrayReflow);
|
|
478
|
+
}
|
|
479
|
+
renderTray() {
|
|
480
|
+
const tray = this.launcherRoot?.getElementById('tray');
|
|
481
|
+
if (!tray)
|
|
482
|
+
return;
|
|
483
|
+
const c = this.copy;
|
|
484
|
+
const unread = this.items.filter((item) => item.status !== 'VIEWED').length;
|
|
485
|
+
const muted = !PopupViewer.soundEnabled();
|
|
486
|
+
const soundLabel = muted ? c.soundOff : c.soundOn;
|
|
487
|
+
// Read once per render so every row in one paint agrees, and no timer has to tick on the
|
|
488
|
+
// tenant's page for the sake of "5m" becoming "6m".
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
const body = this.items.length
|
|
491
|
+
? `<ul id="tray-list" role="list">${this.items.map((item) => this.rowMarkup(item, now)).join('')}</ul>`
|
|
492
|
+
: `<div id="tray-empty">${escapeHtml(c.trayEmpty)}</div>`;
|
|
493
|
+
tray.setAttribute('role', 'dialog');
|
|
494
|
+
tray.setAttribute('aria-labelledby', 'tray-title');
|
|
495
|
+
tray.setAttribute('tabindex', '-1');
|
|
496
|
+
tray.setAttribute('part', 'tray');
|
|
497
|
+
tray.removeAttribute('hidden');
|
|
498
|
+
tray.innerHTML = `
|
|
499
|
+
<div id="tray-head">
|
|
500
|
+
<h2 id="tray-title">${escapeHtml(c.tray)}</h2>
|
|
501
|
+
<div id="tray-actions">
|
|
502
|
+
${unread > 0 ? `<button id="tray-mark-all" data-action="mark-all-seen">${escapeHtml(c.markAllSeen)}</button>` : ''}
|
|
503
|
+
<button id="tray-sound" data-action="toggle-sound"
|
|
504
|
+
aria-pressed="${muted}" aria-label="${escapeHtml(soundLabel)}"
|
|
505
|
+
title="${escapeHtml(soundLabel)}">${muted ? '🔕' : '🔔'}</button>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
${body}`;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* One row.
|
|
512
|
+
*
|
|
513
|
+
* <p>Everything interpolated here is tenant-authored — `previewText` is derived from their own
|
|
514
|
+
* popup body and routinely contains quotes and apostrophes, and `thumbnailUrl` is a free-text
|
|
515
|
+
* column — so the title, preview and time go through {@link escapeHtml} and the thumbnail through
|
|
516
|
+
* {@link sanitizeUrl}.
|
|
517
|
+
*
|
|
518
|
+
* <p>Each of the three optional parts is omitted rather than rendered blank when its data is
|
|
519
|
+
* missing. That covers both a new SDK talking to a backend without the fields and the tail of
|
|
520
|
+
* popups delivered before they existed.
|
|
521
|
+
*/
|
|
522
|
+
rowMarkup(item, now) {
|
|
523
|
+
const c = this.copy;
|
|
524
|
+
const title = item.title ?? c.dialog;
|
|
525
|
+
const thumb = sanitizeUrl(item.thumbnailUrl);
|
|
526
|
+
const relative = formatRelative(item.deliveredAtEpochMs, now, c, this.lang);
|
|
527
|
+
const absolute = formatAbsolute(item.deliveredAtEpochMs, this.lang);
|
|
528
|
+
const iso = item.deliveredAtEpochMs ? new Date(item.deliveredAtEpochMs).toISOString() : '';
|
|
529
|
+
const isUnread = item.status !== 'VIEWED';
|
|
530
|
+
return `
|
|
531
|
+
<li class="np-row${isUnread ? ' np-row-unread' : ''}" data-id="${item.id}">
|
|
532
|
+
<button class="np-row-open" data-action="open-popup" data-id="${item.id}">
|
|
533
|
+
${thumb ? `<img class="np-row-thumb" src="${escapeHtml(thumb)}" alt="" loading="lazy">` : ''}
|
|
534
|
+
<span class="np-row-text">
|
|
535
|
+
<span class="np-row-title">${escapeHtml(title)}</span>
|
|
536
|
+
${item.previewText ? `<span class="np-row-preview">${escapeHtml(item.previewText)}</span>` : ''}
|
|
537
|
+
${relative ? `<time class="np-row-time" datetime="${escapeHtml(iso)}" title="${escapeHtml(absolute)}">${escapeHtml(relative)}</time>` : ''}
|
|
538
|
+
</span>
|
|
539
|
+
${isUnread ? `<span class="np-row-dot"><span class="np-sr">${escapeHtml(c.unread)}</span></span>` : ''}
|
|
540
|
+
</button>
|
|
541
|
+
<button class="np-row-x" data-action="dismiss-row" data-id="${item.id}"
|
|
542
|
+
aria-label="${escapeHtml(c.dismissRow.replace('{title}', title))}">×</button>
|
|
543
|
+
</li>`;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Places the panel against the bell.
|
|
547
|
+
*
|
|
548
|
+
* <p>`position: fixed` with coordinates written here, rather than `absolute` inside the wrap:
|
|
549
|
+
* fixed descendants are not clipped by an ancestor's `overflow: hidden`, which is what a tenant's
|
|
550
|
+
* sticky header almost always has. It does not escape a `transform`ed ancestor — the same
|
|
551
|
+
* documented limit the modal already carries — so the anchor element must not sit inside one.
|
|
552
|
+
*
|
|
553
|
+
* <h3>Why a default decides and geometry only refines</h3>
|
|
554
|
+
* jsdom has no layout: every `getBoundingClientRect()` is zeroes. Geometry-first logic would read
|
|
555
|
+
* that as "there is room below" and be silently right in tests while being wrong for a bell in a
|
|
556
|
+
* footer or a bottom bar. Defaulting first means the untestable path is the *refinement*, never
|
|
557
|
+
* the rule.
|
|
558
|
+
*/
|
|
559
|
+
positionTray() {
|
|
560
|
+
const tray = this.launcherRoot?.getElementById('tray');
|
|
561
|
+
const launcher = this.launcherRoot?.getElementById('launcher');
|
|
562
|
+
if (!tray || !launcher || typeof window === 'undefined')
|
|
563
|
+
return;
|
|
564
|
+
// Anchors overwhelmingly sit in a header, with the whole page below them. Geometry corrects the
|
|
565
|
+
// exceptions — a footer or bottom bar — whenever the box is actually measurable.
|
|
566
|
+
const fallback = 'down';
|
|
567
|
+
const rect = launcher.getBoundingClientRect();
|
|
568
|
+
const measurable = rect.width > 0 || rect.height > 0;
|
|
569
|
+
let flip = fallback;
|
|
570
|
+
let left = 0;
|
|
571
|
+
let top = 0;
|
|
572
|
+
if (measurable) {
|
|
573
|
+
const fitsDown = rect.bottom + TRAY_MAX_HEIGHT_PX + TRAY_GAP_PX <= window.innerHeight;
|
|
574
|
+
const fitsUp = rect.top - TRAY_MAX_HEIGHT_PX - TRAY_GAP_PX >= 0;
|
|
575
|
+
flip = fitsDown && !fitsUp ? 'down' : fitsUp && !fitsDown ? 'up' : fallback;
|
|
576
|
+
// Right-align to a bell in the right half, so the panel opens inwards rather than off-screen.
|
|
577
|
+
const rightAligned = rect.left > window.innerWidth / 2;
|
|
578
|
+
const raw = rightAligned ? rect.right - TRAY_WIDTH_PX : rect.left;
|
|
579
|
+
left = Math.min(Math.max(raw, TRAY_GAP_PX), window.innerWidth - TRAY_WIDTH_PX - TRAY_GAP_PX);
|
|
580
|
+
top = flip === 'down' ? rect.bottom + TRAY_GAP_PX : rect.top - TRAY_GAP_PX - TRAY_MAX_HEIGHT_PX;
|
|
581
|
+
tray.style.left = `${Math.round(left)}px`;
|
|
582
|
+
tray.style.top = `${Math.round(Math.max(top, TRAY_GAP_PX))}px`;
|
|
367
583
|
}
|
|
584
|
+
tray.setAttribute('data-flip', flip);
|
|
368
585
|
}
|
|
369
586
|
/**
|
|
370
587
|
* A popup arrived while the visitor was already on the page. Without this the only signal is a
|
|
371
|
-
* small badge appearing
|
|
372
|
-
* chime. Callers suppress it for first load, cache restore and the auto-open path — see
|
|
588
|
+
* small badge appearing among header icons nobody is looking at, so shake the launcher and play a
|
|
589
|
+
* short chime. Callers suppress it for first load, cache restore and the auto-open path — see
|
|
373
590
|
* `PopupInbox.refresh`.
|
|
591
|
+
*
|
|
592
|
+
* <p>Silent with no anchor: there is no bell to shake. The chime still plays, since it is the one
|
|
593
|
+
* signal that does not need a button to land on.
|
|
374
594
|
*/
|
|
375
595
|
notifyArrival() {
|
|
376
|
-
this.setLauncherHidden(false);
|
|
377
596
|
this.renderLauncher();
|
|
378
597
|
const launcher = this.launcherRoot?.getElementById('launcher');
|
|
379
598
|
if (launcher) {
|
|
@@ -491,18 +710,14 @@ export class PopupViewer {
|
|
|
491
710
|
shell(item, body, content) {
|
|
492
711
|
const title = escapeHtml(content?.title ?? item.title ?? '');
|
|
493
712
|
const buttons = content?.buttons ?? item.buttons ?? [];
|
|
494
|
-
const many = this.items.length > 1;
|
|
495
713
|
const c = this.copy;
|
|
496
714
|
return `
|
|
497
715
|
<style id="variant-css"></style>
|
|
498
716
|
<div class="np-backdrop" data-action="close"></div>
|
|
499
717
|
<div class="np-modal" role="dialog" aria-modal="true" aria-label="${title || escapeHtml(c.dialog)}" tabindex="-1">
|
|
500
718
|
<button class="np-chip np-close" data-action="close" aria-label="${escapeHtml(c.close)}">×</button>
|
|
501
|
-
${many ? `<button class="np-chip np-nav np-prev" data-action="prev" aria-label="${escapeHtml(c.prev)}">‹</button>` : ''}
|
|
502
719
|
<div class="np-stage">${body}</div>
|
|
503
|
-
${many ? `<button class="np-chip np-nav np-next" data-action="next" aria-label="${escapeHtml(c.next)}">›</button>` : ''}
|
|
504
720
|
<div class="np-underbar">
|
|
505
|
-
${many ? `<div class="np-dots">${this.dotsMarkup()}</div>` : ''}
|
|
506
721
|
${this.ctaMarkup(buttons)}
|
|
507
722
|
<button class="np-dismiss" data-action="dismiss">${escapeHtml(c.dismiss)}</button>
|
|
508
723
|
</div>
|
|
@@ -519,11 +734,6 @@ export class PopupViewer {
|
|
|
519
734
|
})
|
|
520
735
|
.join('');
|
|
521
736
|
}
|
|
522
|
-
dotsMarkup() {
|
|
523
|
-
return this.items
|
|
524
|
-
.map((_, i) => `<span class="np-dot${i === this.index ? ' np-dot-on' : ''}"></span>`)
|
|
525
|
-
.join('');
|
|
526
|
-
}
|
|
527
737
|
isMobileViewport() {
|
|
528
738
|
return typeof window !== 'undefined' && window.innerWidth < POPUP_MOBILE_BREAKPOINT_PX;
|
|
529
739
|
}
|
|
@@ -538,14 +748,32 @@ export class PopupViewer {
|
|
|
538
748
|
for (const node of path) {
|
|
539
749
|
if (!(node instanceof HTMLElement))
|
|
540
750
|
continue;
|
|
751
|
+
// The bell now opens the list rather than a message — the visitor chooses which one.
|
|
541
752
|
if (node.id === 'launcher') {
|
|
542
|
-
|
|
753
|
+
this.toggleTray();
|
|
543
754
|
return;
|
|
544
755
|
}
|
|
545
756
|
const action = node.dataset['action'];
|
|
546
|
-
if (action === '
|
|
547
|
-
this.
|
|
548
|
-
this.
|
|
757
|
+
if (action === 'open-popup') {
|
|
758
|
+
const chosen = this.items.find((candidate) => candidate.id === Number(node.dataset['id']));
|
|
759
|
+
this.closeTray({ restoreFocus: false });
|
|
760
|
+
if (chosen)
|
|
761
|
+
void this.open(chosen.id, 'manual');
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (action === 'dismiss-row') {
|
|
765
|
+
const chosen = this.items.find((candidate) => candidate.id === Number(node.dataset['id']));
|
|
766
|
+
if (chosen)
|
|
767
|
+
this.callbacks.onDismissed(chosen, 'tray');
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (action === 'mark-all-seen') {
|
|
771
|
+
this.callbacks.onMarkAllSeen();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (action === 'toggle-sound') {
|
|
775
|
+
PopupViewer.setSoundEnabled(!PopupViewer.soundEnabled());
|
|
776
|
+
this.renderTray();
|
|
549
777
|
return;
|
|
550
778
|
}
|
|
551
779
|
if (action === 'close') {
|
|
@@ -554,11 +782,7 @@ export class PopupViewer {
|
|
|
554
782
|
}
|
|
555
783
|
if (action === 'dismiss') {
|
|
556
784
|
if (item)
|
|
557
|
-
this.callbacks.onDismissed(item);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
if (action === 'next' || action === 'prev') {
|
|
561
|
-
this.move(action);
|
|
785
|
+
this.callbacks.onDismissed(item, 'modal');
|
|
562
786
|
return;
|
|
563
787
|
}
|
|
564
788
|
if (action === 'cta' && item) {
|
|
@@ -589,112 +813,50 @@ export class PopupViewer {
|
|
|
589
813
|
this.close();
|
|
590
814
|
}
|
|
591
815
|
/**
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* <p>This used to re-render the whole modal through {@link renderCurrent}, which tore the shell
|
|
595
|
-
* down to a loading placeholder before awaiting the content. Content is cached after its first
|
|
596
|
-
* fetch, so the await almost always resolved on the next microtask — but "almost always" still
|
|
597
|
-
* paints one frame of collapsed spinner, which is what read as a flicker. Loading first and
|
|
598
|
-
* swapping second means the visitor never sees an intermediate state.
|
|
816
|
+
* The tray branch runs before the modal's `visible` guard, because the two surfaces are never open
|
|
817
|
+
* at once and Escape has to mean "close what is in front of me" for both.
|
|
599
818
|
*/
|
|
600
|
-
|
|
601
|
-
if (this.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
// Stepped from the pending position, not the rendered one, so a burst of taps accumulates.
|
|
606
|
-
const nextIndex = (this.pendingIndex + delta + this.items.length) % this.items.length;
|
|
607
|
-
const next = this.items[nextIndex];
|
|
608
|
-
if (!next)
|
|
609
|
-
return;
|
|
610
|
-
this.pendingIndex = nextIndex;
|
|
611
|
-
this.callbacks.onCarouselMove(from, direction);
|
|
612
|
-
// Spamming the arrows must not let a slow load land after a later one and rewind the carousel.
|
|
613
|
-
const token = ++this.swapToken;
|
|
614
|
-
void this.callbacks.loadContent(next.id).then((content) => {
|
|
615
|
-
if (token !== this.swapToken || this.state !== 'visible')
|
|
616
|
-
return;
|
|
617
|
-
if (!content) {
|
|
618
|
-
// The index deliberately has not moved: a popup that cannot render must not become the
|
|
619
|
-
// carousel's position, or the dots would point at something the visitor cannot see.
|
|
620
|
-
// Intent rewinds to match, so the next tap steps from what is actually on screen.
|
|
621
|
-
this.pendingIndex = this.index;
|
|
622
|
-
this.callbacks.onRenderFailed(next, 'content_unavailable');
|
|
819
|
+
handleKeyDown(event) {
|
|
820
|
+
if (this.trayOpen) {
|
|
821
|
+
if (event.key === 'Escape') {
|
|
822
|
+
event.preventDefault();
|
|
823
|
+
this.closeTray();
|
|
623
824
|
return;
|
|
624
825
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
this.callbacks.onOpened(next, 'manual');
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
/**
|
|
631
|
-
* Cross-slides the authored body, leaving the chrome untouched.
|
|
632
|
-
*
|
|
633
|
-
* <p>Only `.np-stage`'s children change, so the underbar, the chips and the dialog itself never
|
|
634
|
-
* re-render — the frame stays put while the content moves through it.
|
|
635
|
-
*/
|
|
636
|
-
swapBody(item, content, direction) {
|
|
637
|
-
const stage = this.root?.querySelector('.np-stage');
|
|
638
|
-
if (!stage)
|
|
639
|
-
return;
|
|
640
|
-
const template = document.createElement('div');
|
|
641
|
-
template.innerHTML = this.bodyFor(item, content);
|
|
642
|
-
const incoming = template.firstElementChild;
|
|
643
|
-
if (!incoming)
|
|
644
|
-
return;
|
|
645
|
-
// Drop any ghost still animating from a previous move, so rapid clicks cannot stack them.
|
|
646
|
-
stage.querySelectorAll('.np-ghost').forEach((ghost) => ghost.remove());
|
|
647
|
-
const outgoing = stage.querySelector('.np-body');
|
|
648
|
-
if (outgoing) {
|
|
649
|
-
if (this.prefersReducedMotion()) {
|
|
650
|
-
outgoing.remove();
|
|
651
|
-
}
|
|
652
|
-
else {
|
|
653
|
-
// Sheds `np-body` as it leaves: while it kept the class there were momentarily two bodies,
|
|
654
|
-
// and everything that reaches for "the body" got the one on its way out.
|
|
655
|
-
outgoing.classList.remove('np-body');
|
|
656
|
-
outgoing.classList.add('np-ghost', direction === 'next' ? 'np-leave-left' : 'np-leave-right');
|
|
657
|
-
const drop = () => outgoing.remove();
|
|
658
|
-
outgoing.addEventListener('animationend', drop, { once: true });
|
|
659
|
-
// `animationend` does not fire if the animation never starts or is cancelled — a hidden
|
|
660
|
-
// tab, a cancelled paint. Without this the ghost would sit over the popup for good.
|
|
661
|
-
window.setTimeout(drop, GHOST_TTL_MS);
|
|
826
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
|
|
827
|
+
this.moveTrayFocus(event);
|
|
662
828
|
}
|
|
829
|
+
// Tab is deliberately not trapped: a tray is a non-modal popover, and holding focus inside a
|
|
830
|
+
// header dropdown strands keyboard visitors in the tenant's own chrome.
|
|
831
|
+
return;
|
|
663
832
|
}
|
|
664
|
-
if (!this.prefersReducedMotion()) {
|
|
665
|
-
incoming.classList.add(direction === 'next' ? 'np-enter-right' : 'np-enter-left');
|
|
666
|
-
}
|
|
667
|
-
stage.appendChild(incoming);
|
|
668
|
-
// Both bodies share one `<style>`, so during the overlap the outgoing one is briefly styled by
|
|
669
|
-
// its successor's CSS. Visible only where two templates differ sharply, and the alternative —
|
|
670
|
-
// a second style element scoped per body — costs more than the artefact it removes.
|
|
671
|
-
this.applyVariantCss(content);
|
|
672
|
-
this.syncDots();
|
|
673
|
-
}
|
|
674
|
-
/** Repoints the dots without rebuilding them, so the underbar never repaints mid-swipe. */
|
|
675
|
-
syncDots() {
|
|
676
|
-
const dots = this.root?.querySelectorAll('.np-dot');
|
|
677
|
-
dots?.forEach((dot, i) => dot.classList.toggle('np-dot-on', i === this.index));
|
|
678
|
-
}
|
|
679
|
-
prefersReducedMotion() {
|
|
680
|
-
return (typeof window !== 'undefined' &&
|
|
681
|
-
typeof window.matchMedia === 'function' &&
|
|
682
|
-
window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
683
|
-
}
|
|
684
|
-
handleKeyDown(event) {
|
|
685
833
|
if (this.state !== 'visible')
|
|
686
834
|
return;
|
|
687
835
|
if (event.key === 'Escape') {
|
|
688
836
|
this.close();
|
|
689
837
|
return;
|
|
690
838
|
}
|
|
691
|
-
if (event.key === 'ArrowRight')
|
|
692
|
-
this.move('next');
|
|
693
|
-
if (event.key === 'ArrowLeft')
|
|
694
|
-
this.move('prev');
|
|
695
839
|
if (event.key === 'Tab')
|
|
696
840
|
this.trapFocus(event);
|
|
697
841
|
}
|
|
842
|
+
/** Roving focus between rows — the muscle memory the removed carousel arrows used to serve. */
|
|
843
|
+
moveTrayFocus(event) {
|
|
844
|
+
const rows = Array.from(this.launcherRoot?.querySelectorAll('.np-row-open') ?? []);
|
|
845
|
+
if (rows.length === 0)
|
|
846
|
+
return;
|
|
847
|
+
event.preventDefault();
|
|
848
|
+
const current = rows.indexOf(this.launcherRoot?.activeElement);
|
|
849
|
+
let next;
|
|
850
|
+
if (event.key === 'Home')
|
|
851
|
+
next = 0;
|
|
852
|
+
else if (event.key === 'End')
|
|
853
|
+
next = rows.length - 1;
|
|
854
|
+
else if (event.key === 'ArrowDown')
|
|
855
|
+
next = current < 0 ? 0 : (current + 1) % rows.length;
|
|
856
|
+
else
|
|
857
|
+
next = current < 0 ? rows.length - 1 : (current - 1 + rows.length) % rows.length;
|
|
858
|
+
rows[next].focus();
|
|
859
|
+
}
|
|
698
860
|
/** Keeps keyboard focus inside the dialog while it is open. */
|
|
699
861
|
trapFocus(event) {
|
|
700
862
|
const focusable = this.root?.querySelectorAll('a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
@@ -715,18 +877,6 @@ export class PopupViewer {
|
|
|
715
877
|
focusDialog() {
|
|
716
878
|
this.root?.querySelector('.np-modal')?.focus();
|
|
717
879
|
}
|
|
718
|
-
handleTouchStart(event) {
|
|
719
|
-
this.touchStartX = event.touches[0]?.clientX ?? null;
|
|
720
|
-
}
|
|
721
|
-
handleTouchEnd(event) {
|
|
722
|
-
if (this.touchStartX === null || this.items.length < 2)
|
|
723
|
-
return;
|
|
724
|
-
const delta = (event.changedTouches[0]?.clientX ?? this.touchStartX) - this.touchStartX;
|
|
725
|
-
this.touchStartX = null;
|
|
726
|
-
if (Math.abs(delta) < 50)
|
|
727
|
-
return;
|
|
728
|
-
this.move(delta < 0 ? 'next' : 'prev');
|
|
729
|
-
}
|
|
730
880
|
}
|
|
731
881
|
/**
|
|
732
882
|
* Chrome only. The authored body brings its own CSS, injected separately into `#variant-css`.
|
|
@@ -764,34 +914,9 @@ const CHROME_CSS = `
|
|
|
764
914
|
@media (max-width: 767px) {
|
|
765
915
|
.np-modal { width: 96vw; max-width: 96vw; max-height: 90dvh; }
|
|
766
916
|
}
|
|
767
|
-
/*
|
|
768
|
-
stretching to hold both at once. */
|
|
917
|
+
/* Owns the body's scroll container, and the min-height:0 that lets it actually shrink. */
|
|
769
918
|
.np-stage { position: relative; display: flex; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
|
770
919
|
.np-body { overflow: auto; flex: 1 1 auto; }
|
|
771
|
-
/* Out of flow, so the stage sizes to the incoming body alone. */
|
|
772
|
-
.np-ghost {
|
|
773
|
-
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
|
|
774
|
-
}
|
|
775
|
-
.np-enter-right { animation: np-in-right 260ms cubic-bezier(.22,.61,.36,1) both; }
|
|
776
|
-
.np-enter-left { animation: np-in-left 260ms cubic-bezier(.22,.61,.36,1) both; }
|
|
777
|
-
.np-leave-left { animation: np-out-left 260ms cubic-bezier(.55,.06,.68,.19) both; }
|
|
778
|
-
.np-leave-right { animation: np-out-right 260ms cubic-bezier(.55,.06,.68,.19) both; }
|
|
779
|
-
@keyframes np-in-right {
|
|
780
|
-
from { transform: translateX(28%); opacity: 0; }
|
|
781
|
-
to { transform: translateX(0); opacity: 1; }
|
|
782
|
-
}
|
|
783
|
-
@keyframes np-in-left {
|
|
784
|
-
from { transform: translateX(-28%); opacity: 0; }
|
|
785
|
-
to { transform: translateX(0); opacity: 1; }
|
|
786
|
-
}
|
|
787
|
-
@keyframes np-out-left {
|
|
788
|
-
from { transform: translateX(0); opacity: 1; }
|
|
789
|
-
to { transform: translateX(-28%); opacity: 0; }
|
|
790
|
-
}
|
|
791
|
-
@keyframes np-out-right {
|
|
792
|
-
from { transform: translateX(0); opacity: 1; }
|
|
793
|
-
to { transform: translateX(28%); opacity: 0; }
|
|
794
|
-
}
|
|
795
920
|
.np-surface { background: #fff; border-radius: 12px; padding: 24px; }
|
|
796
921
|
.np-fallback { font-size: 18px; text-align: center; }
|
|
797
922
|
.np-loading { text-align: center; color: #888; }
|
|
@@ -810,9 +935,6 @@ const CHROME_CSS = `
|
|
|
810
935
|
/* Inside the card's own corner rather than hanging off it: outside, the chip lands on the
|
|
811
936
|
tenant's page and reads as page furniture instead of part of the popup. */
|
|
812
937
|
.np-close { top: 10px; right: 10px; width: 30px; height: 30px; font-size: 18px; }
|
|
813
|
-
.np-nav { top: 50%; transform: translateY(-50%); width: 34px; height: 34px; font-size: 20px; }
|
|
814
|
-
.np-prev { left: 10px; }
|
|
815
|
-
.np-next { right: 10px; }
|
|
816
938
|
/* Attached to the card rather than floating below it: with no surface of its own the chrome
|
|
817
939
|
reads as stray page content, especially over a busy host page. */
|
|
818
940
|
.np-underbar {
|
|
@@ -832,13 +954,9 @@ const CHROME_CSS = `
|
|
|
832
954
|
font-size: 13px; cursor: pointer; text-decoration: underline; font-family: inherit;
|
|
833
955
|
padding: 0;
|
|
834
956
|
}
|
|
835
|
-
.np-dots { display: flex; gap: 6px; align-items: center; }
|
|
836
|
-
.np-dot { width: 7px; height: 7px; border-radius: 50%; background: rgba(255,255,255,.4); }
|
|
837
|
-
.np-dot-on { background: #fff; }
|
|
838
957
|
@media (prefers-reduced-motion: reduce) {
|
|
839
958
|
.np-modal, .np-backdrop { transition: none !important; animation: none !important; }
|
|
840
|
-
/*
|
|
841
|
-
query is re-evaluated must not animate either. */
|
|
959
|
+
/* The authored body brings its own CSS and may animate on its own account. */
|
|
842
960
|
.np-body { animation: none !important; }
|
|
843
961
|
}
|
|
844
962
|
`;
|
|
@@ -850,49 +968,123 @@ const CHROME_CSS = `
|
|
|
850
968
|
* resolve per shadow root, so a rule in one root cannot reference an animation declared in another.
|
|
851
969
|
*/
|
|
852
970
|
const LAUNCHER_CSS = `
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
971
|
+
/* \`all: initial\` computes \`display: inline\`, which would collapse the button's box inside the
|
|
972
|
+
tenant's own flow, so the display is restored here.
|
|
973
|
+
|
|
974
|
+
\`color\` is deliberately let back in past that same reset: the launcher has no surface of its
|
|
975
|
+
own, so the glyph has to take the tenant's text colour or it is invisible on a dark header. */
|
|
976
|
+
:host { all: initial; display: inline-block; vertical-align: middle; color: inherit; }
|
|
977
|
+
/* Positioning belongs to the tenant's layout — the launcher sits in a slot they marked. The wrap
|
|
978
|
+
keeps its own \`part\` so either half can be restyled from their page. */
|
|
979
|
+
#launcher-wrap { position: static; }
|
|
980
|
+
/* The launcher is one icon among the tenant's own header actions, not a widget sitting on top of
|
|
981
|
+
their page, so it carries no disc and imposes no colour of its own.
|
|
857
982
|
|
|
858
|
-
\`
|
|
859
|
-
|
|
860
|
-
be invisible on a dark header. Higher specificity than the \`:host\` reset above, so it wins. */
|
|
861
|
-
:host([data-anchored]) { display: inline-block; vertical-align: middle; color: inherit; }
|
|
862
|
-
/* The wrap carries the position so the hide chip has something to anchor to; the button keeps
|
|
863
|
-
its own \`part\` so a tenant can restyle either half from their page. Bottom-LEFT by default —
|
|
864
|
-
bottom-right is where our own chatbot embed and most third-party widgets live. */
|
|
865
|
-
#launcher-wrap { position: fixed; left: 20px; bottom: 20px; z-index: 2147483645; }
|
|
866
|
-
/* Anchored: hand positioning back to the tenant's layout. The badge still lands on the button's
|
|
867
|
-
corner because the button itself stays \`position: relative\`. */
|
|
868
|
-
:host([data-anchored]) #launcher-wrap { position: static; }
|
|
869
|
-
/* \`box-sizing\` and \`padding\` are explicit because the UA button stylesheet otherwise adds its
|
|
870
|
-
own padding outside a content-box width, inflating the circle past the size set here. */
|
|
983
|
+
\`box-sizing\` and \`padding\` are explicit because the UA button stylesheet otherwise adds its
|
|
984
|
+
own padding outside a content-box width, inflating the box past the size set here. */
|
|
871
985
|
#launcher {
|
|
872
986
|
position: relative; box-sizing: border-box; padding: 0;
|
|
873
987
|
display: flex; align-items: center; justify-content: center;
|
|
874
|
-
width:
|
|
875
|
-
background:
|
|
988
|
+
width: 36px; height: 36px; border-radius: 50%; border: 0; cursor: pointer;
|
|
989
|
+
background: transparent; color: inherit; font-size: 20px;
|
|
876
990
|
}
|
|
877
|
-
/* Sized in \`em\` so the
|
|
991
|
+
/* Sized in \`em\` so the glyph tracks the button if a tenant restyles its size through \`part\`. */
|
|
878
992
|
#launcher svg { width: 1.1em; height: 1.1em; display: block; }
|
|
879
|
-
/*
|
|
880
|
-
on top of their page: it drops the dark disc entirely and takes their text colour, so it
|
|
881
|
-
inherits their theme instead of imposing ours. The glyph runs larger than the floating
|
|
882
|
-
\`1.1em\` would give at this size, because without a surface behind it there is nothing to
|
|
883
|
-
anchor the eye. The badge keeps its red, which is the one thing that must not blend in. */
|
|
884
|
-
:host([data-anchored]) #launcher {
|
|
885
|
-
width: 36px; height: 36px; font-size: 20px;
|
|
886
|
-
background: transparent; color: inherit;
|
|
887
|
-
}
|
|
993
|
+
/* The badge keeps its red, which is the one thing that must not blend into the tenant's theme. */
|
|
888
994
|
#badge {
|
|
889
995
|
position: absolute; top: -4px; right: -4px; min-width: 20px; height: 20px;
|
|
890
996
|
border-radius: 10px; background: #e5484d; color: #fff; font-size: 12px; line-height: 20px;
|
|
891
997
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
998
|
+
|
|
999
|
+
/* ── Notification tray ──────────────────────────────────────────────────────
|
|
1000
|
+
\`fixed\`, with coordinates written by positionTray(): fixed descendants are not clipped by an
|
|
1001
|
+
ancestor's \`overflow: hidden\`, which is what a tenant's sticky header almost always carries.
|
|
1002
|
+
Both \`hidden\` and \`display: none\` — \`hidden\` alone loses to the \`display: flex\` below,
|
|
1003
|
+
and a laid-out empty panel would push the tenant's own header around. */
|
|
1004
|
+
#tray[hidden] { display: none; }
|
|
1005
|
+
#tray {
|
|
1006
|
+
position: fixed; z-index: 2147483645;
|
|
1007
|
+
box-sizing: border-box;
|
|
1008
|
+
width: ${TRAY_WIDTH_PX}px; max-width: calc(100vw - ${TRAY_GAP_PX * 2}px);
|
|
1009
|
+
max-height: ${TRAY_MAX_HEIGHT_PX}px;
|
|
1010
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
1011
|
+
background: #fff; color: #111;
|
|
1012
|
+
border-radius: 14px; border: 1px solid rgba(15,23,42,.10);
|
|
1013
|
+
box-shadow: 0 12px 32px rgba(0,0,0,.22);
|
|
1014
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1015
|
+
text-align: left;
|
|
1016
|
+
}
|
|
1017
|
+
/* The panel grows away from the bell, so the transform origin follows the flip. No JS needed to
|
|
1018
|
+
retrigger this: going from \`display: none\` to displayed restarts a CSS animation, which is
|
|
1019
|
+
exactly why this is an \`animation\` and not a \`transition\`. */
|
|
1020
|
+
#tray[data-flip="up"] { transform-origin: bottom center; animation: np-tray-in 140ms ease-out; }
|
|
1021
|
+
#tray[data-flip="down"] { transform-origin: top center; animation: np-tray-in 140ms ease-out; }
|
|
1022
|
+
@keyframes np-tray-in {
|
|
1023
|
+
from { opacity: 0; transform: scale(.96); }
|
|
1024
|
+
to { opacity: 1; transform: scale(1); }
|
|
1025
|
+
}
|
|
1026
|
+
#tray-head {
|
|
1027
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
1028
|
+
padding: 12px 14px; border-bottom: 1px solid rgba(15,23,42,.08); flex: 0 0 auto;
|
|
1029
|
+
}
|
|
1030
|
+
#tray-title { margin: 0; font-size: 15px; font-weight: 600; }
|
|
1031
|
+
#tray-actions { display: flex; align-items: center; gap: 6px; }
|
|
1032
|
+
#tray-mark-all {
|
|
1033
|
+
background: transparent; border: 0; padding: 0; cursor: pointer;
|
|
1034
|
+
color: #2563eb; font-size: 12px; font-family: inherit; text-decoration: underline;
|
|
1035
|
+
}
|
|
1036
|
+
#tray-sound {
|
|
1037
|
+
background: transparent; border: 0; padding: 2px; cursor: pointer;
|
|
1038
|
+
font-size: 14px; line-height: 1; font-family: inherit;
|
|
1039
|
+
}
|
|
1040
|
+
#tray-list { margin: 0; padding: 0; list-style: none; overflow-y: auto; flex: 1 1 auto; }
|
|
1041
|
+
#tray-empty { padding: 28px 14px; text-align: center; color: #6b7280; font-size: 13px; }
|
|
1042
|
+
.np-row { display: flex; align-items: flex-start; border-bottom: 1px solid rgba(15,23,42,.06); }
|
|
1043
|
+
.np-row:last-child { border-bottom: 0; }
|
|
1044
|
+
.np-row-open {
|
|
1045
|
+
flex: 1 1 auto; display: flex; align-items: flex-start; gap: 10px;
|
|
1046
|
+
padding: 12px 4px 12px 14px; background: transparent; border: 0; cursor: pointer;
|
|
1047
|
+
font-family: inherit; text-align: left; color: inherit; min-width: 0;
|
|
1048
|
+
}
|
|
1049
|
+
.np-row-open:hover { background: rgba(15,23,42,.035); }
|
|
1050
|
+
.np-row-thumb {
|
|
1051
|
+
width: 36px; height: 36px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
|
1052
|
+
}
|
|
1053
|
+
.np-row-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
|
|
1054
|
+
/* Two lines, then ellipsis: rows must stay a scannable list, and a long authored title would
|
|
1055
|
+
otherwise push the rest of the inbox off the panel. */
|
|
1056
|
+
.np-row-title {
|
|
1057
|
+
font-size: 13px; font-weight: 600; line-height: 1.35;
|
|
1058
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
|
1059
|
+
}
|
|
1060
|
+
.np-row-preview {
|
|
1061
|
+
font-size: 12px; color: #6b7280; line-height: 1.35;
|
|
1062
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
|
1063
|
+
}
|
|
1064
|
+
.np-row-time { font-size: 11px; color: #9ca3af; }
|
|
1065
|
+
.np-row-dot {
|
|
1066
|
+
width: 8px; height: 8px; border-radius: 50%; background: #2563eb;
|
|
1067
|
+
flex: 0 0 auto; margin-top: 6px;
|
|
1068
|
+
}
|
|
1069
|
+
/* Visually hidden but still announced — the dot alone carries no meaning for a screen reader. */
|
|
1070
|
+
.np-sr {
|
|
1071
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
1072
|
+
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
1073
|
+
}
|
|
1074
|
+
.np-row-x {
|
|
1075
|
+
flex: 0 0 auto; align-self: center; margin-right: 8px;
|
|
1076
|
+
width: 24px; height: 24px; border-radius: 50%; border: 0; padding: 0; cursor: pointer;
|
|
1077
|
+
background: transparent; color: #9ca3af; font-size: 15px; line-height: 1; font-family: inherit;
|
|
1078
|
+
}
|
|
1079
|
+
.np-row-x:hover { background: rgba(15,23,42,.07); color: #374151; }
|
|
1080
|
+
#tray :focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
|
|
1081
|
+
@media (prefers-color-scheme: dark) {
|
|
1082
|
+
#tray { background: #1f2430; color: #f3f4f6; border-color: rgba(255,255,255,.12); }
|
|
1083
|
+
#tray-head { border-bottom-color: rgba(255,255,255,.10); }
|
|
1084
|
+
.np-row { border-bottom-color: rgba(255,255,255,.08); }
|
|
1085
|
+
.np-row-open:hover { background: rgba(255,255,255,.05); }
|
|
1086
|
+
.np-row-preview { color: #9ca3af; }
|
|
1087
|
+
.np-row-x:hover { background: rgba(255,255,255,.10); color: #e5e7eb; }
|
|
896
1088
|
}
|
|
897
1089
|
/* Rings continuously for as long as anything is unread — the launcher only exists in that
|
|
898
1090
|
state, so there is no case where this animates over nothing. */
|
|
@@ -914,7 +1106,7 @@ const LAUNCHER_CSS = `
|
|
|
914
1106
|
100% { transform: scale(1); }
|
|
915
1107
|
}
|
|
916
1108
|
@media (prefers-reduced-motion: reduce) {
|
|
917
|
-
#launcher, #badge { transition: none !important; animation: none !important; }
|
|
1109
|
+
#launcher, #badge, #tray { transition: none !important; animation: none !important; }
|
|
918
1110
|
}
|
|
919
1111
|
`;
|
|
920
1112
|
//# sourceMappingURL=popup-viewer.js.map
|