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 c9acfff2bc
commit 71efbd1bc1
2 changed files with 237 additions and 64 deletions
+99 -18
View File
@@ -16,6 +16,16 @@ const OVERLAY_NOTIFICATION_POSITION_CLASSES = [
'position-top',
'position-top-right',
] 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<
Pick<OverlayNotificationPayload, 'id' | 'title' | 'persistent'>
@@ -139,6 +149,10 @@ export function createOverlayNotificationRenderer(
) {
const store = createOverlayNotificationStore();
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;
function clearTimer(id: string): void {
@@ -149,33 +163,65 @@ 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 {
clearTimer(id);
store.remove(id);
const card = cards.get(id);
if (card) {
beginExit(id, card);
} else {
render();
}
}
function render(): void {
const visible = store.visible();
ctx.dom.overlayNotificationStack.replaceChildren();
ctx.dom.overlayNotificationStack.classList.toggle('hidden', visible.length === 0);
ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_NOTIFICATION_POSITION_CLASSES);
ctx.dom.overlayNotificationStack.classList.add(overlayNotificationPositionClass(position));
for (const entry of visible) {
function populateCard(card: HTMLElement, entry: OverlayNotificationEntry): void {
const imageSource = normalizeImageSource(entry.image);
const card = document.createElement('section');
card.className = `overlay-notification-card ${normalizeVariant(entry.variant)}${
imageSource ? ' has-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 leading = imageSource ? document.createElement('img') : document.createElement('span');
leading.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon';
leading.setAttribute('aria-hidden', 'true');
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 = leading as HTMLImageElement;
const image = leadingEl as HTMLImageElement;
image.src = imageSource;
image.alt = '';
image.decoding = 'async';
@@ -220,11 +266,46 @@ export function createOverlayNotificationRenderer(
closeButton.textContent = '×';
closeButton.addEventListener('click', () => remove(entry.id));
card.append(leading, content, closeButton);
card.replaceChildren(leadingEl, content, closeButton);
}
function render(): void {
const visible = store.visible();
const visibleIds = new Set(visible.map((entry) => entry.id));
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.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) {
let card = cards.get(entry.id);
if (card && leaving.has(entry.id)) {
// The card was animating out but has been re-shown: cancel the exit.
leaving.delete(entry.id);
card.classList.remove('leaving');
}
if (!card) {
card = document.createElement('section');
card.classList.add('entering');
cards.set(entry.id, card);
}
populateCard(card, entry);
// Appending an element already in the stack just moves it, keeping visible order
// without restarting its enter animation.
ctx.dom.overlayNotificationStack.append(card);
}
if (visible.length === 0) {
if (visible.length === 0 && leaving.size === 0) {
setInteractiveState(ctx, false);
}
options.onChanged?.();
+96 -4
View File
@@ -198,7 +198,32 @@ body:focus-visible,
0 1px 0 rgba(202, 211, 245, 0.05) inset;
color: var(--ctp-text);
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 {
@@ -365,15 +390,82 @@ body:focus-visible,
color: var(--ctp-red);
}
@keyframes overlay-notification-enter {
@keyframes overlay-notification-enter-right {
from {
opacity: 0;
transform: translateY(-6px);
transform: translateX(28px) scale(0.96);
}
to {
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;
}
}