Mercurial
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 } |