@wcstack/notification 1.13.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.
@@ -0,0 +1,830 @@
1
+ const _config = {
2
+ autoTrigger: true,
3
+ triggerAttribute: "data-notifytarget",
4
+ tagNames: {
5
+ notify: "wcs-notify",
6
+ },
7
+ };
8
+ function deepFreeze(obj) {
9
+ if (obj === null || typeof obj !== "object")
10
+ return obj;
11
+ Object.freeze(obj);
12
+ for (const key of Object.keys(obj)) {
13
+ deepFreeze(obj[key]);
14
+ }
15
+ return obj;
16
+ }
17
+ function deepClone(obj) {
18
+ if (obj === null || typeof obj !== "object")
19
+ return obj;
20
+ const clone = {};
21
+ for (const key of Object.keys(obj)) {
22
+ clone[key] = deepClone(obj[key]);
23
+ }
24
+ return clone;
25
+ }
26
+ let frozenConfig = null;
27
+ // Internal, mutable live config used by the components/autoTrigger (they read it
28
+ // at call time so setConfig() takes effect without re-import). Typed as the
29
+ // readonly IConfig at the export boundary — the `as IConfig` is a compile-time
30
+ // view only and does NOT freeze the object, so this export must stay
31
+ // package-internal (it is not re-exported from exports.ts). Public consumers get
32
+ // the deep-frozen clone from getConfig() instead.
33
+ const config = _config;
34
+ function getConfig() {
35
+ if (!frozenConfig) {
36
+ frozenConfig = deepFreeze(deepClone(_config));
37
+ }
38
+ return frozenConfig;
39
+ }
40
+ function setConfig(partialConfig) {
41
+ if (typeof partialConfig.autoTrigger === "boolean") {
42
+ _config.autoTrigger = partialConfig.autoTrigger;
43
+ }
44
+ if (typeof partialConfig.triggerAttribute === "string") {
45
+ _config.triggerAttribute = partialConfig.triggerAttribute;
46
+ }
47
+ if (partialConfig.tagNames) {
48
+ Object.assign(_config.tagNames, partialConfig.tagNames);
49
+ }
50
+ frozenConfig = null;
51
+ }
52
+
53
+ /**
54
+ * Headless desktop-notification primitive. A thin, framework-agnostic wrapper
55
+ * around the Notifications API exposed through the wc-bindable protocol.
56
+ *
57
+ * Unlike `@wcstack/permission` (a read-only monitor — the Permissions API has no
58
+ * `request()`), the Notifications API *does* expose `Notification.requestPermission()`,
59
+ * so this node is self-contained: it both **requests/monitors** the permission and
60
+ * **shows** notifications. It is the first @wcstack node where the command-token
61
+ * (show: `notify`) and event-token (`click` / `close` / `show`) directions both
62
+ * live in one tag.
63
+ *
64
+ * - **request()** asks for the `notifications` permission (`Notification.requestPermission`).
65
+ * - **notify(title, options)** shows a notification and returns its identifying tag
66
+ * (a caller `options.tag`, or a generated `wcs-<n>`). It picks a backend per
67
+ * `mode`: the `Notification` constructor (desktop) or
68
+ * `ServiceWorkerRegistration.showNotification()` (mobile). `"auto"` prefers the
69
+ * constructor and falls back to the SW on a `TypeError`.
70
+ * - **close(tag) / closeAll()** dismiss notifications by tag / all.
71
+ * - Clicks flow back as the `wcs-notify:click` event: directly via the
72
+ * Notification's `onclick` (constructor), or via the SW helper's
73
+ * BroadcastChannel/postMessage relay (SW). `permission` mirrors the live grant.
74
+ *
75
+ * Failures never throw: they surface through `error` (and the `unsupported`
76
+ * permission state) so they flow into the declarative state.
77
+ */
78
+ class NotificationCore extends EventTarget {
79
+ static wcBindable = {
80
+ protocol: "wc-bindable",
81
+ version: 1,
82
+ properties: [
83
+ { name: "permission", event: "wcs-notify:permission-change" },
84
+ { name: "granted", event: "wcs-notify:permission-change", getter: (e) => e.detail === "granted" },
85
+ { name: "denied", event: "wcs-notify:permission-change", getter: (e) => e.detail === "denied" },
86
+ { name: "prompt", event: "wcs-notify:permission-change", getter: (e) => e.detail === "prompt" },
87
+ { name: "unsupported", event: "wcs-notify:permission-change", getter: (e) => e.detail === "unsupported" },
88
+ { name: "error", event: "wcs-notify:error" },
89
+ { name: "clicked", event: "wcs-notify:click", getter: (e) => e.detail },
90
+ { name: "closed", event: "wcs-notify:close", getter: (e) => e.detail },
91
+ { name: "shown", event: "wcs-notify:show", getter: (e) => e.detail },
92
+ ],
93
+ commands: [
94
+ { name: "request", async: true },
95
+ { name: "notify" },
96
+ { name: "close" },
97
+ { name: "closeAll" },
98
+ ],
99
+ };
100
+ _target;
101
+ _mode = "auto";
102
+ _permission = "prompt";
103
+ _error = null;
104
+ _lastClick = null;
105
+ _lastClose = null;
106
+ _lastShow = null;
107
+ // Live PermissionStatus (when the Permissions API can query `notifications`),
108
+ // kept so its `change` listener can be removed on dispose().
109
+ _permissionStatus = null;
110
+ // True once a permission subscription has been (or is being) established; reset
111
+ // by dispose(). Guards observe() so a reconnect re-queries while a redundant
112
+ // observe() on a live subscription does not.
113
+ _permissionSubscribed = false;
114
+ // Monotonic id of the current lifecycle. Bumped by every observe() and by
115
+ // dispose(). In-flight async work (permission query, SW show, inbound click)
116
+ // captures it and bails if stale, so a query/click that resolves after a
117
+ // disconnect — or after a rapid disconnect→reconnect — never mutates state or
118
+ // dispatches on a torn-down element.
119
+ _gen = 0;
120
+ // Resolves once the connect-time permission probe settles. The Shell exposes
121
+ // this as connectedCallbackPromise so SSR can await it before snapshotting.
122
+ _ready = Promise.resolve();
123
+ // Counter for auto-assigned tags when the caller omits one.
124
+ _idSeq = 0;
125
+ // Notifications created via the constructor backend, by tag, so close()/closeAll()
126
+ // can dismiss them. The SW backend has no handle (showNotification returns void),
127
+ // so its tags are tracked separately and closed via registration.getNotifications().
128
+ _constructed = new Map();
129
+ _swTags = new Set();
130
+ // Click subscription handles (SW relay).
131
+ _channel = null;
132
+ _serviceWorker = null;
133
+ _clicksSubscribed = false;
134
+ // Per-click ids already handled, to de-dup the two relay transports. FIFO-capped
135
+ // so a long session does not leak; the two transports always arrive in the same
136
+ // tick, so a small cap is ample.
137
+ _seenIds = [];
138
+ constructor(target) {
139
+ super();
140
+ this._target = target ?? this;
141
+ }
142
+ get permission() {
143
+ return this._permission;
144
+ }
145
+ get granted() {
146
+ return this._permission === "granted";
147
+ }
148
+ get denied() {
149
+ return this._permission === "denied";
150
+ }
151
+ get prompt() {
152
+ return this._permission === "prompt";
153
+ }
154
+ get unsupported() {
155
+ return this._permission === "unsupported";
156
+ }
157
+ get error() {
158
+ return this._error;
159
+ }
160
+ get clicked() {
161
+ return this._lastClick;
162
+ }
163
+ get closed() {
164
+ return this._lastClose;
165
+ }
166
+ get shown() {
167
+ return this._lastShow;
168
+ }
169
+ /** Resolves once the current (or initial) permission probe settles. */
170
+ get ready() {
171
+ return this._ready;
172
+ }
173
+ // --- State setters with event dispatch ---
174
+ _setPermission(state) {
175
+ if (this._permission === state)
176
+ return;
177
+ this._permission = state;
178
+ this._target.dispatchEvent(new CustomEvent("wcs-notify:permission-change", {
179
+ detail: state,
180
+ bubbles: true,
181
+ }));
182
+ }
183
+ _setError(error) {
184
+ if (this._error === error)
185
+ return;
186
+ this._error = error;
187
+ this._target.dispatchEvent(new CustomEvent("wcs-notify:error", {
188
+ detail: error,
189
+ bubbles: true,
190
+ }));
191
+ }
192
+ _emit(kind, detail) {
193
+ if (kind === "click")
194
+ this._lastClick = detail;
195
+ else if (kind === "close")
196
+ this._lastClose = detail;
197
+ else
198
+ this._lastShow = detail;
199
+ this._target.dispatchEvent(new CustomEvent(`wcs-notify:${kind}`, {
200
+ detail,
201
+ bubbles: true,
202
+ }));
203
+ }
204
+ // --- Public API ---
205
+ /**
206
+ * Start observing the `notifications` permission and subscribing to Service
207
+ * Worker click relays. `mode` selects the show backend (default `"auto"`).
208
+ * Idempotent while already subscribed: it only updates the stored mode; to
209
+ * restart, dispose() first. Returns a promise that resolves once the first
210
+ * permission probe settles, for SSR.
211
+ *
212
+ * Headless callers must call observe() to begin; the Shell calls it from
213
+ * connectedCallback once the element's attributes resolve.
214
+ */
215
+ observe(mode = "auto") {
216
+ this._mode = mode;
217
+ if (!this._permissionSubscribed) {
218
+ this._ready = this._initPermission();
219
+ this._subscribeClicks();
220
+ }
221
+ return this._ready;
222
+ }
223
+ /**
224
+ * Ask the user for the `notifications` permission. Resolves to the resulting
225
+ * (normalized) permission state. Never throws: an unavailable API resolves to
226
+ * `"unsupported"`.
227
+ */
228
+ async request() {
229
+ const api = this._api();
230
+ if (!api || typeof api.requestPermission !== "function") {
231
+ this._setPermission("unsupported");
232
+ return this._permission;
233
+ }
234
+ try {
235
+ const result = await api.requestPermission();
236
+ this._setPermission(this._normalize(result));
237
+ }
238
+ catch {
239
+ // Some legacy engines may reject; keep the current state rather than throw.
240
+ }
241
+ return this._permission;
242
+ }
243
+ /**
244
+ * Show a notification. Returns the identifying tag (the caller's `options.tag`,
245
+ * or a generated `wcs-<n>` when omitted). Never throws: when the API is
246
+ * unavailable or the permission is not granted it surfaces an `error` and
247
+ * returns an empty string.
248
+ */
249
+ notify(title, options = {}) {
250
+ if (!this._api()) {
251
+ this._setError(this._err("unsupported", "Notifications API is not available in this environment."));
252
+ return "";
253
+ }
254
+ if (this._permission !== "granted") {
255
+ this._setError(this._err("not-granted", "Notification permission is not granted; call request() first."));
256
+ return "";
257
+ }
258
+ if (typeof title !== "string") {
259
+ this._setError(this._err("invalid-title", "notify() requires a string title."));
260
+ return "";
261
+ }
262
+ const tag = (typeof options.tag === "string" && options.tag !== "") ? options.tag : this._nextId();
263
+ const payload = options.data;
264
+ const data = { __wcsId: tag, payload };
265
+ const backendOptions = { ...options, tag, data };
266
+ this._setError(null);
267
+ this._show(title, backendOptions, tag, payload);
268
+ return tag;
269
+ }
270
+ /** Dismiss the notification(s) with `tag` across both backends. */
271
+ close(tag) {
272
+ if (typeof tag !== "string" || tag === "")
273
+ return;
274
+ const n = this._constructed.get(tag);
275
+ if (n) {
276
+ n.close();
277
+ this._constructed.delete(tag);
278
+ }
279
+ if (this._swTags.has(tag)) {
280
+ this._closeSw(tag);
281
+ this._swTags.delete(tag);
282
+ }
283
+ }
284
+ /**
285
+ * Dismiss every notification this instance has shown. Scoped to this instance's
286
+ * own tags on both backends — the SW path closes each tracked tag individually
287
+ * rather than enumerating the whole origin, so it never dismisses notifications
288
+ * shown by another `<wcs-notify>` or by an unrelated code path.
289
+ */
290
+ closeAll() {
291
+ for (const n of this._constructed.values()) {
292
+ n.close();
293
+ }
294
+ this._constructed.clear();
295
+ for (const tag of this._swTags) {
296
+ this._closeSw(tag);
297
+ }
298
+ this._swTags.clear();
299
+ }
300
+ /**
301
+ * Detach permission and click subscriptions. Open notifications are intentionally
302
+ * **left on screen** (a notification outlives the page that posted it — that is
303
+ * the point); use close()/closeAll() to dismiss. Call from the Shell's
304
+ * disconnectedCallback. A later observe() resumes.
305
+ */
306
+ dispose() {
307
+ this._permissionSubscribed = false;
308
+ this._clicksSubscribed = false;
309
+ this._gen++;
310
+ if (this._permissionStatus) {
311
+ this._permissionStatus.removeEventListener("change", this._onPermissionChange);
312
+ this._permissionStatus = null;
313
+ }
314
+ if (this._channel) {
315
+ this._channel.removeEventListener("message", this._onInbound);
316
+ this._channel.close();
317
+ this._channel = null;
318
+ }
319
+ if (this._serviceWorker) {
320
+ this._serviceWorker.removeEventListener("message", this._onInbound);
321
+ this._serviceWorker = null;
322
+ }
323
+ }
324
+ // --- Internal: permission ---
325
+ _initPermission() {
326
+ const api = this._api();
327
+ if (!api) {
328
+ this._setPermission("unsupported");
329
+ // Intentionally does NOT set _permissionSubscribed: there is no permission
330
+ // listener to tear down, so a reconnect simply re-probes (idempotent — the
331
+ // same-value guard suppresses any redundant dispatch and no listener is ever
332
+ // attached). _subscribeClicks() is re-entered too, but its own
333
+ // _clicksSubscribed guard short-circuits the second pass, so no transport is
334
+ // double-subscribed. Mirrors @wcstack/permission's unsupported path.
335
+ return Promise.resolve();
336
+ }
337
+ this._permissionSubscribed = true;
338
+ // Prefer the Permissions API: it provides a live `change` event. Fall back to
339
+ // the static `Notification.permission` when it is absent or rejects the
340
+ // `notifications` descriptor.
341
+ if (typeof navigator !== "undefined" && navigator.permissions && typeof navigator.permissions.query === "function") {
342
+ const gen = ++this._gen;
343
+ return navigator.permissions.query({ name: "notifications" }).then((status) => {
344
+ if (gen !== this._gen)
345
+ return;
346
+ this._permissionStatus = status;
347
+ this._setPermission(this._normalize(status.state));
348
+ status.addEventListener("change", this._onPermissionChange);
349
+ }, () => {
350
+ if (gen !== this._gen)
351
+ return;
352
+ // Permissions API rejected the `notifications` descriptor — fall back to
353
+ // the static `Notification.permission` (api is in scope and non-null here).
354
+ this._setPermission(this._normalize(api.permission));
355
+ });
356
+ }
357
+ // No Permissions API: read the static permission once (no live change events).
358
+ this._setPermission(this._normalize(api.permission));
359
+ return Promise.resolve();
360
+ }
361
+ _onPermissionChange = (event) => {
362
+ const status = event.target;
363
+ this._setPermission(this._normalize(status.state));
364
+ };
365
+ // Normalize the Notifications API's `"default"` to `"prompt"` so this node shares
366
+ // the four-value surface of @wcstack/permission. The Permissions API already
367
+ // reports `"prompt"`, so it passes through unchanged.
368
+ _normalize(raw) {
369
+ if (raw === "default")
370
+ return "prompt";
371
+ if (raw === "granted" || raw === "denied" || raw === "prompt")
372
+ return raw;
373
+ return "prompt";
374
+ }
375
+ // --- Internal: showing ---
376
+ _show(title, options, tag, payload) {
377
+ if (this._mode === "sw") {
378
+ this._showViaSw(title, options, tag, payload);
379
+ return;
380
+ }
381
+ const handled = this._showViaConstructor(title, options, tag, payload);
382
+ if (handled)
383
+ return;
384
+ // Constructor threw TypeError (e.g. mobile, where `new Notification` is illegal).
385
+ if (this._mode === "auto") {
386
+ this._showViaSw(title, options, tag, payload);
387
+ }
388
+ else {
389
+ this._setError(this._err("show-failed", "new Notification() is not usable here and mode=\"constructor\" disallows the Service Worker fallback."));
390
+ }
391
+ }
392
+ // Returns false only when the constructor threw a TypeError (the signal to fall
393
+ // back to the SW backend); true when it showed or surfaced a non-TypeError error.
394
+ _showViaConstructor(title, options, tag, payload) {
395
+ const api = this._api();
396
+ const gen = this._gen;
397
+ let n;
398
+ try {
399
+ n = new api(title, options);
400
+ }
401
+ catch (e) {
402
+ if (e instanceof TypeError)
403
+ return false;
404
+ this._setError(this._err("show-failed", "Failed to create the notification."));
405
+ return true;
406
+ }
407
+ this._constructed.set(tag, n);
408
+ n.onshow = () => {
409
+ if (gen !== this._gen)
410
+ return;
411
+ this._emit("show", { tag, data: payload, action: "" });
412
+ };
413
+ n.onclick = () => {
414
+ if (gen !== this._gen)
415
+ return;
416
+ this._emit("click", { tag, data: payload, action: "" });
417
+ };
418
+ n.onclose = () => {
419
+ this._constructed.delete(tag);
420
+ if (gen !== this._gen)
421
+ return;
422
+ this._emit("close", { tag, data: payload, action: "" });
423
+ };
424
+ n.onerror = () => {
425
+ if (gen !== this._gen)
426
+ return;
427
+ this._setError(this._err("show-failed", "The notification failed to display."));
428
+ };
429
+ return true;
430
+ }
431
+ _showViaSw(title, options, tag, payload) {
432
+ const sw = navigator.serviceWorker;
433
+ if (!sw) {
434
+ this._setError(this._err("no-service-worker", "Service Worker is required to show this notification but is unavailable."));
435
+ return;
436
+ }
437
+ const gen = this._gen;
438
+ this._swTags.add(tag);
439
+ // A notification deliberately outlives the page (see § dispose), so we do NOT
440
+ // bail before showNotification on a stale gen — a notify() issued while
441
+ // connected still shows. The stale-gen guards only suppress dispatching the
442
+ // observable `show` / `error` back onto a torn-down element.
443
+ sw.ready
444
+ .then((registration) => registration.showNotification(title, options))
445
+ .then(() => {
446
+ if (gen !== this._gen)
447
+ return;
448
+ this._emit("show", { tag, data: payload, action: "" });
449
+ })
450
+ .catch(() => {
451
+ if (gen !== this._gen)
452
+ return;
453
+ this._setError(this._err("show-failed", "ServiceWorkerRegistration.showNotification() failed."));
454
+ });
455
+ }
456
+ // Close the SW notification(s) carrying `tag`. Always scoped to a single tag —
457
+ // both callers (close / closeAll) iterate their own tracked tags, so the whole
458
+ // origin is never enumerated.
459
+ _closeSw(tag) {
460
+ const sw = navigator.serviceWorker;
461
+ if (!sw)
462
+ return;
463
+ sw.ready.then((registration) => {
464
+ return registration.getNotifications({ tag }).then((list) => {
465
+ for (const n of list)
466
+ n.close();
467
+ });
468
+ }).catch(() => {
469
+ // Closing is best-effort; a failure to enumerate is not surfaced.
470
+ });
471
+ }
472
+ // --- Internal: click relay (SW) ---
473
+ _subscribeClicks() {
474
+ if (this._clicksSubscribed)
475
+ return;
476
+ this._clicksSubscribed = true;
477
+ if (typeof BroadcastChannel === "function") {
478
+ this._channel = new BroadcastChannel("wcs-notify");
479
+ this._channel.addEventListener("message", this._onInbound);
480
+ }
481
+ const sw = navigator.serviceWorker;
482
+ if (sw) {
483
+ this._serviceWorker = sw;
484
+ sw.addEventListener("message", this._onInbound);
485
+ }
486
+ }
487
+ _onInbound = (event) => {
488
+ const msg = event.data;
489
+ if (!msg || msg.__wcsNotify !== true)
490
+ return;
491
+ if (this._isDuplicate(msg.id))
492
+ return;
493
+ this._emit("click", { tag: msg.tag, data: this._unwrap(msg.data), action: msg.action });
494
+ };
495
+ _isDuplicate(id) {
496
+ if (this._seenIds.includes(id))
497
+ return true;
498
+ this._seenIds.push(id);
499
+ if (this._seenIds.length > 50)
500
+ this._seenIds.shift();
501
+ return false;
502
+ }
503
+ _unwrap(raw) {
504
+ if (raw !== null && typeof raw === "object" && "__wcsId" in raw) {
505
+ return raw.payload;
506
+ }
507
+ return raw;
508
+ }
509
+ // --- Internal: misc ---
510
+ // Resolve the global `Notification` constructor at call time (not cached) so
511
+ // tests can install/remove it and so unsupported environments report correctly.
512
+ _api() {
513
+ const g = globalThis;
514
+ return typeof g.Notification === "function" ? g.Notification : undefined;
515
+ }
516
+ _nextId() {
517
+ return `wcs-${++this._idSeq}`;
518
+ }
519
+ _err(error, message) {
520
+ return { error, message };
521
+ }
522
+ }
523
+
524
+ let registered = false;
525
+ function handleClick(event) {
526
+ const target = event.target;
527
+ if (!(target instanceof Element))
528
+ return;
529
+ // A misconfigured triggerAttribute (e.g. one with a space) makes the attribute
530
+ // selector invalid and closest() throw SyntaxError; guard so a bad config
531
+ // disables only this shortcut rather than killing every document click handler.
532
+ let triggerElement;
533
+ try {
534
+ triggerElement = target.closest(`[${config.triggerAttribute}]`);
535
+ }
536
+ catch {
537
+ return;
538
+ }
539
+ if (!triggerElement)
540
+ return;
541
+ const notifyId = triggerElement.getAttribute(config.triggerAttribute);
542
+ if (!notifyId)
543
+ return;
544
+ // Resolve the registered constructor at call time instead of importing Notify as
545
+ // a value, avoiding a components/Notify.ts ⇄ autoTrigger.ts cycle
546
+ // (Notify.connectedCallback() calls registerAutoTrigger()). instanceof against
547
+ // the customElements registry keeps the same identity guarantee.
548
+ const NotifyCtor = customElements.get(config.tagNames.notify);
549
+ const notifyElement = document.getElementById(notifyId);
550
+ if (!NotifyCtor || !(notifyElement instanceof NotifyCtor))
551
+ return;
552
+ // The title comes from the trigger element: an explicit `data-notifytitle`
553
+ // attribute wins, otherwise the element's trimmed text content. The body is an
554
+ // optional `data-notifybody`. This keeps the click-driven shortcut declarative
555
+ // without inventing a payload channel.
556
+ const explicit = triggerElement.getAttribute("data-notifytitle");
557
+ // `Element.textContent` is spec-guaranteed non-null (only Document / DocumentType
558
+ // nodes return null, never an Element), so the cast is sound and lets us avoid an
559
+ // unreachable `?? ""` branch. `triggerElement` is always an Element here.
560
+ const title = explicit !== null ? explicit : triggerElement.textContent.trim();
561
+ const body = triggerElement.getAttribute("data-notifybody");
562
+ notifyElement.notify(title, body !== null ? { body } : undefined);
563
+ }
564
+ function registerAutoTrigger() {
565
+ if (registered)
566
+ return;
567
+ registered = true;
568
+ document.addEventListener("click", handleClick);
569
+ }
570
+
571
+ /**
572
+ * `<wcs-notify>` — declarative desktop notifications. Wraps NotificationCore and
573
+ * exposes both directions in one tag:
574
+ *
575
+ * - **`notice`** (reactive input): writing a *changed* value shows a notification,
576
+ * suppressing same-value writes so it fires only when the bound source actually
577
+ * changes. The imperative `notify` command instead shows on demand (even the
578
+ * same text again). See `docs/notification-tag-design.md` § 2.
579
+ * - **`request` / `notify` / `close` / `closeAll`** commands (state → element).
580
+ * - per-notification options (`body` / `icon` / `badge` / `tag` / `lang` / `dir` /
581
+ * `require-interaction` / `silent` / `renotify`) as mirrored attributes.
582
+ * - `mode` selects the show backend (`auto` / `sw` / `constructor`).
583
+ * - the Core's observable surface (permission / granted / … / error / clicked /
584
+ * closed / shown) via delegated getters; clicked/closed/shown carry the
585
+ * `{ tag, data, action }` payload for event-token wiring.
586
+ */
587
+ class WcsNotify extends HTMLElement {
588
+ static hasConnectedCallbackPromise = true;
589
+ static wcBindable = {
590
+ ...NotificationCore.wcBindable,
591
+ // Shell-level settable surface. `notice` is a momentary reactive command-property
592
+ // with no mirrored attribute (it carries dynamic text, not declarative config),
593
+ // mirroring <wcs-speak>'s `say`. The rest mirror their HTML attributes idempotently.
594
+ inputs: [
595
+ { name: "notice" },
596
+ { name: "mode", attribute: "mode" },
597
+ { name: "body", attribute: "body" },
598
+ { name: "icon", attribute: "icon" },
599
+ { name: "badge", attribute: "badge" },
600
+ { name: "tag", attribute: "tag" },
601
+ { name: "lang", attribute: "lang" },
602
+ { name: "dir", attribute: "dir" },
603
+ { name: "requireInteraction", attribute: "require-interaction" },
604
+ { name: "silent", attribute: "silent" },
605
+ { name: "renotify", attribute: "renotify" },
606
+ { name: "manual", attribute: "manual" },
607
+ ],
608
+ commands: NotificationCore.wcBindable.commands,
609
+ };
610
+ _core;
611
+ _notice = "";
612
+ _connectedCallbackPromise = Promise.resolve();
613
+ constructor() {
614
+ super();
615
+ this._core = new NotificationCore(this);
616
+ }
617
+ // --- Attribute accessors ---
618
+ get mode() {
619
+ const m = this.getAttribute("mode");
620
+ return (m === "sw" || m === "constructor") ? m : "auto";
621
+ }
622
+ set mode(value) {
623
+ this.setAttribute("mode", value);
624
+ }
625
+ get body() {
626
+ return this.getAttribute("body") ?? "";
627
+ }
628
+ set body(value) {
629
+ this._reflect("body", value);
630
+ }
631
+ get icon() {
632
+ return this.getAttribute("icon") ?? "";
633
+ }
634
+ set icon(value) {
635
+ this._reflect("icon", value);
636
+ }
637
+ get badge() {
638
+ return this.getAttribute("badge") ?? "";
639
+ }
640
+ set badge(value) {
641
+ this._reflect("badge", value);
642
+ }
643
+ get tag() {
644
+ return this.getAttribute("tag") ?? "";
645
+ }
646
+ set tag(value) {
647
+ this._reflect("tag", value);
648
+ }
649
+ // NOTE: `lang` and `dir` intentionally repurpose the standard HTMLElement IDL
650
+ // attributes as per-notification options (forwarded to NotificationOptions).
651
+ // This element is always display:none, so overriding their normal rendering
652
+ // semantics has no visual effect — but be aware the values mean "the
653
+ // notification's language/direction", not the host element's.
654
+ get lang() {
655
+ return this.getAttribute("lang") ?? "";
656
+ }
657
+ set lang(value) {
658
+ this._reflect("lang", value);
659
+ }
660
+ get dir() {
661
+ return this.getAttribute("dir") ?? "";
662
+ }
663
+ set dir(value) {
664
+ this._reflect("dir", value);
665
+ }
666
+ get requireInteraction() {
667
+ return this.hasAttribute("require-interaction");
668
+ }
669
+ set requireInteraction(value) {
670
+ this._reflectBool("require-interaction", value);
671
+ }
672
+ get silent() {
673
+ return this.hasAttribute("silent");
674
+ }
675
+ set silent(value) {
676
+ this._reflectBool("silent", value);
677
+ }
678
+ get renotify() {
679
+ return this.hasAttribute("renotify");
680
+ }
681
+ set renotify(value) {
682
+ this._reflectBool("renotify", value);
683
+ }
684
+ get manual() {
685
+ return this.hasAttribute("manual");
686
+ }
687
+ set manual(value) {
688
+ this._reflectBool("manual", value);
689
+ }
690
+ // --- Reactive command-property ---
691
+ get notice() {
692
+ return this._notice;
693
+ }
694
+ set notice(value) {
695
+ // Reactive: writing a new value shows it. `manual` mutes the path entirely
696
+ // (the imperative `notify` command still works). A conforming binder never
697
+ // delivers `undefined` (it skips the write), but a direct assignment can, so
698
+ // normalize null/undefined to a no-op.
699
+ if (value == null)
700
+ return;
701
+ if (this.manual)
702
+ return;
703
+ const v = String(value);
704
+ // Same-value guard: only show when the bound source actually changes. To show
705
+ // the same text again on demand, use the `notify` command instead. (This is
706
+ // the only spam guard the package provides — see docs § 2-c; debounce is the
707
+ // caller's job via a filter, e.g. `notice@x|debounce(1000)`.)
708
+ if (v === this._notice)
709
+ return;
710
+ this._notice = v;
711
+ this.notify(v);
712
+ }
713
+ // --- Core delegated getters ---
714
+ get permission() {
715
+ return this._core.permission;
716
+ }
717
+ get granted() {
718
+ return this._core.granted;
719
+ }
720
+ get denied() {
721
+ return this._core.denied;
722
+ }
723
+ get prompt() {
724
+ return this._core.prompt;
725
+ }
726
+ get unsupported() {
727
+ return this._core.unsupported;
728
+ }
729
+ get error() {
730
+ return this._core.error;
731
+ }
732
+ get clicked() {
733
+ return this._core.clicked;
734
+ }
735
+ get closed() {
736
+ return this._core.closed;
737
+ }
738
+ get shown() {
739
+ return this._core.shown;
740
+ }
741
+ get connectedCallbackPromise() {
742
+ return this._connectedCallbackPromise;
743
+ }
744
+ // --- Commands ---
745
+ request() {
746
+ return this._core.request();
747
+ }
748
+ notify(title, options) {
749
+ // Explicit options (from a command-token emit) win per-key over the attribute
750
+ // defaults, so `notify.emit(title, { body })` still picks up the element's icon.
751
+ return this._core.notify(title, { ...this._options(), ...(options ?? {}) });
752
+ }
753
+ close(tag) {
754
+ this._core.close(tag);
755
+ }
756
+ closeAll() {
757
+ this._core.closeAll();
758
+ }
759
+ // --- Internal ---
760
+ _reflect(name, value) {
761
+ if (value == null) {
762
+ this.removeAttribute(name);
763
+ }
764
+ else {
765
+ this.setAttribute(name, String(value));
766
+ }
767
+ }
768
+ _reflectBool(name, value) {
769
+ if (value) {
770
+ this.setAttribute(name, "");
771
+ }
772
+ else {
773
+ this.removeAttribute(name);
774
+ }
775
+ }
776
+ _options() {
777
+ const o = {};
778
+ if (this.body !== "")
779
+ o.body = this.body;
780
+ if (this.icon !== "")
781
+ o.icon = this.icon;
782
+ if (this.badge !== "")
783
+ o.badge = this.badge;
784
+ if (this.tag !== "")
785
+ o.tag = this.tag;
786
+ if (this.lang !== "")
787
+ o.lang = this.lang;
788
+ if (this.dir === "auto" || this.dir === "ltr" || this.dir === "rtl")
789
+ o.dir = this.dir;
790
+ if (this.requireInteraction)
791
+ o.requireInteraction = true;
792
+ if (this.silent)
793
+ o.silent = true;
794
+ if (this.renotify)
795
+ o.renotify = true;
796
+ return o;
797
+ }
798
+ // --- Lifecycle ---
799
+ connectedCallback() {
800
+ this.style.display = "none";
801
+ if (config.autoTrigger) {
802
+ registerAutoTrigger();
803
+ }
804
+ // Begin observing permission and subscribing to SW click relays (or revive
805
+ // after a reconnect). The returned promise is held as connectedCallbackPromise
806
+ // for SSR.
807
+ this._connectedCallbackPromise = this._core.observe(this.mode);
808
+ }
809
+ disconnectedCallback() {
810
+ // Detach subscriptions. Open notifications are left on screen (see Core docs);
811
+ // call close()/closeAll() to dismiss.
812
+ this._core.dispose();
813
+ }
814
+ }
815
+
816
+ function registerComponents() {
817
+ if (!customElements.get(config.tagNames.notify)) {
818
+ customElements.define(config.tagNames.notify, WcsNotify);
819
+ }
820
+ }
821
+
822
+ function bootstrapNotification(userConfig) {
823
+ if (userConfig) {
824
+ setConfig(userConfig);
825
+ }
826
+ registerComponents();
827
+ }
828
+
829
+ export { NotificationCore, WcsNotify, bootstrapNotification, getConfig };
830
+ //# sourceMappingURL=index.esm.js.map