diff design_system/src/components/notifications.js @ 253:fdf3816959cb

[ui] Add Sonner-like notification stack Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Tue, 04 Aug 2026 12:21:19 -0700
parents 7a7581f040e8
children 2b6e732087ff
line wrap: on
line diff
--- a/design_system/src/components/notifications.js	Tue Aug 04 11:57:16 2026 -0700
+++ b/design_system/src/components/notifications.js	Tue Aug 04 12:21:19 2026 -0700
@@ -88,6 +88,10 @@
     polite: false,
     assertive: false,
   };
+  #exitTimers = new Map();
+  #exitingElements = new Set();
+  #layoutFrame = null;
+  #resizeObserver = null;
 
   constructor() {
     super();
@@ -116,6 +120,12 @@
 
   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",
@@ -141,6 +151,14 @@
     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]);
@@ -236,7 +254,22 @@
     if (!record) return;
     this.clearTimer(record);
     this.#records.delete(id);
-    record.view?.article.remove();
+    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,
@@ -370,6 +403,7 @@
         message,
         tone,
       };
+      this.#resizeObserver?.observe(article);
     }
 
     const view = record.view;
@@ -427,6 +461,71 @@
     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 styles = getComputedStyle(this.stack);
+    const configuredGap = Number.parseFloat(
+      styles.getPropertyValue("--zen-notification-gap"),
+    );
+    const gap = Number.isFinite(configuredGap)
+      ? configuredGap
+      : 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(
+        "--zen-notification-depth",
+        String(depth),
+      );
+      article.style.setProperty(
+        "--zen-notification-collapsed-offset",
+        `${-depth * 10}px`,
+      );
+      article.style.setProperty(
+        "--zen-notification-collapsed-opacity",
+        String(Math.max(0.55, 1 - depth * 0.18)),
+      );
+      article.dataset.depth = String(depth);
+      article.style.setProperty(
+        "--zen-notification-offset",
+        `${-expandedHeight}px`,
+      );
+      article.style.setProperty(
+        "--zen-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(
+      "--zen-notification-collapsed-height",
+      `${newestHeight + Math.max(0, visible.length - 1) * 10}px`,
+    );
+    this.stack.style.setProperty(
+      "--zen-notification-expanded-height",
+      `${Math.max(0, expandedHeight - gap)}px`,
+    );
+  }
+
   render() {
     if (!this.stack) return;
     const records = [...this.#records.values()];
@@ -440,7 +539,10 @@
         this.pause(record, "queued");
         this.resume(record, "hover");
         this.resume(record, "focus");
-        record.view?.article.remove();
+        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");
@@ -450,15 +552,22 @@
       this.createNotificationElement(record)
     );
     for (let index = 0; index < elements.length; index++) {
-      if (this.stack.children[index] !== elements[index]) {
+      const current = [...this.stack.children]
+        .filter(element => !this.#exitingElements.has(element))[index];
+      if (current !== elements[index]) {
         this.stack.insertBefore(
           elements[index],
-          this.stack.children[index] || null,
+          current || null,
         );
       }
+      this.#resizeObserver?.observe(elements[index]);
     }
-    while (this.stack.children.length > elements.length) {
-      this.stack.lastElementChild.remove();
+    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) {
@@ -471,6 +580,7 @@
       this.announce(record);
       this.schedule(record);
     }
+    this.requestLayout();
   }
 }