diff --git a/src/app/components/Toaster.module.css b/src/app/components/Toaster.module.css index 3c96109..b7879e5 100644 --- a/src/app/components/Toaster.module.css +++ b/src/app/components/Toaster.module.css @@ -70,6 +70,26 @@ } } +@keyframes toast-out { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(8px); + } +} + +/* Applied for the exit phase: plays toast-out, then the JS timeout removes the + toast from the store. Under prefers-reduced-motion, base.css forces + animation-duration to 0.01ms so the toast vanishes immediately; the matching + JS timeout (TOAST_EXIT_MS) is set to the same 150ms value — 0.01ms rounds + to the next rAF in practice, which is imperceptibly fast. */ +.exiting { + animation: toast-out var(--dur-moderate) var(--ease) forwards; +} + .body { flex: 1; min-width: 0; diff --git a/src/app/components/Toaster.test.tsx b/src/app/components/Toaster.test.tsx index 0dbfd4b..466a470 100644 --- a/src/app/components/Toaster.test.tsx +++ b/src/app/components/Toaster.test.tsx @@ -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(); + }); + + // 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(); + }); + + 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(); + }); + + // 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(); + }); + + // 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(); + }); + + 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(); + }); +}); diff --git a/src/app/components/Toaster.tsx b/src/app/components/Toaster.tsx index 0201fdc..fc77cfc 100644 --- a/src/app/components/Toaster.tsx +++ b/src/app/components/Toaster.tsx @@ -8,9 +8,16 @@ * critical message shouldn't vanish on a timer); success/info auto-dismiss. A * failure that carries diagnostic `detail` exposes it under a collapsed * "Technical details" disclosure — available to report, without shouting. + * + * Dismissal is two-phase: the first call to `startExit` applies the exit CSS + * animation; after TOAST_EXIT_MS the notification is removed from the store. + * This matches the toast-out @keyframes duration in Toaster.module.css. + * Under prefers-reduced-motion, base.css drives animation-duration to ~0ms, + * so the toast vanishes on the next frame — the JS timeout fires immediately + * afterwards and cleans up the store entry. */ -import { useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNotificationStore, type Notification, @@ -22,6 +29,14 @@ import styles from './Toaster.module.css'; /** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */ const AUTO_DISMISS_MS = 6000; +/** + * Duration of the exit animation — must match var(--dur-moderate) (150ms) used + * by the .exiting class in Toaster.module.css. Under prefers-reduced-motion, + * base.css zeroes animation durations so the animation completes in ~0ms; the + * timeout fires on the next tick, keeping cleanup prompt. + */ +export const TOAST_EXIT_MS = 150; + /** Kinds that fade on their own; errors and warnings wait for the user. */ const AUTO_DISMISS: Record = { success: true, @@ -46,21 +61,31 @@ const KIND_ICON: Record = { info: 'status-info', }; -function Toast({ notification }: { notification: Notification }) { - const dismiss = useNotificationStore((s) => s.dismiss); +function Toast({ + notification, + exiting, + onStartExit, +}: { + notification: Notification; + exiting: boolean; + onStartExit: (id: string) => void; +}) { const { id, kind, title, message, detail } = notification; - // Positive/informational toasts clear themselves after a beat; the close - // button still works for an early dismissal. + // Trigger exit animation; after the animation duration the store removes it. + const handleDismiss = useCallback(() => onStartExit(id), [id, onStartExit]); + + // Positive/informational toasts clear themselves after a beat. Resets on + // re-render only when id/kind changes (stable identity guarantee). useEffect(() => { if (!AUTO_DISMISS[kind]) return; - const timer = setTimeout(() => dismiss(id), AUTO_DISMISS_MS); + const timer = setTimeout(handleDismiss, AUTO_DISMISS_MS); return () => clearTimeout(timer); - }, [id, kind, dismiss]); + }, [id, kind, handleDismiss]); return (
@@ -80,7 +105,7 @@ function Toast({ notification }: { notification: Notification }) { type="button" className={styles.close} aria-label="Dismiss notification" - onClick={() => dismiss(id)} + onClick={handleDismiss} > @@ -90,11 +115,40 @@ function Toast({ notification }: { notification: Notification }) { export function Toaster() { const notifications = useNotificationStore((s) => s.notifications); - if (notifications.length === 0) return null; + const dismiss = useNotificationStore((s) => s.dismiss); + + // Local set of ids currently playing their exit animation. Each call to + // startExit marks the id, then after TOAST_EXIT_MS removes it from the store. + // Multiple toasts can be leaving simultaneously. + const [exiting, setExiting] = useState>(new Set()); + + const startExit = useCallback( + (id: string) => { + setExiting((prev) => { + if (prev.has(id)) return prev; // already leaving — no-op + const next = new Set(prev); + next.add(id); + return next; + }); + setTimeout(() => { + dismiss(id); + // Clean the exiting set so stale ids don't accumulate (the store entry + // is gone, but the Set would grow unboundedly on repeated notify/dismiss). + setExiting((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }, TOAST_EXIT_MS); + }, + [dismiss], + ); + + if (notifications.length === 0 && exiting.size === 0) return null; return (
{notifications.map((n) => ( - + ))}
);