Mercurial
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) { |