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>
);
}
|