mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 15:13:32 -07:00
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:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user