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");
+}