import { useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"; import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../lib/notifications"; export const LEVEL_STYLE: Record = { info: { icon: Info, color: "text-info", bar: "bg-info" }, success: { icon: CheckCircle2, color: "text-success", bar: "bg-success" }, warning: { icon: AlertTriangle, color: "text-warn", bar: "bg-warn" }, error: { icon: AlertCircle, color: "text-danger", bar: "bg-danger" }, fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" }, }; export default function Toaster() { const { t } = useTranslation(); const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts); return ( // Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a // screen reader to announce content later inserted into it (a region created together with its // first message is unreliably announced). `aria-live` (not role="status") on purpose: // role="status" implies aria-atomic="true", which would re-read the WHOLE stack on every toast; // aria-live defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts // additionally carry role="alert", which announces assertively on insertion regardless.
{toasts.map((toast) => { const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level]; const urgent = toast.level === "error" || toast.level === "fatal"; return (
{toast.title &&
{toast.title}
}
{toast.message} {toast.repeat > 1 && ( ×{toast.repeat} )}
{toast.action && ( )}
{toast.duration && (
)}
); })}
); }