view design_system/src/components/notifications.js @ 262:0f45474c1b1a

Add cyberpunk JRPG blog browser Show the latest posts in the JRPG preview and render the full blog archive and sanitized post details in the Inspect modal. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <mrjunejune@users.noreply.github.com>
date Thu, 06 Aug 2026 04:08:45 -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");
}