feat(notifications): add enter/leave animations and DOM-reuse for overla

- Direction-aware slide animations (right/left/top) on enter and leave
- Cards keyed by id so re-renders reuse elements; enter animation only plays once
- Exit animates via .leaving class then removes; fallback timer guards missing animationend
- Respects prefers-reduced-motion
This commit is contained in:
2026-06-05 00:38:59 -07:00
parent d07a3b7d55
commit b177a2fb4d
2 changed files with 237 additions and 64 deletions
+141 -60
View File
@@ -16,6 +16,16 @@ const OVERLAY_NOTIFICATION_POSITION_CLASSES = [
'position-top', 'position-top',
'position-top-right', 'position-top-right',
] as const; ] as const;
const OVERLAY_NOTIFICATION_VARIANT_CLASSES = [
'info',
'progress',
'success',
'warning',
'error',
] as const;
// Matches the `.leaving` animation duration in style.css; the fallback timer guards
// against `animationend` never firing (e.g. element detached or reduced-motion).
const OVERLAY_NOTIFICATION_EXIT_FALLBACK_MS = 260;
export type OverlayNotificationEntry = Required< export type OverlayNotificationEntry = Required<
Pick<OverlayNotificationPayload, 'id' | 'title' | 'persistent'> Pick<OverlayNotificationPayload, 'id' | 'title' | 'persistent'>
@@ -139,6 +149,10 @@ export function createOverlayNotificationRenderer(
) { ) {
const store = createOverlayNotificationStore(); const store = createOverlayNotificationStore();
const timers = new Map<string, number>(); const timers = new Map<string, number>();
// Live card elements keyed by notification id so re-renders reuse them: the enter
// animation only plays for freshly created cards instead of replaying on every render.
const cards = new Map<string, HTMLElement>();
const leaving = new Set<string>();
let position: OverlayNotificationPosition = DEFAULT_OVERLAY_NOTIFICATION_POSITION; let position: OverlayNotificationPosition = DEFAULT_OVERLAY_NOTIFICATION_POSITION;
function clearTimer(id: string): void { function clearTimer(id: string): void {
@@ -149,82 +163,149 @@ export function createOverlayNotificationRenderer(
} }
} }
function commitExit(id: string, card: HTMLElement): void {
if (!leaving.has(id)) return;
leaving.delete(id);
cards.delete(id);
card.remove();
if (cards.size === 0) {
ctx.dom.overlayNotificationStack.classList.add('hidden');
setInteractiveState(ctx, false);
}
options.onChanged?.();
}
function beginExit(id: string, card: HTMLElement): void {
if (leaving.has(id)) return;
leaving.add(id);
card.classList.remove('entering');
card.classList.add('leaving');
const finalize = () => {
window.clearTimeout(fallback);
commitExit(id, card);
};
const fallback = window.setTimeout(finalize, OVERLAY_NOTIFICATION_EXIT_FALLBACK_MS);
card.addEventListener(
'animationend',
(event) => {
if ((event as AnimationEvent).animationName?.startsWith('overlay-notification-leave')) {
finalize();
}
},
{ once: true },
);
}
function remove(id: string): void { function remove(id: string): void {
clearTimer(id); clearTimer(id);
store.remove(id); store.remove(id);
render(); const card = cards.get(id);
if (card) {
beginExit(id, card);
} else {
render();
}
}
function populateCard(card: HTMLElement, entry: OverlayNotificationEntry): void {
const imageSource = normalizeImageSource(entry.image);
card.classList.add('overlay-notification-card');
for (const variant of OVERLAY_NOTIFICATION_VARIANT_CLASSES) {
card.classList.toggle(variant, variant === normalizeVariant(entry.variant));
}
card.classList.toggle('has-image', Boolean(imageSource));
card.dataset.notificationId = entry.id;
card.setAttribute('role', 'status');
const leadingEl = imageSource ? document.createElement('img') : document.createElement('span');
leadingEl.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon';
leadingEl.setAttribute('aria-hidden', 'true');
if (imageSource) {
const image = leadingEl as HTMLImageElement;
image.src = imageSource;
image.alt = '';
image.decoding = 'async';
}
const content = document.createElement('div');
content.className = 'overlay-notification-content';
const title = document.createElement('div');
title.className = 'overlay-notification-title';
title.textContent = entry.title;
content.append(title);
if (entry.body && entry.body.trim().length > 0) {
const body = document.createElement('div');
body.className = 'overlay-notification-body';
body.textContent = entry.body;
content.append(body);
}
if (entry.actions && entry.actions.length > 0) {
const actions = document.createElement('div');
actions.className = 'overlay-notification-actions';
for (const action of entry.actions) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'overlay-notification-action';
button.textContent = action.label;
button.addEventListener('click', () => {
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id);
remove(entry.id);
});
actions.append(button);
}
content.append(actions);
}
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'overlay-notification-close';
closeButton.setAttribute('aria-label', 'Dismiss notification');
closeButton.textContent = '×';
closeButton.addEventListener('click', () => remove(entry.id));
card.replaceChildren(leadingEl, content, closeButton);
} }
function render(): void { function render(): void {
const visible = store.visible(); const visible = store.visible();
ctx.dom.overlayNotificationStack.replaceChildren(); const visibleIds = new Set(visible.map((entry) => entry.id));
ctx.dom.overlayNotificationStack.classList.toggle('hidden', visible.length === 0); ctx.dom.overlayNotificationStack.classList.toggle(
'hidden',
visible.length === 0 && leaving.size === 0,
);
ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_NOTIFICATION_POSITION_CLASSES); ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_NOTIFICATION_POSITION_CLASSES);
ctx.dom.overlayNotificationStack.classList.add(overlayNotificationPositionClass(position)); ctx.dom.overlayNotificationStack.classList.add(overlayNotificationPositionClass(position));
// Cards that vanished from the store without an explicit remove() (e.g. pruned when
// over the visible cap) still need to animate out.
for (const [id, card] of cards) {
if (!visibleIds.has(id)) {
beginExit(id, card);
}
}
for (const entry of visible) { for (const entry of visible) {
const imageSource = normalizeImageSource(entry.image); let card = cards.get(entry.id);
const card = document.createElement('section'); if (card && leaving.has(entry.id)) {
card.className = `overlay-notification-card ${normalizeVariant(entry.variant)}${ // The card was animating out but has been re-shown: cancel the exit.
imageSource ? ' has-image' : '' leaving.delete(entry.id);
}`; card.classList.remove('leaving');
card.dataset.notificationId = entry.id;
card.setAttribute('role', 'status');
const leading = imageSource ? document.createElement('img') : document.createElement('span');
leading.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon';
leading.setAttribute('aria-hidden', 'true');
if (imageSource) {
const image = leading as HTMLImageElement;
image.src = imageSource;
image.alt = '';
image.decoding = 'async';
} }
if (!card) {
const content = document.createElement('div'); card = document.createElement('section');
content.className = 'overlay-notification-content'; card.classList.add('entering');
cards.set(entry.id, card);
const title = document.createElement('div');
title.className = 'overlay-notification-title';
title.textContent = entry.title;
content.append(title);
if (entry.body && entry.body.trim().length > 0) {
const body = document.createElement('div');
body.className = 'overlay-notification-body';
body.textContent = entry.body;
content.append(body);
} }
populateCard(card, entry);
if (entry.actions && entry.actions.length > 0) { // Appending an element already in the stack just moves it, keeping visible order
const actions = document.createElement('div'); // without restarting its enter animation.
actions.className = 'overlay-notification-actions';
for (const action of entry.actions) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'overlay-notification-action';
button.textContent = action.label;
button.addEventListener('click', () => {
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id);
remove(entry.id);
});
actions.append(button);
}
content.append(actions);
}
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'overlay-notification-close';
closeButton.setAttribute('aria-label', 'Dismiss notification');
closeButton.textContent = '×';
closeButton.addEventListener('click', () => remove(entry.id));
card.append(leading, content, closeButton);
ctx.dom.overlayNotificationStack.append(card); ctx.dom.overlayNotificationStack.append(card);
} }
if (visible.length === 0) { if (visible.length === 0 && leaving.size === 0) {
setInteractiveState(ctx, false); setInteractiveState(ctx, false);
} }
options.onChanged?.(); options.onChanged?.();
+96 -4
View File
@@ -198,7 +198,32 @@ body:focus-visible,
0 1px 0 rgba(202, 211, 245, 0.05) inset; 0 1px 0 rgba(202, 211, 245, 0.05) inset;
color: var(--ctp-text); color: var(--ctp-text);
overflow: hidden; overflow: hidden;
animation: overlay-notification-enter 140ms ease-out; }
/* Direction-aware enter/exit — slide in from the stack's anchored edge, slide back out. */
.overlay-notification-card.entering {
animation: overlay-notification-enter-right 240ms cubic-bezier(0.21, 1.02, 0.73, 1) both;
}
.overlay-notification-card.leaving {
pointer-events: none;
animation: overlay-notification-leave-right 190ms cubic-bezier(0.55, 0.06, 0.68, 0.19) both;
}
.overlay-notification-stack.position-top-left .overlay-notification-card.entering {
animation-name: overlay-notification-enter-left;
}
.overlay-notification-stack.position-top-left .overlay-notification-card.leaving {
animation-name: overlay-notification-leave-left;
}
.overlay-notification-stack.position-top .overlay-notification-card.entering {
animation-name: overlay-notification-enter-top;
}
.overlay-notification-stack.position-top .overlay-notification-card.leaving {
animation-name: overlay-notification-leave-top;
} }
.overlay-notification-card::before { .overlay-notification-card::before {
@@ -365,15 +390,82 @@ body:focus-visible,
color: var(--ctp-red); color: var(--ctp-red);
} }
@keyframes overlay-notification-enter { @keyframes overlay-notification-enter-right {
from { from {
opacity: 0; opacity: 0;
transform: translateY(-6px); transform: translateX(28px) scale(0.96);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateX(0) scale(1);
}
}
@keyframes overlay-notification-enter-left {
from {
opacity: 0;
transform: translateX(-28px) scale(0.96);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes overlay-notification-enter-top {
from {
opacity: 0;
transform: translateY(-16px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes overlay-notification-leave-right {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(28px) scale(0.94);
}
}
@keyframes overlay-notification-leave-left {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(-28px) scale(0.94);
}
}
@keyframes overlay-notification-leave-top {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(-14px) scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.overlay-notification-card.entering,
.overlay-notification-card.leaving {
animation-duration: 1ms;
} }
} }