mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add toast fade-out animation (M6, §01F)
This commit is contained in:
@@ -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 {
|
.body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -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 { act } from 'react';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
import { useNotificationStore, type NotificationKind } from '../stores/NotificationStore';
|
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;
|
(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();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,9 +8,16 @@
|
|||||||
* critical message shouldn't vanish on a timer); success/info auto-dismiss. A
|
* critical message shouldn't vanish on a timer); success/info auto-dismiss. A
|
||||||
* failure that carries diagnostic `detail` exposes it under a collapsed
|
* failure that carries diagnostic `detail` exposes it under a collapsed
|
||||||
* "Technical details" disclosure — available to report, without shouting.
|
* "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 {
|
import {
|
||||||
useNotificationStore,
|
useNotificationStore,
|
||||||
type Notification,
|
type Notification,
|
||||||
@@ -22,6 +29,14 @@ import styles from './Toaster.module.css';
|
|||||||
/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */
|
/** Auto-dismiss delay for the non-critical kinds (ms). Errors/warnings persist. */
|
||||||
const AUTO_DISMISS_MS = 6000;
|
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. */
|
/** Kinds that fade on their own; errors and warnings wait for the user. */
|
||||||
const AUTO_DISMISS: Record<NotificationKind, boolean> = {
|
const AUTO_DISMISS: Record<NotificationKind, boolean> = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -46,21 +61,31 @@ const KIND_ICON: Record<NotificationKind, IconName> = {
|
|||||||
info: 'status-info',
|
info: 'status-info',
|
||||||
};
|
};
|
||||||
|
|
||||||
function Toast({ notification }: { notification: Notification }) {
|
function Toast({
|
||||||
const dismiss = useNotificationStore((s) => s.dismiss);
|
notification,
|
||||||
|
exiting,
|
||||||
|
onStartExit,
|
||||||
|
}: {
|
||||||
|
notification: Notification;
|
||||||
|
exiting: boolean;
|
||||||
|
onStartExit: (id: string) => void;
|
||||||
|
}) {
|
||||||
const { id, kind, title, message, detail } = notification;
|
const { id, kind, title, message, detail } = notification;
|
||||||
|
|
||||||
// Positive/informational toasts clear themselves after a beat; the close
|
// Trigger exit animation; after the animation duration the store removes it.
|
||||||
// button still works for an early dismissal.
|
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(() => {
|
useEffect(() => {
|
||||||
if (!AUTO_DISMISS[kind]) return;
|
if (!AUTO_DISMISS[kind]) return;
|
||||||
const timer = setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
|
const timer = setTimeout(handleDismiss, AUTO_DISMISS_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [id, kind, dismiss]);
|
}, [id, kind, handleDismiss]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.toast} ${KIND_CLASS[kind]}`}
|
className={`${styles.toast} ${KIND_CLASS[kind]}${exiting ? ` ${styles.exiting}` : ''}`}
|
||||||
// Errors/warnings interrupt assistive tech (assertive); the rest are polite.
|
// Errors/warnings interrupt assistive tech (assertive); the rest are polite.
|
||||||
role={kind === 'error' || kind === 'warning' ? 'alert' : 'status'}
|
role={kind === 'error' || kind === 'warning' ? 'alert' : 'status'}
|
||||||
>
|
>
|
||||||
@@ -80,7 +105,7 @@ function Toast({ notification }: { notification: Notification }) {
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.close}
|
className={styles.close}
|
||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
onClick={() => dismiss(id)}
|
onClick={handleDismiss}
|
||||||
>
|
>
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
@@ -90,11 +115,40 @@ function Toast({ notification }: { notification: Notification }) {
|
|||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const notifications = useNotificationStore((s) => s.notifications);
|
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<ReadonlySet<string>>(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 (
|
return (
|
||||||
<div className={styles.region} role="region" aria-label="Notifications">
|
<div className={styles.region} role="region" aria-label="Notifications">
|
||||||
{notifications.map((n) => (
|
{notifications.map((n) => (
|
||||||
<Toast key={n.id} notification={n} />
|
<Toast key={n.id} notification={n} exiting={exiting.has(n.id)} onStartExit={startExit} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user