Mercurial
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(); } }