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',
|
||||||
'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,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 {
|
function remove(id: string): void {
|
||||||
clearTimer(id);
|
clearTimer(id);
|
||||||
store.remove(id);
|
store.remove(id);
|
||||||
|
const card = cards.get(id);
|
||||||
|
if (card) {
|
||||||
|
beginExit(id, card);
|
||||||
|
} else {
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function render(): void {
|
function populateCard(card: HTMLElement, entry: OverlayNotificationEntry): 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) {
|
|
||||||
const imageSource = normalizeImageSource(entry.image);
|
const imageSource = normalizeImageSource(entry.image);
|
||||||
const card = document.createElement('section');
|
card.classList.add('overlay-notification-card');
|
||||||
card.className = `overlay-notification-card ${normalizeVariant(entry.variant)}${
|
for (const variant of OVERLAY_NOTIFICATION_VARIANT_CLASSES) {
|
||||||
imageSource ? ' has-image' : ''
|
card.classList.toggle(variant, variant === normalizeVariant(entry.variant));
|
||||||
}`;
|
}
|
||||||
|
card.classList.toggle('has-image', Boolean(imageSource));
|
||||||
card.dataset.notificationId = entry.id;
|
card.dataset.notificationId = entry.id;
|
||||||
card.setAttribute('role', 'status');
|
card.setAttribute('role', 'status');
|
||||||
|
|
||||||
const leading = imageSource ? document.createElement('img') : document.createElement('span');
|
const leadingEl = imageSource ? document.createElement('img') : document.createElement('span');
|
||||||
leading.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon';
|
leadingEl.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon';
|
||||||
leading.setAttribute('aria-hidden', 'true');
|
leadingEl.setAttribute('aria-hidden', 'true');
|
||||||
if (imageSource) {
|
if (imageSource) {
|
||||||
const image = leading as HTMLImageElement;
|
const image = leadingEl as HTMLImageElement;
|
||||||
image.src = imageSource;
|
image.src = imageSource;
|
||||||
image.alt = '';
|
image.alt = '';
|
||||||
image.decoding = 'async';
|
image.decoding = 'async';
|
||||||
@@ -220,11 +266,46 @@ export function createOverlayNotificationRenderer(
|
|||||||
closeButton.textContent = '×';
|
closeButton.textContent = '×';
|
||||||
closeButton.addEventListener('click', () => remove(entry.id));
|
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);
|
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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user