"use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; import { createPortal } from "react-dom"; import { CircleAlert, CircleCheck, Info, LoaderCircle, TriangleAlert, X } from "lucide-react"; import { Button } from "./button"; import { cn } from "./lib/utils"; export type ToastVariant = "default" | "accent" | "success" | "error" | "warning" | "info" | "loading"; export type ToasterPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; export interface ToastAction { label: ReactNode; onClick?: (event: ReactMouseEvent) => void; } export interface ToastOptions { id?: string | number; description?: ReactNode; action?: ToastAction; variant?: ToastVariant; /** Hide the variant icon while preserving the selected Toast appearance. */ showIcon?: boolean; duration?: number; /** Accessible name for this toast's close button; overrides the Toaster `closeLabel`. */ closeLabel?: string; /** Show or hide this toast's close button; defaults to the Toaster `closeButton`. */ dismissible?: boolean; /** Called with the toast id after its close button or action removes it. */ onDismiss?: (id: string | number) => void; /** Called with the toast id after it expires on its own. */ onAutoClose?: (id: string | number) => void; } interface ToastRecord extends ToastOptions { id: string | number; title: ReactNode; variant: ToastVariant; } const DEFAULT_CLOSE_LABEL = "Dismiss notification"; const ANNOUNCEMENT_CLEAR_MS = 5000; const MAX_ANNOUNCEMENTS = 5; const defaultAnnouncementLabel = (message: string) => `Notification: ${message}`; let records: ToastRecord[] = []; let counter = 0; const listeners = new Set<() => void>(); const emit = () => listeners.forEach((listener) => listener()); function createToast(title: ReactNode, options: ToastOptions = {}, variant: ToastVariant = "default") { const resolvedVariant = options.variant ?? variant; const record: ToastRecord = { ...options, id: options.id ?? `ci-toast-${++counter}`, title, variant: resolvedVariant, duration: options.duration ?? (resolvedVariant === "loading" ? Infinity : undefined), }; records = records.some((item) => item.id === record.id) ? records.map((item) => item.id === record.id ? record : item) : [record, ...records]; emit(); return record.id; } export function dismissToast(id?: string | number) { records = id === undefined ? [] : records.filter((item) => item.id !== id); emit(); } type ToastFunction = (title: ReactNode, options?: ToastOptions) => string | number; export const toast: ToastFunction & { success: ToastFunction; error: ToastFunction; warning: ToastFunction; info: ToastFunction; loading: ToastFunction; dismiss: (id?: string | number) => void; } = Object.assign( (title: ReactNode, options?: ToastOptions) => createToast(title, options), { success: (title: ReactNode, options?: ToastOptions) => createToast(title, options, "success"), error: (title: ReactNode, options?: ToastOptions) => createToast(title, options, "error"), warning: (title: ReactNode, options?: ToastOptions) => createToast(title, options, "warning"), info: (title: ReactNode, options?: ToastOptions) => createToast(title, options, "info"), loading: (title: ReactNode, options?: ToastOptions) => createToast(title, options, "loading"), dismiss: dismissToast, }, ); export interface ToasterProps { position?: ToasterPosition; duration?: number; closeButton?: boolean; /** Accessible name for every close button. Defaults to "Dismiss notification". */ closeLabel?: string; /** Text screen readers hear for each toast. Defaults to `Notification: ${message}`. */ announcementLabel?: (message: string, variant: ToastVariant) => string; } export interface ToastProps extends Omit { title: ReactNode; closeButton?: boolean; onDismiss: () => void; } /** The same toast surface as Toaster, with placement and visibility owned by its parent. */ export function Toast({ title, description, action, variant = "default", showIcon = true, duration = 5000, closeButton = true, closeLabel = DEFAULT_CLOSE_LABEL, onDismiss }: ToastProps) { const id = useId(); const item = useMemo( () => ({ id, title, description, action, variant, showIcon, duration }), [id, title, description, action, variant, showIcon, duration], ); return ; } type AnnouncementPoliteness = "polite" | "assertive"; interface ToastAnnouncement { key: number; message: string; variant: ToastVariant } type ToastAnnouncements = Record; const NO_ANNOUNCEMENTS: ToastAnnouncements = { polite: [], assertive: [] }; export function Toaster({ position = "bottom-right", duration = 5000, closeButton = true, closeLabel = DEFAULT_CLOSE_LABEL, announcementLabel = defaultAnnouncementLabel }: ToasterProps) { const [mounted, setMounted] = useState(false); const [items, setItems] = useState(records); const [announcements, setAnnouncements] = useState(NO_ANNOUNCEMENTS); const announcementKey = useRef(0); const announced = useRef(new WeakSet()); const pending = useRef>([]); useEffect(() => setMounted(true), []); useEffect(() => { const update = () => setItems(records); listeners.add(update); update(); return () => { listeners.delete(update); }; }, []); const announce = useCallback((item: ToastRecord, message: string) => { if (!message || announced.current.has(item)) return; announced.current.add(item); const politeness: AnnouncementPoliteness = item.variant === "error" ? "assertive" : "polite"; pending.current.push({ politeness, announcement: { key: ++announcementKey.current, message, variant: item.variant } }); }, []); // Runs after the ToastItem effects of the same commit, so toasts raised together are queued as one batch. useEffect(() => { if (pending.current.length === 0) return; // Newest toasts render (and announce) first; restore the order in which they were raised. const batch = pending.current.reverse(); pending.current = []; setAnnouncements((current) => { const next: ToastAnnouncements = { polite: [...current.polite], assertive: [...current.assertive] }; for (const { politeness, announcement } of batch) next[politeness].push(announcement); return { polite: next.polite.slice(-MAX_ANNOUNCEMENTS), assertive: next.assertive.slice(-MAX_ANNOUNCEMENTS) }; }); }); useEffect(() => { if (announcements.polite.length === 0 && announcements.assertive.length === 0) return; const timer = window.setTimeout(() => setAnnouncements(NO_ANNOUNCEMENTS), ANNOUNCEMENT_CLEAR_MS); return () => window.clearTimeout(timer); }, [announcements]); if (!mounted) return null; return createPortal(
{(["polite", "assertive"] as const).map((politeness) => ( // Each announcement is its own keyed addition, so toasts raised together are all read.
{announcements[politeness].map((announcement) => ( {announcementLabel(announcement.message, announcement.variant)} ))}
))} {items.map((item) => )}
, document.body, ); } function ToastIcon({ variant }: { variant: ToastVariant }) { if (variant === "success") return