comparison design_system/src/components/notifications.js @ 252:7a7581f040e8

[ui] Add scoped notification component Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Tue, 04 Aug 2026 11:57:16 -0700
parents
children fdf3816959cb
comparison
equal deleted inserted replaced
251:117c4d53c9a4 252:7a7581f040e8
1 const MAX_VISIBLE = 3;
2 const MAX_QUEUED = 20;
3 const MAX_RECORDS = MAX_VISIBLE + MAX_QUEUED;
4 const DEFAULT_DURATION_MS = 5000;
5 const MIN_DURATION_MS = 250;
6 const MAX_DURATION_MS = 120000;
7 const TONES = new Set(["info", "success", "warning", "error"]);
8 const ANNOUNCEMENTS = new Set(["none", "polite", "assertive"]);
9
10 function isShortString(value, maximum, allowEmpty = false) {
11 return typeof value === "string" &&
12 value.length <= maximum &&
13 (allowEmpty || value.trim().length > 0);
14 }
15
16 export function validateNotification(value) {
17 if (!value ||
18 value.version !== 1 ||
19 !isShortString(value.id, 128) ||
20 !TONES.has(value.tone) ||
21 !isShortString(value.message, 500) ||
22 !ANNOUNCEMENTS.has(value.announcement)) return null;
23 if (value.description !== undefined &&
24 !isShortString(value.description, 1000, true)) return null;
25 if (value.persistent !== undefined &&
26 typeof value.persistent !== "boolean") return null;
27 if (value.durationMs !== undefined &&
28 (!Number.isFinite(value.durationMs) ||
29 value.durationMs < MIN_DURATION_MS ||
30 value.durationMs > MAX_DURATION_MS)) return null;
31
32 let action;
33 if (value.action !== undefined) {
34 if (!value.action ||
35 !isShortString(value.action.token, 256) ||
36 !isShortString(value.action.label, 120)) return null;
37 action = Object.freeze({
38 token: value.action.token,
39 label: value.action.label,
40 });
41 }
42
43 return Object.freeze({
44 version: 1,
45 id: value.id,
46 tone: value.tone,
47 message: value.message,
48 description: value.description || "",
49 announcement: value.announcement,
50 durationMs: value.durationMs ?? DEFAULT_DURATION_MS,
51 persistent: value.persistent === true,
52 action,
53 });
54 }
55
56 export function validateNotificationDismiss(value) {
57 if (!value ||
58 value.version !== 1 ||
59 !isShortString(value.id, 128)) return null;
60 return value.id;
61 }
62
63 function makeRecord(notification, announced = false) {
64 return {
65 ...notification,
66 announced,
67 pauseReasons: new Set(),
68 remainingMs: notification.durationMs,
69 startedAt: 0,
70 timer: null,
71 view: null,
72 };
73 }
74
75 export class ZenNotifications extends HTMLElement {
76 static version = 1;
77
78 #records = new Map();
79 #announcementQueues = {
80 polite: [],
81 assertive: [],
82 };
83 #announcementTimers = {
84 polite: null,
85 assertive: null,
86 };
87 #announcementRunning = {
88 polite: false,
89 assertive: false,
90 };
91
92 constructor() {
93 super();
94 this.handleNotify = event => {
95 if (!(event instanceof CustomEvent) ||
96 !event.bubbles ||
97 event.composed) return;
98 event.stopPropagation();
99 this.notify(event.detail);
100 };
101 this.handleDismiss = event => {
102 if (!(event instanceof CustomEvent) ||
103 !event.bubbles ||
104 event.composed) return;
105 event.stopPropagation();
106 const id = validateNotificationDismiss(event.detail);
107 if (id) this.dismiss(id, "programmatic");
108 };
109 this.handleVisibility = () => {
110 for (const record of this.#records.values()) {
111 if (document.hidden) this.pause(record, "hidden");
112 else this.resume(record, "hidden");
113 }
114 };
115 }
116
117 connectedCallback() {
118 if (!this.stack) this.createInternalElements();
119 this.addEventListener("zen-notify", this.handleNotify);
120 this.addEventListener(
121 "zen-dismiss-notification",
122 this.handleDismiss,
123 );
124 document.addEventListener(
125 "visibilitychange",
126 this.handleVisibility,
127 );
128 this.render();
129 }
130
131 disconnectedCallback() {
132 this.removeEventListener("zen-notify", this.handleNotify);
133 this.removeEventListener(
134 "zen-dismiss-notification",
135 this.handleDismiss,
136 );
137 document.removeEventListener(
138 "visibilitychange",
139 this.handleVisibility,
140 );
141 for (const record of this.#records.values()) {
142 this.clearTimer(record);
143 }
144 this.#records.clear();
145 for (const priority of ["polite", "assertive"]) {
146 clearTimeout(this.#announcementTimers[priority]);
147 this.#announcementTimers[priority] = null;
148 this.#announcementQueues[priority].length = 0;
149 this.#announcementRunning[priority] = false;
150 }
151 for (const element of this.querySelectorAll(
152 ":scope > [data-zen-notifications-internal]",
153 )) {
154 element.remove();
155 }
156 this.stack = null;
157 this.politeRegion = null;
158 this.assertiveRegion = null;
159 }
160
161 createInternalElements() {
162 const label = this.getAttribute("aria-label") || "Notifications";
163 this.stack = document.createElement("section");
164 this.stack.dataset.zenNotificationStack = "";
165 this.stack.dataset.zenNotificationsInternal = "";
166 this.stack.setAttribute("role", "region");
167 this.stack.setAttribute("aria-label", label);
168
169 this.politeRegion = document.createElement("span");
170 this.politeRegion.dataset.zenLive = "polite";
171 this.politeRegion.dataset.zenNotificationsInternal = "";
172 this.politeRegion.className = "zen-visually-hidden";
173 this.politeRegion.setAttribute("aria-live", "polite");
174 this.politeRegion.setAttribute("aria-atomic", "true");
175
176 this.assertiveRegion = document.createElement("span");
177 this.assertiveRegion.dataset.zenLive = "assertive";
178 this.assertiveRegion.dataset.zenNotificationsInternal = "";
179 this.assertiveRegion.className = "zen-visually-hidden";
180 this.assertiveRegion.setAttribute("aria-live", "assertive");
181 this.assertiveRegion.setAttribute("aria-atomic", "true");
182
183 this.append(
184 this.stack,
185 this.politeRegion,
186 this.assertiveRegion,
187 );
188 }
189
190 get size() {
191 return this.#records.size;
192 }
193
194 get visibleCount() {
195 return Math.min(this.#records.size, MAX_VISIBLE);
196 }
197
198 notify(value) {
199 const notification = validateNotification(value);
200 if (!notification) return false;
201
202 const previous = this.#records.get(notification.id);
203 if (!previous && this.#records.size >= MAX_RECORDS) {
204 const oldestFinite = [...this.#records.values()].find(
205 record => !record.persistent,
206 );
207 if (!oldestFinite) return false;
208 this.removeRecord(oldestFinite.id, "overflow", false);
209 }
210
211 let record;
212 if (previous) {
213 this.clearTimer(previous);
214 Object.assign(previous, notification);
215 previous.remainingMs = notification.durationMs;
216 this.#records.delete(notification.id);
217 record = previous;
218 } else {
219 record = makeRecord(notification);
220 }
221 this.#records.set(notification.id, record);
222 this.render();
223 return true;
224 }
225
226 dismiss(id, reason = "dismissed") {
227 if (!isShortString(id, 128) || !this.#records.has(id)) {
228 return false;
229 }
230 this.removeRecord(id, reason, true);
231 return true;
232 }
233
234 removeRecord(id, reason, render) {
235 const record = this.#records.get(id);
236 if (!record) return;
237 this.clearTimer(record);
238 this.#records.delete(id);
239 record.view?.article.remove();
240 record.view = null;
241 this.dispatchEvent(new CustomEvent("zen-notification-removed", {
242 bubbles: true,
243 composed: false,
244 detail: Object.freeze({
245 version: 1,
246 id,
247 reason,
248 }),
249 }));
250 if (render) this.render();
251 }
252
253 clearTimer(record) {
254 if (!record.timer) return;
255 clearTimeout(record.timer);
256 record.remainingMs = Math.max(
257 0,
258 record.remainingMs - (performance.now() - record.startedAt),
259 );
260 record.timer = null;
261 record.startedAt = 0;
262 }
263
264 pause(record, reason) {
265 if (record.persistent || record.pauseReasons.has(reason)) return;
266 record.pauseReasons.add(reason);
267 this.clearTimer(record);
268 }
269
270 resume(record, reason) {
271 if (record.persistent) return;
272 record.pauseReasons.delete(reason);
273 this.schedule(record);
274 }
275
276 schedule(record) {
277 if (record.persistent ||
278 record.timer ||
279 record.pauseReasons.size > 0 ||
280 !this.#records.has(record.id)) return;
281 if (record.remainingMs <= 0) {
282 this.dismiss(record.id, "timeout");
283 return;
284 }
285 record.startedAt = performance.now();
286 record.timer = setTimeout(() => {
287 record.timer = null;
288 record.remainingMs = 0;
289 this.dismiss(record.id, "timeout");
290 }, record.remainingMs);
291 }
292
293 announce(record) {
294 if (record.announced) return;
295 record.announced = true;
296 if (record.announcement === "none") return;
297 this.#announcementQueues[record.announcement].push({
298 id: record.id,
299 message: record.message,
300 });
301 this.runAnnouncementQueue(record.announcement);
302 }
303
304 runAnnouncementQueue(priority) {
305 if (this.#announcementRunning[priority]) return;
306 const next = this.#announcementQueues[priority].shift();
307 if (!next) return;
308 this.#announcementRunning[priority] = true;
309 const region = priority === "assertive"
310 ? this.assertiveRegion
311 : this.politeRegion;
312 region.textContent = "";
313 this.#announcementTimers[priority] = setTimeout(() => {
314 if (this.isConnected && this.#records.has(next.id)) {
315 region.textContent = next.message;
316 }
317 this.#announcementTimers[priority] = setTimeout(() => {
318 this.#announcementTimers[priority] = null;
319 this.#announcementRunning[priority] = false;
320 this.runAnnouncementQueue(priority);
321 }, 120);
322 }, 16);
323 }
324
325 createNotificationElement(record) {
326 if (!record.view) {
327 const article = document.createElement("article");
328 article.dataset.zenNotificationId = record.id;
329
330 const tone = document.createElement("span");
331 tone.className = "zen-notification-tone";
332 tone.setAttribute("aria-hidden", "true");
333
334 const content = document.createElement("div");
335 content.className = "zen-notification-content";
336 const message = document.createElement("p");
337 message.className = "zen-notification-message";
338 content.append(message);
339
340 const dismiss = document.createElement("button");
341 dismiss.type = "button";
342 dismiss.className = "zen-notification-dismiss";
343 dismiss.textContent = "×";
344 dismiss.addEventListener("click", () => {
345 this.dismiss(record.id, "dismissed");
346 });
347
348 article.addEventListener("pointerenter", () => {
349 this.pause(record, "hover");
350 });
351 article.addEventListener("pointerleave", () => {
352 this.resume(record, "hover");
353 });
354 article.addEventListener("focusin", () => {
355 this.pause(record, "focus");
356 });
357 article.addEventListener("focusout", event => {
358 if (!article.contains(event.relatedTarget)) {
359 this.resume(record, "focus");
360 }
361 });
362
363 article.append(tone, content, dismiss);
364 record.view = {
365 action: null,
366 article,
367 content,
368 description: null,
369 dismiss,
370 message,
371 tone,
372 };
373 }
374
375 const view = record.view;
376 view.article.dataset.tone = record.tone;
377 view.tone.textContent = {
378 info: "i",
379 success: "✓",
380 warning: "!",
381 error: "×",
382 }[record.tone];
383 view.message.textContent = record.message;
384 view.dismiss.setAttribute(
385 "aria-label",
386 this.getAttribute("dismiss-label") || "Dismiss notification",
387 );
388
389 if (record.description) {
390 if (!view.description) {
391 view.description = document.createElement("p");
392 view.description.className = "zen-notification-description";
393 view.content.insertBefore(
394 view.description,
395 view.action,
396 );
397 }
398 view.description.textContent = record.description;
399 } else if (view.description) {
400 view.description.remove();
401 view.description = null;
402 }
403
404 if (record.action) {
405 if (!view.action) {
406 view.action = document.createElement("button");
407 view.action.type = "button";
408 view.action.className = "zen-notification-action";
409 view.action.addEventListener("click", () => {
410 this.dispatchEvent(new CustomEvent("zen-notification-action", {
411 bubbles: true,
412 composed: false,
413 detail: Object.freeze({
414 version: 1,
415 id: record.id,
416 token: record.action.token,
417 }),
418 }));
419 });
420 view.content.append(view.action);
421 }
422 view.action.textContent = record.action.label;
423 } else if (view.action) {
424 view.action.remove();
425 view.action = null;
426 }
427 return view.article;
428 }
429
430 render() {
431 if (!this.stack) return;
432 const records = [...this.#records.values()];
433 const visible = records.slice(-MAX_VISIBLE);
434 const visibleIds = new Set(visible.map(record => record.id));
435
436 for (const record of records) {
437 if (visibleIds.has(record.id)) {
438 this.resume(record, "queued");
439 } else {
440 this.pause(record, "queued");
441 this.resume(record, "hover");
442 this.resume(record, "focus");
443 record.view?.article.remove();
444 }
445 if (document.hidden) this.pause(record, "hidden");
446 else this.resume(record, "hidden");
447 }
448
449 const elements = visible.map(record =>
450 this.createNotificationElement(record)
451 );
452 for (let index = 0; index < elements.length; index++) {
453 if (this.stack.children[index] !== elements[index]) {
454 this.stack.insertBefore(
455 elements[index],
456 this.stack.children[index] || null,
457 );
458 }
459 }
460 while (this.stack.children.length > elements.length) {
461 this.stack.lastElementChild.remove();
462 }
463
464 for (const record of visible) {
465 const element = record.view.article;
466 if (element.matches(":hover")) this.pause(record, "hover");
467 else this.resume(record, "hover");
468 if (element.contains(document.activeElement)) {
469 this.pause(record, "focus");
470 } else this.resume(record, "focus");
471 this.announce(record);
472 this.schedule(record);
473 }
474 }
475 }
476
477 const existing = customElements.get("zen-notifications");
478 if (!existing) {
479 customElements.define("zen-notifications", ZenNotifications);
480 } else if (existing.version !== ZenNotifications.version) {
481 throw new Error("Incompatible zen-notifications component");
482 }