Mercurial
view design_system/src/components/notifications.js @ 270:358dd5950985
sync production config on every deploy
Always install the ignored repository .config into /etc/mrjunejune before promoting the new bundle.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 13:16:47 -0700 |
| parents | 60a876c4587a |
| children |
line wrap: on
line source
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); } function resolveCssLength(element, property, fallback) { const probe = document.createElement("span"); probe.style.position = "absolute"; probe.style.inlineSize = `var(${property})`; probe.style.visibility = "hidden"; element.append(probe); const value = Number.parseFloat(getComputedStyle(probe).inlineSize); probe.remove(); return Number.isFinite(value) ? value : fallback; } /** * Validates and normalizes a notification event record. * * @param {*} value Candidate event detail. * @return {?Object} Frozen normalized record, or null when invalid. */ 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, }); } /** * Validates a programmatic notification dismissal record. * * @param {*} value Candidate event detail. * @return {?string} Valid notification ID, or null when invalid. */ 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, }; } /** * Renders a bounded, scoped notification queue. * * @extends {HTMLElement} */ export class ZenNotifications extends HTMLElement { static version = 1; #records = new Map(); #announcementQueues = { polite: [], assertive: [], }; #announcementTimers = { polite: null, assertive: null, }; #announcementRunning = { polite: false, assertive: false, }; #exitTimers = new Map(); #exitingElements = new Set(); #layoutFrame = null; #resizeObserver = null; 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(); if (!this.#resizeObserver && typeof ResizeObserver === "function") { this.#resizeObserver = new ResizeObserver(() => { this.requestLayout(); }); } 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); } cancelAnimationFrame(this.#layoutFrame); this.#layoutFrame = null; this.#resizeObserver?.disconnect(); for (const timer of this.#exitTimers.values()) { clearTimeout(timer); } this.#exitTimers.clear(); this.#exitingElements.clear(); 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); const article = record.view?.article; if (article && render && article.isConnected) { article.dataset.removing = "true"; this.#exitingElements.add(article); const exitTimer = setTimeout(() => { this.#resizeObserver?.unobserve(article); article.remove(); this.#exitingElements.delete(article); this.#exitTimers.delete(article); this.requestLayout(); }, 180); this.#exitTimers.set(article, exitTimer); } else if (article) { this.#resizeObserver?.unobserve(article); 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 toneIcon = document.createElement("zen-icon"); tone.append(toneIcon); 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"; const dismissIcon = document.createElement("zen-icon"); dismissIcon.setAttribute("name", "close"); dismiss.append(dismissIcon); 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, toneIcon, }; this.#resizeObserver?.observe(article); } const view = record.view; view.article.dataset.tone = record.tone; view.toneIcon.setAttribute("name", { info: "info", success: "check", warning: "alert", error: "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; } requestLayout() { cancelAnimationFrame(this.#layoutFrame); this.#layoutFrame = requestAnimationFrame(() => { this.#layoutFrame = null; this.layoutStack(); }); } layoutStack() { if (!this.stack) return; const visible = [...this.#records.values()] .slice(-MAX_VISIBLE) .filter(record => record.view?.article.isConnected); const gap = resolveCssLength( this.stack, "--zenbu-notification-gap", 12, ); let expandedHeight = 0; let newestHeight = 0; for (let index = visible.length - 1; index >= 0; index--) { const record = visible[index]; const article = record.view.article; const depth = visible.length - 1 - index; const height = article.offsetHeight; if (depth === 0) newestHeight = height; article.style.setProperty( "--zenbu-notification-depth", String(depth), ); article.style.setProperty( "--zenbu-notification-collapsed-offset", `${-depth * 10}px`, ); article.style.setProperty( "--zenbu-notification-collapsed-opacity", String(Math.max(0.55, 1 - depth * 0.18)), ); article.dataset.depth = String(depth); article.style.setProperty( "--zenbu-notification-offset", `${-expandedHeight}px`, ); article.style.setProperty( "--zenbu-notification-scale", String(Math.max(0.88, 1 - depth * 0.055)), ); article.style.zIndex = String(visible.length - depth); expandedHeight += height + gap; } this.stack.dataset.count = String(visible.length); this.stack.style.setProperty( "--zenbu-notification-collapsed-height", `${newestHeight + Math.max(0, visible.length - 1) * 10}px`, ); this.stack.style.setProperty( "--zenbu-notification-expanded-height", `${Math.max(0, expandedHeight - gap)}px`, ); } 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"); if (record.view?.article) { this.#resizeObserver?.unobserve(record.view.article); 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++) { const current = [...this.stack.children] .filter(element => !this.#exitingElements.has(element))[index]; if (current !== elements[index]) { this.stack.insertBefore( elements[index], current || null, ); } this.#resizeObserver?.observe(elements[index]); } const desired = new Set(elements); for (const element of [...this.stack.children]) { if (!desired.has(element) && !this.#exitingElements.has(element)) { element.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); } this.requestLayout(); } } 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"); }