comparison 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
comparison
equal deleted inserted replaced
252:7a7581f040e8 253:fdf3816959cb
86 }; 86 };
87 #announcementRunning = { 87 #announcementRunning = {
88 polite: false, 88 polite: false,
89 assertive: false, 89 assertive: false,
90 }; 90 };
91 #exitTimers = new Map();
92 #exitingElements = new Set();
93 #layoutFrame = null;
94 #resizeObserver = null;
91 95
92 constructor() { 96 constructor() {
93 super(); 97 super();
94 this.handleNotify = event => { 98 this.handleNotify = event => {
95 if (!(event instanceof CustomEvent) || 99 if (!(event instanceof CustomEvent) ||
114 }; 118 };
115 } 119 }
116 120
117 connectedCallback() { 121 connectedCallback() {
118 if (!this.stack) this.createInternalElements(); 122 if (!this.stack) this.createInternalElements();
123 if (!this.#resizeObserver &&
124 typeof ResizeObserver === "function") {
125 this.#resizeObserver = new ResizeObserver(() => {
126 this.requestLayout();
127 });
128 }
119 this.addEventListener("zen-notify", this.handleNotify); 129 this.addEventListener("zen-notify", this.handleNotify);
120 this.addEventListener( 130 this.addEventListener(
121 "zen-dismiss-notification", 131 "zen-dismiss-notification",
122 this.handleDismiss, 132 this.handleDismiss,
123 ); 133 );
139 this.handleVisibility, 149 this.handleVisibility,
140 ); 150 );
141 for (const record of this.#records.values()) { 151 for (const record of this.#records.values()) {
142 this.clearTimer(record); 152 this.clearTimer(record);
143 } 153 }
154 cancelAnimationFrame(this.#layoutFrame);
155 this.#layoutFrame = null;
156 this.#resizeObserver?.disconnect();
157 for (const timer of this.#exitTimers.values()) {
158 clearTimeout(timer);
159 }
160 this.#exitTimers.clear();
161 this.#exitingElements.clear();
144 this.#records.clear(); 162 this.#records.clear();
145 for (const priority of ["polite", "assertive"]) { 163 for (const priority of ["polite", "assertive"]) {
146 clearTimeout(this.#announcementTimers[priority]); 164 clearTimeout(this.#announcementTimers[priority]);
147 this.#announcementTimers[priority] = null; 165 this.#announcementTimers[priority] = null;
148 this.#announcementQueues[priority].length = 0; 166 this.#announcementQueues[priority].length = 0;
234 removeRecord(id, reason, render) { 252 removeRecord(id, reason, render) {
235 const record = this.#records.get(id); 253 const record = this.#records.get(id);
236 if (!record) return; 254 if (!record) return;
237 this.clearTimer(record); 255 this.clearTimer(record);
238 this.#records.delete(id); 256 this.#records.delete(id);
239 record.view?.article.remove(); 257 const article = record.view?.article;
258 if (article && render && article.isConnected) {
259 article.dataset.removing = "true";
260 this.#exitingElements.add(article);
261 const exitTimer = setTimeout(() => {
262 this.#resizeObserver?.unobserve(article);
263 article.remove();
264 this.#exitingElements.delete(article);
265 this.#exitTimers.delete(article);
266 this.requestLayout();
267 }, 180);
268 this.#exitTimers.set(article, exitTimer);
269 } else if (article) {
270 this.#resizeObserver?.unobserve(article);
271 article.remove();
272 }
240 record.view = null; 273 record.view = null;
241 this.dispatchEvent(new CustomEvent("zen-notification-removed", { 274 this.dispatchEvent(new CustomEvent("zen-notification-removed", {
242 bubbles: true, 275 bubbles: true,
243 composed: false, 276 composed: false,
244 detail: Object.freeze({ 277 detail: Object.freeze({
368 description: null, 401 description: null,
369 dismiss, 402 dismiss,
370 message, 403 message,
371 tone, 404 tone,
372 }; 405 };
406 this.#resizeObserver?.observe(article);
373 } 407 }
374 408
375 const view = record.view; 409 const view = record.view;
376 view.article.dataset.tone = record.tone; 410 view.article.dataset.tone = record.tone;
377 view.tone.textContent = { 411 view.tone.textContent = {
425 view.action = null; 459 view.action = null;
426 } 460 }
427 return view.article; 461 return view.article;
428 } 462 }
429 463
464 requestLayout() {
465 cancelAnimationFrame(this.#layoutFrame);
466 this.#layoutFrame = requestAnimationFrame(() => {
467 this.#layoutFrame = null;
468 this.layoutStack();
469 });
470 }
471
472 layoutStack() {
473 if (!this.stack) return;
474 const visible = [...this.#records.values()]
475 .slice(-MAX_VISIBLE)
476 .filter(record => record.view?.article.isConnected);
477 const styles = getComputedStyle(this.stack);
478 const configuredGap = Number.parseFloat(
479 styles.getPropertyValue("--zen-notification-gap"),
480 );
481 const gap = Number.isFinite(configuredGap)
482 ? configuredGap
483 : 12;
484 let expandedHeight = 0;
485 let newestHeight = 0;
486
487 for (let index = visible.length - 1; index >= 0; index--) {
488 const record = visible[index];
489 const article = record.view.article;
490 const depth = visible.length - 1 - index;
491 const height = article.offsetHeight;
492 if (depth === 0) newestHeight = height;
493 article.style.setProperty(
494 "--zen-notification-depth",
495 String(depth),
496 );
497 article.style.setProperty(
498 "--zen-notification-collapsed-offset",
499 `${-depth * 10}px`,
500 );
501 article.style.setProperty(
502 "--zen-notification-collapsed-opacity",
503 String(Math.max(0.55, 1 - depth * 0.18)),
504 );
505 article.dataset.depth = String(depth);
506 article.style.setProperty(
507 "--zen-notification-offset",
508 `${-expandedHeight}px`,
509 );
510 article.style.setProperty(
511 "--zen-notification-scale",
512 String(Math.max(0.88, 1 - depth * 0.055)),
513 );
514 article.style.zIndex = String(visible.length - depth);
515 expandedHeight += height + gap;
516 }
517
518 this.stack.dataset.count = String(visible.length);
519 this.stack.style.setProperty(
520 "--zen-notification-collapsed-height",
521 `${newestHeight + Math.max(0, visible.length - 1) * 10}px`,
522 );
523 this.stack.style.setProperty(
524 "--zen-notification-expanded-height",
525 `${Math.max(0, expandedHeight - gap)}px`,
526 );
527 }
528
430 render() { 529 render() {
431 if (!this.stack) return; 530 if (!this.stack) return;
432 const records = [...this.#records.values()]; 531 const records = [...this.#records.values()];
433 const visible = records.slice(-MAX_VISIBLE); 532 const visible = records.slice(-MAX_VISIBLE);
434 const visibleIds = new Set(visible.map(record => record.id)); 533 const visibleIds = new Set(visible.map(record => record.id));
438 this.resume(record, "queued"); 537 this.resume(record, "queued");
439 } else { 538 } else {
440 this.pause(record, "queued"); 539 this.pause(record, "queued");
441 this.resume(record, "hover"); 540 this.resume(record, "hover");
442 this.resume(record, "focus"); 541 this.resume(record, "focus");
443 record.view?.article.remove(); 542 if (record.view?.article) {
543 this.#resizeObserver?.unobserve(record.view.article);
544 record.view.article.remove();
545 }
444 } 546 }
445 if (document.hidden) this.pause(record, "hidden"); 547 if (document.hidden) this.pause(record, "hidden");
446 else this.resume(record, "hidden"); 548 else this.resume(record, "hidden");
447 } 549 }
448 550
449 const elements = visible.map(record => 551 const elements = visible.map(record =>
450 this.createNotificationElement(record) 552 this.createNotificationElement(record)
451 ); 553 );
452 for (let index = 0; index < elements.length; index++) { 554 for (let index = 0; index < elements.length; index++) {
453 if (this.stack.children[index] !== elements[index]) { 555 const current = [...this.stack.children]
556 .filter(element => !this.#exitingElements.has(element))[index];
557 if (current !== elements[index]) {
454 this.stack.insertBefore( 558 this.stack.insertBefore(
455 elements[index], 559 elements[index],
456 this.stack.children[index] || null, 560 current || null,
457 ); 561 );
458 } 562 }
459 } 563 this.#resizeObserver?.observe(elements[index]);
460 while (this.stack.children.length > elements.length) { 564 }
461 this.stack.lastElementChild.remove(); 565 const desired = new Set(elements);
566 for (const element of [...this.stack.children]) {
567 if (!desired.has(element) &&
568 !this.#exitingElements.has(element)) {
569 element.remove();
570 }
462 } 571 }
463 572
464 for (const record of visible) { 573 for (const record of visible) {
465 const element = record.view.article; 574 const element = record.view.article;
466 if (element.matches(":hover")) this.pause(record, "hover"); 575 if (element.matches(":hover")) this.pause(record, "hover");
469 this.pause(record, "focus"); 578 this.pause(record, "focus");
470 } else this.resume(record, "focus"); 579 } else this.resume(record, "focus");
471 this.announce(record); 580 this.announce(record);
472 this.schedule(record); 581 this.schedule(record);
473 } 582 }
583 this.requestLayout();
474 } 584 }
475 } 585 }
476 586
477 const existing = customElements.get("zen-notifications"); 587 const existing = customElements.get("zen-notifications");
478 if (!existing) { 588 if (!existing) {