Add toast fade-out animation (M6, §01F)

This commit is contained in:
2026-06-07 20:01:53 +03:00
parent 14f34712b2
commit 1bead4d004
3 changed files with 235 additions and 13 deletions
+150 -2
View File
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useNotificationStore, type NotificationKind } from '../stores/NotificationStore';
import { Toaster } from './Toaster';
import { Toaster, TOAST_EXIT_MS } from './Toaster';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -49,3 +49,151 @@ describe('Toaster status glyphs', () => {
expect(container.querySelector('[role="status"]')).not.toBeNull();
});
});
describe('Toaster exit animation (two-phase dismiss)', () => {
test('close button applies the exiting class immediately and keeps the toast visible', () => {
vi.useFakeTimers();
let id: string;
act(() => {
id = useNotificationStore.getState().notify({ kind: 'error', title: 'T', message: 'M' });
root.render(<Toaster />);
});
// Toast is visible.
const closeBtn = container.querySelector('button[aria-label="Dismiss notification"]');
expect(closeBtn).not.toBeNull();
// Click close — begins the exit phase.
act(() => {
closeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// Still in the DOM (store not yet cleared), but carrying the exiting class.
const toast = container.querySelector('[role="alert"]');
expect(toast).not.toBeNull();
expect(toast!.className).toMatch(/exiting/);
// Notification still in the store during the animation window.
expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true);
vi.useRealTimers();
});
test('toast is removed from the store after TOAST_EXIT_MS', () => {
vi.useFakeTimers();
let id: string;
act(() => {
id = useNotificationStore.getState().notify({ kind: 'error', title: 'T', message: 'M' });
root.render(<Toaster />);
});
const closeBtn = container.querySelector('button[aria-label="Dismiss notification"]');
act(() => {
closeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// Just before the timeout fires — still in the store.
act(() => {
vi.advanceTimersByTime(TOAST_EXIT_MS - 1);
});
expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true);
// After the timeout — removed from the store.
act(() => {
vi.advanceTimersByTime(1);
});
expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(false);
vi.useRealTimers();
});
test('auto-dismiss (success) also plays the exit animation before removing', () => {
vi.useFakeTimers();
const AUTO_DISMISS_MS = 6000;
let id: string;
act(() => {
id = useNotificationStore.getState().notify({ kind: 'success', title: 'T', message: 'M' });
root.render(<Toaster />);
});
// Before auto-dismiss fires — toast is visible and not exiting.
expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true);
const toastBefore = container.querySelector('[role="status"]');
expect(toastBefore).not.toBeNull();
expect(toastBefore!.className).not.toMatch(/exiting/);
// At the auto-dismiss threshold — exit phase begins.
act(() => {
vi.advanceTimersByTime(AUTO_DISMISS_MS);
});
const toastMid = container.querySelector('[role="status"]');
expect(toastMid).not.toBeNull();
expect(toastMid!.className).toMatch(/exiting/);
// Still in store during animation.
expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(true);
// After the exit animation — removed from store.
act(() => {
vi.advanceTimersByTime(TOAST_EXIT_MS);
});
expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(false);
vi.useRealTimers();
});
test('multiple toasts can be in the exiting state simultaneously', () => {
vi.useFakeTimers();
act(() => {
useNotificationStore.getState().notify({ kind: 'error', title: 'A', message: 'a' });
useNotificationStore.getState().notify({ kind: 'warning', title: 'B', message: 'b' });
root.render(<Toaster />);
});
// Click close on both toasts.
const closeBtns = container.querySelectorAll('button[aria-label="Dismiss notification"]');
expect(closeBtns.length).toBe(2);
act(() => {
closeBtns[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
closeBtns[1].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// Both are in the exiting state — both still in the DOM.
const toasts = container.querySelectorAll('[role="alert"]');
expect(toasts.length).toBe(2);
toasts.forEach((t) => expect(t.className).toMatch(/exiting/));
// Both removed from the store after the timeout.
act(() => {
vi.advanceTimersByTime(TOAST_EXIT_MS);
});
expect(useNotificationStore.getState().notifications).toHaveLength(0);
vi.useRealTimers();
});
test('clicking close twice does not reset the exit timer', () => {
vi.useFakeTimers();
let id: string;
act(() => {
id = useNotificationStore.getState().notify({ kind: 'error', title: 'T', message: 'M' });
root.render(<Toaster />);
});
const closeBtn = container.querySelector('button[aria-label="Dismiss notification"]');
act(() => {
closeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// A second click while already exiting — should be a no-op for the timer.
act(() => {
closeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(TOAST_EXIT_MS);
});
// Notification is gone — second click didn't schedule a duplicate removal.
expect(useNotificationStore.getState().notifications.some((n) => n.id === id!)).toBe(false);
vi.useRealTimers();
});
});