Shorten notification dwell time and add dismissible toast stack.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

This commit is contained in:
AetherForge
2026-06-07 18:22:27 -07:00
parent b0240abecc
commit f795ff2b47
12 changed files with 677 additions and 8 deletions

View File

@@ -0,0 +1,99 @@
/**
* @vitest-environment happy-dom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
import { ToastProvider, useToast, TOAST_DURATION_MS, MAX_VISIBLE_TOASTS } from './ToastContext';
function Probe() {
const { toasts, showToast, dismissToast, clearAllToasts } = useToast();
return (
<div>
<span data-testid="count">{toasts.length}</span>
<button type="button" onClick={() => showToast('hello')}>
info
</button>
<button type="button" onClick={() => showToast('bad', { level: 'error' })}>
error
</button>
<button type="button" onClick={() => dismissToast(toasts[0]?.id)}>
dismiss
</button>
<button type="button" onClick={clearAllToasts}>
clear
</button>
</div>
);
}
describe('ToastContext', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('uses shorter default durations for info than error', () => {
expect(TOAST_DURATION_MS.info).toBeLessThan(TOAST_DURATION_MS.error);
expect(TOAST_DURATION_MS.info).toBeGreaterThanOrEqual(4000);
expect(TOAST_DURATION_MS.info).toBeLessThanOrEqual(6000);
});
it('auto-dismisses info toasts and keeps errors longer', () => {
render(
<ToastProvider>
<Probe />
</ToastProvider>,
);
fireEvent.click(screen.getByText('info'));
fireEvent.click(screen.getByText('error'));
expect(screen.getByTestId('count').textContent).toBe('2');
act(() => {
vi.advanceTimersByTime(TOAST_DURATION_MS.info);
});
expect(screen.getByTestId('count').textContent).toBe('1');
act(() => {
vi.advanceTimersByTime(TOAST_DURATION_MS.error - TOAST_DURATION_MS.info);
});
expect(screen.getByTestId('count').textContent).toBe('0');
});
it('caps visible toasts', () => {
function Spam() {
const { showToast, toasts } = useToast();
return (
<button type="button" onClick={() => {
for (let i = 0; i < MAX_VISIBLE_TOASTS + 3; i += 1) showToast(`t${i}`);
}}>
spam ({toasts.length})
</button>
);
}
render(
<ToastProvider>
<Spam />
</ToastProvider>,
);
fireEvent.click(screen.getByText(/spam/));
expect(screen.getByText(/spam/)).toHaveTextContent(String(MAX_VISIBLE_TOASTS));
});
it('dismisses and clears all manually', () => {
render(
<ToastProvider>
<Probe />
</ToastProvider>,
);
fireEvent.click(screen.getByText('info'));
fireEvent.click(screen.getByText('error'));
fireEvent.click(screen.getByText('dismiss'));
expect(screen.getByTestId('count').textContent).toBe('1');
fireEvent.click(screen.getByText('clear'));
expect(screen.getByTestId('count').textContent).toBe('0');
});
});

View File

@@ -0,0 +1,123 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
export type ToastLevel = 'info' | 'success' | 'warn' | 'error';
export interface Toast {
id: string;
level: ToastLevel;
message: string;
detail?: string;
createdAt: number;
durationMs: number;
}
export interface ShowToastOptions {
level?: ToastLevel;
detail?: string;
/** Override default auto-dismiss; 0 = stay until dismissed */
durationMs?: number;
}
export const TOAST_DURATION_MS: Record<ToastLevel, number> = {
info: 5000,
success: 5000,
warn: 6000,
error: 10000,
};
export const MAX_VISIBLE_TOASTS = 5;
let toastCounter = 0;
function nextToastId(): string {
toastCounter += 1;
return `toast-${toastCounter}`;
}
export interface ToastContextValue {
toasts: Toast[];
showToast: (message: string, options?: ShowToastOptions) => string;
dismissToast: (id: string) => void;
clearAllToasts: () => void;
}
const ToastContext = createContext<ToastContextValue>({
toasts: [],
showToast: () => '',
dismissToast: () => {},
clearAllToasts: () => {},
});
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const dismissToast = useCallback((id: string) => {
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const clearAllToasts = useCallback(() => {
for (const timer of timersRef.current.values()) clearTimeout(timer);
timersRef.current.clear();
setToasts([]);
}, []);
const scheduleDismiss = useCallback(
(toast: Toast) => {
if (toast.durationMs <= 0) return;
const timer = setTimeout(() => dismissToast(toast.id), toast.durationMs);
timersRef.current.set(toast.id, timer);
},
[dismissToast],
);
const showToast = useCallback(
(message: string, options?: ShowToastOptions) => {
const level = options?.level ?? 'info';
const durationMs = options?.durationMs ?? TOAST_DURATION_MS[level];
const toast: Toast = {
id: nextToastId(),
level,
message,
detail: options?.detail,
createdAt: Date.now(),
durationMs,
};
setToasts((prev) => [toast, ...prev].slice(0, MAX_VISIBLE_TOASTS));
scheduleDismiss(toast);
return toast.id;
},
[scheduleDismiss],
);
useEffect(() => {
const timers = timersRef.current;
return () => {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
};
}, []);
const value = useMemo(
() => ({ toasts, showToast, dismissToast, clearAllToasts }),
[toasts, showToast, dismissToast, clearAllToasts],
);
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
}
export function useToast(): ToastContextValue {
return useContext(ToastContext);
}