Mercurial
changeset 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 |
| files | .claude/skills/zenbu-design-system/SKILL.md design_system/NOTIFICATIONS.md design_system/src/components/notifications.js design_system/src/styles/components.css design_system/test/storybook_test.js |
| diffstat | 5 files changed, 256 insertions(+), 12 deletions(-) [+] |
line wrap: on
line diff
--- a/.claude/skills/zenbu-design-system/SKILL.md Tue Aug 04 11:57:16 2026 -0700 +++ b/.claude/skills/zenbu-design-system/SKILL.md Tue Aug 04 12:21:19 2026 -0700 @@ -24,6 +24,8 @@ non-composed `zen-notify` events and bounded queues. - Keep `zen-notifications` behavior aligned with `design_system/NOTIFICATIONS.md`. +- Notification presentation uses a measured collapsed depth stack that expands + on hover/focus; do not import Sonner or Radix. - Preserve keyboard, form, validity, label, and ARIA behavior. - Prefix custom elements and tokens with `zen-` / `--zen-`. - Keep selectors low-specificity so applications can override tokens.
--- a/design_system/NOTIFICATIONS.md Tue Aug 04 11:57:16 2026 -0700 +++ b/design_system/NOTIFICATIONS.md Tue Aug 04 12:21:19 2026 -0700 @@ -75,6 +75,9 @@ - Three records are rendered. - Twenty additional records may remain queued. +- The idle stack layers older records behind the newest with depth and scale. +- Hovering the stack or focusing an action expands all visible records using + their measured heights; no Sonner/Radix runtime or CSS is used. - Overflow evicts the oldest finite record; all-persistent queues reject new records. - Queued records do not count down.
--- 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(); } }
--- a/design_system/src/styles/components.css Tue Aug 04 11:57:16 2026 -0700 +++ b/design_system/src/styles/components.css Tue Aug 04 12:21:19 2026 -0700 @@ -213,13 +213,25 @@ --zen-notification-bottom-inset, var(--zen-space-4) ); - display: grid; inline-size: min(24rem, calc(100vi - 2rem)); - gap: var(--zen-space-3); - pointer-events: none; + height: var(--zen-notification-collapsed-height, 0); + --zen-notification-gap: 12px; + pointer-events: auto; + isolation: isolate; + transition: height 220ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +zen-notifications > [data-zen-notification-stack]:is( + :hover, + :focus-within +) { + height: var(--zen-notification-expanded-height, 0); } zen-notifications > [data-zen-notification-stack] > article { + position: absolute; + inset-inline: 0; + inset-block-end: 0; display: grid; grid-template-columns: 1.75rem minmax(0, 1fr) 2rem; align-items: start; @@ -230,8 +242,46 @@ box-shadow: var(--zen-shadow-md); background: var(--zen-color-surface-raised); color: var(--zen-color-text); + opacity: var(--zen-notification-collapsed-opacity, 1); + pointer-events: none; + transform: + translate3d( + 0, + var(--zen-notification-collapsed-offset, 0), + 0 + ) + scale(var(--zen-notification-scale, 1)); + transform-origin: center bottom; + transition: + opacity 180ms ease, + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); + will-change: transform; + animation: zen-notification-enter 160ms ease-out; +} + +zen-notifications > [data-zen-notification-stack] > article[data-depth="0"] { pointer-events: auto; - animation: zen-notification-enter 160ms ease-out; +} + +zen-notifications > [data-zen-notification-stack]:is( + :hover, + :focus-within +) > article { + opacity: 1; + pointer-events: auto; + transform: + translate3d( + 0, + var(--zen-notification-offset, 0px), + 0 + ) + scale(1); +} + +zen-notifications > [data-zen-notification-stack] > article[data-removing] { + opacity: 0; + pointer-events: none; + transform: translate3d(110%, 0, 0) scale(0.96); } .zen-notification-tone { @@ -324,7 +374,6 @@ @keyframes zen-notification-enter { from { opacity: 0; - transform: translateY(0.75rem); } } @@ -347,5 +396,10 @@ zen-notifications > [data-zen-notification-stack] > article { animation: none; + transition: none; + } + + zen-notifications > [data-zen-notification-stack] { + transition: none; } }
--- a/design_system/test/storybook_test.js Tue Aug 04 11:57:16 2026 -0700 +++ b/design_system/test/storybook_test.js Tue Aug 04 12:21:19 2026 -0700 @@ -438,6 +438,81 @@ await notificationScope.locator('article').count(), 3, ); + await page.waitForFunction(() => + [...document.querySelectorAll( + 'zen-notifications article', + )].some(article => article.dataset.depth === '2') + ); + const collapsedStack = await notificationScope.locator( + '[data-zen-notification-stack]', + ).evaluate(stack => { + const articles = [...stack.querySelectorAll('article')]; + return { + depths: articles.map(article => article.dataset.depth), + height: stack.getBoundingClientRect().height, + positions: articles.map(article => + getComputedStyle(article).position + ), + transforms: articles.map(article => + getComputedStyle(article).transform + ), + }; + }); + assert.deepEqual(collapsedStack.depths, ['2', '1', '0']); + assert.deepEqual( + collapsedStack.positions, + ['absolute', 'absolute', 'absolute'], + ); + await notificationScope.locator('article').last().hover(); + await page.waitForTimeout(300); + const expandedStack = await notificationScope.locator( + '[data-zen-notification-stack]', + ).evaluate(stack => ({ + height: stack.getBoundingClientRect().height, + rects: [...stack.querySelectorAll('article')].map(article => { + const bounds = article.getBoundingClientRect(); + return { + bottom: bounds.bottom, + left: bounds.left, + right: bounds.right, + top: bounds.top, + }; + }), + tops: [...stack.querySelectorAll('article')].map( + article => Math.round(article.getBoundingClientRect().top), + ), + transforms: [...stack.querySelectorAll('article')].map( + article => getComputedStyle(article).transform, + ), + })); + assert.ok(expandedStack.height > collapsedStack.height); + assert.equal(new Set(expandedStack.tops).size, 3); + const expandedGaps = [ + expandedStack.rects[1].top - expandedStack.rects[0].bottom, + expandedStack.rects[2].top - expandedStack.rects[1].bottom, + ]; + for (const gap of expandedGaps) { + assert.ok(gap >= 10 && gap <= 14, JSON.stringify(expandedGaps)); + } + assert.notDeepEqual( + expandedStack.transforms, + collapsedStack.transforms, + ); + const gapX = + (expandedStack.rects[0].left + expandedStack.rects[0].right) / 2; + const gapY = + (expandedStack.rects[0].bottom + expandedStack.rects[1].top) / 2; + await page.mouse.move(gapX, gapY); + await page.waitForTimeout(100); + const gapState = await notificationScope.locator( + '[data-zen-notification-stack]', + ).evaluate(stack => ({ + height: stack.getBoundingClientRect().height, + hovered: stack.matches(':hover'), + })); + assert.equal(gapState.hovered, true); + assert.ok(gapState.height >= expandedStack.height - 1); + await page.mouse.move(0, 0); const actionScope = await page.evaluate(() => { const scope = document.createElement('zen-notifications'); @@ -522,7 +597,7 @@ }, )); }); - assert.equal(await actionArticle.count(), 0); + await actionArticle.waitFor({ state: 'detached' }); await page.evaluate(() => { const scope = document.createElement('zen-notifications');