All files / src/components Toast.tsx

97.77% Statements 44/45
100% Branches 18/18
95.23% Functions 20/21
97.14% Lines 34/35

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138                  16x 16x   16x 16x       16x               50x   50x         12x     105x 46x   46x 46x       478x   478x 128x 35x   128x 128x 128x       478x 6x     478x 478x   478x                   33x                     6x                           39x   35x 35x       39x           39x                             1x 1x 1x                    
import { useCallback, useEffect, useState } from "react";
 
export interface ToastMessage {
	id: number;
	text: string;
	type: "success" | "error" | "info";
	action?: { label: string; onClick: () => void };
}
 
let nextId = 0;
const listeners = new Set<(msg: ToastMessage) => void>();
// Track recent toasts to deduplicate rapid identical messages
const recentToasts: { text: string; type: string; time: number }[] = [];
const DEDUP_WINDOW_MS = 2000;
 
/** Reset dedup state — exposed for testing only */
export function _resetToastDedup() {
	recentToasts.length = 0;
}
 
export function toast(
	text: string,
	type: ToastMessage["type"] = "success",
	action?: { label: string; onClick: () => void },
) {
	const now = Date.now();
	// Prune expired entries
	while (
		recentToasts.length > 0 &&
		recentToasts[0] &&
		now - recentToasts[0].time > DEDUP_WINDOW_MS
	) {
		recentToasts.shift();
	}
	// Skip if an identical toast was shown recently (action toasts are never deduped)
	if (!action && recentToasts.some((t) => t.text === text && t.type === type)) return;
	recentToasts.push({ text, type, time: now });
 
	const msg: ToastMessage = { id: nextId++, text, type, action };
	for (const fn of listeners) fn(msg);
}
 
export function ToastContainer() {
	const [toasts, setToasts] = useState<ToastMessage[]>([]);
 
	useEffect(() => {
		const handler = (msg: ToastMessage) => {
			setToasts((prev) => [...prev, msg]);
		};
		listeners.add(handler);
		return () => {
			listeners.delete(handler);
		};
	}, []);
 
	const dismiss = useCallback((id: number) => {
		setToasts((prev) => prev.filter((t) => t.id !== id));
	}, []);
 
	const politeToasts = toasts.filter((t) => t.type !== "error");
	const assertiveToasts = toasts.filter((t) => t.type === "error");
 
	return (
		<>
			{/* Non-error toasts: polite announcements */}
			<div
				className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 pointer-events-none"
				role="status"
				aria-live="polite"
				aria-atomic="false"
			>
				{politeToasts.map((t) => (
					<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
				))}
			</div>
			{/* Error toasts: assertive announcements for screen readers */}
			<div
				className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 pointer-events-none"
				role="alert"
				aria-live="assertive"
				aria-atomic="false"
			>
				{assertiveToasts.map((t) => (
					<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
				))}
			</div>
		</>
	);
}
 
function ToastItem({
	toast: t,
	onDismiss,
}: {
	toast: ToastMessage;
	onDismiss: (id: number) => void;
}) {
	useEffect(() => {
		// Action toasts stay longer so the user can click the action (7s for undo window)
		const timer = setTimeout(() => onDismiss(t.id), t.action ? 7000 : 3000);
		return () => clearTimeout(timer);
	}, [t.id, t.action, onDismiss]);
 
	const bg =
		t.type === "error"
			? "bg-red-600 dark:bg-red-700"
			: t.type === "info"
				? "bg-gray-700 dark:bg-gray-600"
				: "bg-stork-600 dark:bg-stork-700";
 
	return (
		<div
			className={`${bg} text-white text-sm rounded-lg shadow-lg pointer-events-auto animate-slideUp max-w-xs flex items-center gap-2`}
		>
			<button
				type="button"
				onClick={() => onDismiss(t.id)}
				className="flex-1 text-left px-4 py-2.5 cursor-pointer"
			>
				{t.text}
			</button>
			{t.action && (
				<button
					type="button"
					onClick={(e) => {
						e.stopPropagation();
						t.action?.onClick();
						onDismiss(t.id);
					}}
					className="pr-3 py-2.5 font-semibold text-white/90 hover:text-white underline underline-offset-2 flex-shrink-0"
				>
					{t.action.label}
				</button>
			)}
		</div>
	);
}