Mercurial
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/notifications.js Tue Aug 04 11:57:16 2026 -0700 @@ -0,0 +1,482 @@ +const MAX_VISIBLE = 3; +const MAX_QUEUED = 20; +const MAX_RECORDS = MAX_VISIBLE + MAX_QUEUED; +const DEFAULT_DURATION_MS = 5000; +const MIN_DURATION_MS = 250; +const MAX_DURATION_MS = 120000; +const TONES = new Set(["info", "success", "warning", "error"]); +const ANNOUNCEMENTS = new Set(["none", "polite", "assertive"]); + +function isShortString(value, maximum, allowEmpty = false) { + return typeof value === "string" && + value.length <= maximum && + (allowEmpty || value.trim().length > 0); +} + +export function validateNotification(value) { + if (!value || + value.version !== 1 || + !isShortString(value.id, 128) || + !TONES.has(value.tone) || + !isShortString(value.message, 500) || + !ANNOUNCEMENTS.has(value.announcement)) return null; + if (value.description !== undefined && + !isShortString(value.description, 1000, true)) return null; + if (value.persistent !== undefined && + typeof value.persistent !== "boolean") return null; + if (value.durationMs !== undefined && + (!Number.isFinite(value.durationMs) || + value.durationMs < MIN_DURATION_MS || + value.durationMs > MAX_DURATION_MS)) return null; + + let action; + if (value.action !== undefined) { + if (!value.action || + !isShortString(value.action.token, 256) || + !isShortString(value.action.label, 120)) return null; + action = Object.freeze({ + token: value.action.token, + label: value.action.label, + }); + } + + return Object.freeze({ + version: 1, + id: value.id, + tone: value.tone, + message: value.message, + description: value.description || "", + announcement: value.announcement, + durationMs: value.durationMs ?? DEFAULT_DURATION_MS, + persistent: value.persistent === true, + action, + }); +} + +export function validateNotificationDismiss(value) { + if (!value || + value.version !== 1 || + !isShortString(value.id, 128)) return null; + return value.id; +} + +function makeRecord(notification, announced = false) { + return { + ...notification, + announced, + pauseReasons: new Set(), + remainingMs: notification.durationMs, + startedAt: 0, + timer: null, + view: null, + }; +} + +export class ZenNotifications extends HTMLElement { + static version = 1; + + #records = new Map(); + #announcementQueues = { + polite: [], + assertive: [], + }; + #announcementTimers = { + polite: null, + assertive: null, + }; + #announcementRunning = { + polite: false, + assertive: false, + }; + + constructor() { + super(); + this.handleNotify = event => { + if (!(event instanceof CustomEvent) || + !event.bubbles || + event.composed) return; + event.stopPropagation(); + this.notify(event.detail); + }; + this.handleDismiss = event => { + if (!(event instanceof CustomEvent) || + !event.bubbles || + event.composed) return; + event.stopPropagation(); + const id = validateNotificationDismiss(event.detail); + if (id) this.dismiss(id, "programmatic"); + }; + this.handleVisibility = () => { + for (const record of this.#records.values()) { + if (document.hidden) this.pause(record, "hidden"); + else this.resume(record, "hidden"); + } + }; + } + + connectedCallback() { + if (!this.stack) this.createInternalElements(); + this.addEventListener("zen-notify", this.handleNotify); + this.addEventListener( + "zen-dismiss-notification", + this.handleDismiss, + ); + document.addEventListener( + "visibilitychange", + this.handleVisibility, + ); + this.render(); + } + + disconnectedCallback() { + this.removeEventListener("zen-notify", this.handleNotify); + this.removeEventListener( + "zen-dismiss-notification", + this.handleDismiss, + ); + document.removeEventListener( + "visibilitychange", + this.handleVisibility, + ); + for (const record of this.#records.values()) { + this.clearTimer(record); + } + this.#records.clear(); + for (const priority of ["polite", "assertive"]) { + clearTimeout(this.#announcementTimers[priority]); + this.#announcementTimers[priority] = null; + this.#announcementQueues[priority].length = 0; + this.#announcementRunning[priority] = false; + } + for (const element of this.querySelectorAll( + ":scope > [data-zen-notifications-internal]", + )) { + element.remove(); + } + this.stack = null; + this.politeRegion = null; + this.assertiveRegion = null; + } + + createInternalElements() { + const label = this.getAttribute("aria-label") || "Notifications"; + this.stack = document.createElement("section"); + this.stack.dataset.zenNotificationStack = ""; + this.stack.dataset.zenNotificationsInternal = ""; + this.stack.setAttribute("role", "region"); + this.stack.setAttribute("aria-label", label); + + this.politeRegion = document.createElement("span"); + this.politeRegion.dataset.zenLive = "polite"; + this.politeRegion.dataset.zenNotificationsInternal = ""; + this.politeRegion.className = "zen-visually-hidden"; + this.politeRegion.setAttribute("aria-live", "polite"); + this.politeRegion.setAttribute("aria-atomic", "true"); + + this.assertiveRegion = document.createElement("span"); + this.assertiveRegion.dataset.zenLive = "assertive"; + this.assertiveRegion.dataset.zenNotificationsInternal = ""; + this.assertiveRegion.className = "zen-visually-hidden"; + this.assertiveRegion.setAttribute("aria-live", "assertive"); + this.assertiveRegion.setAttribute("aria-atomic", "true"); + + this.append( + this.stack, + this.politeRegion, + this.assertiveRegion, + ); + } + + get size() { + return this.#records.size; + } + + get visibleCount() { + return Math.min(this.#records.size, MAX_VISIBLE); + } + + notify(value) { + const notification = validateNotification(value); + if (!notification) return false; + + const previous = this.#records.get(notification.id); + if (!previous && this.#records.size >= MAX_RECORDS) { + const oldestFinite = [...this.#records.values()].find( + record => !record.persistent, + ); + if (!oldestFinite) return false; + this.removeRecord(oldestFinite.id, "overflow", false); + } + + let record; + if (previous) { + this.clearTimer(previous); + Object.assign(previous, notification); + previous.remainingMs = notification.durationMs; + this.#records.delete(notification.id); + record = previous; + } else { + record = makeRecord(notification); + } + this.#records.set(notification.id, record); + this.render(); + return true; + } + + dismiss(id, reason = "dismissed") { + if (!isShortString(id, 128) || !this.#records.has(id)) { + return false; + } + this.removeRecord(id, reason, true); + return true; + } + + removeRecord(id, reason, render) { + const record = this.#records.get(id); + if (!record) return; + this.clearTimer(record); + this.#records.delete(id); + record.view?.article.remove(); + record.view = null; + this.dispatchEvent(new CustomEvent("zen-notification-removed", { + bubbles: true, + composed: false, + detail: Object.freeze({ + version: 1, + id, + reason, + }), + })); + if (render) this.render(); + } + + clearTimer(record) { + if (!record.timer) return; + clearTimeout(record.timer); + record.remainingMs = Math.max( + 0, + record.remainingMs - (performance.now() - record.startedAt), + ); + record.timer = null; + record.startedAt = 0; + } + + pause(record, reason) { + if (record.persistent || record.pauseReasons.has(reason)) return; + record.pauseReasons.add(reason); + this.clearTimer(record); + } + + resume(record, reason) { + if (record.persistent) return; + record.pauseReasons.delete(reason); + this.schedule(record); + } + + schedule(record) { + if (record.persistent || + record.timer || + record.pauseReasons.size > 0 || + !this.#records.has(record.id)) return; + if (record.remainingMs <= 0) { + this.dismiss(record.id, "timeout"); + return; + } + record.startedAt = performance.now(); + record.timer = setTimeout(() => { + record.timer = null; + record.remainingMs = 0; + this.dismiss(record.id, "timeout"); + }, record.remainingMs); + } + + announce(record) { + if (record.announced) return; + record.announced = true; + if (record.announcement === "none") return; + this.#announcementQueues[record.announcement].push({ + id: record.id, + message: record.message, + }); + this.runAnnouncementQueue(record.announcement); + } + + runAnnouncementQueue(priority) { + if (this.#announcementRunning[priority]) return; + const next = this.#announcementQueues[priority].shift(); + if (!next) return; + this.#announcementRunning[priority] = true; + const region = priority === "assertive" + ? this.assertiveRegion + : this.politeRegion; + region.textContent = ""; + this.#announcementTimers[priority] = setTimeout(() => { + if (this.isConnected && this.#records.has(next.id)) { + region.textContent = next.message; + } + this.#announcementTimers[priority] = setTimeout(() => { + this.#announcementTimers[priority] = null; + this.#announcementRunning[priority] = false; + this.runAnnouncementQueue(priority); + }, 120); + }, 16); + } + + createNotificationElement(record) { + if (!record.view) { + const article = document.createElement("article"); + article.dataset.zenNotificationId = record.id; + + const tone = document.createElement("span"); + tone.className = "zen-notification-tone"; + tone.setAttribute("aria-hidden", "true"); + + const content = document.createElement("div"); + content.className = "zen-notification-content"; + const message = document.createElement("p"); + message.className = "zen-notification-message"; + content.append(message); + + const dismiss = document.createElement("button"); + dismiss.type = "button"; + dismiss.className = "zen-notification-dismiss"; + dismiss.textContent = "×"; + dismiss.addEventListener("click", () => { + this.dismiss(record.id, "dismissed"); + }); + + article.addEventListener("pointerenter", () => { + this.pause(record, "hover"); + }); + article.addEventListener("pointerleave", () => { + this.resume(record, "hover"); + }); + article.addEventListener("focusin", () => { + this.pause(record, "focus"); + }); + article.addEventListener("focusout", event => { + if (!article.contains(event.relatedTarget)) { + this.resume(record, "focus"); + } + }); + + article.append(tone, content, dismiss); + record.view = { + action: null, + article, + content, + description: null, + dismiss, + message, + tone, + }; + } + + const view = record.view; + view.article.dataset.tone = record.tone; + view.tone.textContent = { + info: "i", + success: "✓", + warning: "!", + error: "×", + }[record.tone]; + view.message.textContent = record.message; + view.dismiss.setAttribute( + "aria-label", + this.getAttribute("dismiss-label") || "Dismiss notification", + ); + + if (record.description) { + if (!view.description) { + view.description = document.createElement("p"); + view.description.className = "zen-notification-description"; + view.content.insertBefore( + view.description, + view.action, + ); + } + view.description.textContent = record.description; + } else if (view.description) { + view.description.remove(); + view.description = null; + } + + if (record.action) { + if (!view.action) { + view.action = document.createElement("button"); + view.action.type = "button"; + view.action.className = "zen-notification-action"; + view.action.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("zen-notification-action", { + bubbles: true, + composed: false, + detail: Object.freeze({ + version: 1, + id: record.id, + token: record.action.token, + }), + })); + }); + view.content.append(view.action); + } + view.action.textContent = record.action.label; + } else if (view.action) { + view.action.remove(); + view.action = null; + } + return view.article; + } + + render() { + if (!this.stack) return; + const records = [...this.#records.values()]; + const visible = records.slice(-MAX_VISIBLE); + const visibleIds = new Set(visible.map(record => record.id)); + + for (const record of records) { + if (visibleIds.has(record.id)) { + this.resume(record, "queued"); + } else { + this.pause(record, "queued"); + this.resume(record, "hover"); + this.resume(record, "focus"); + record.view?.article.remove(); + } + if (document.hidden) this.pause(record, "hidden"); + else this.resume(record, "hidden"); + } + + const elements = visible.map(record => + this.createNotificationElement(record) + ); + for (let index = 0; index < elements.length; index++) { + if (this.stack.children[index] !== elements[index]) { + this.stack.insertBefore( + elements[index], + this.stack.children[index] || null, + ); + } + } + while (this.stack.children.length > elements.length) { + this.stack.lastElementChild.remove(); + } + + for (const record of visible) { + const element = record.view.article; + if (element.matches(":hover")) this.pause(record, "hover"); + else this.resume(record, "hover"); + if (element.contains(document.activeElement)) { + this.pause(record, "focus"); + } else this.resume(record, "focus"); + this.announce(record); + this.schedule(record); + } + } +} + +const existing = customElements.get("zen-notifications"); +if (!existing) { + customElements.define("zen-notifications", ZenNotifications); +} else if (existing.version !== ZenNotifications.version) { + throw new Error("Incompatible zen-notifications component"); +}