"use client"; /** * @fileoverview Base UI-backed toast provider and imperative toast bridge. * * Replaces the previous `sonner` runtime dependency while preserving the existing public exports: * `Toaster` for container rendering and `toast` for imperative notifications used throughout the * website and component consumers. */ import {Toast} from "@base-ui/react/toast"; import {BellRing, CircleAlert, CircleCheck, Info, LoaderCircle, TriangleAlert, X} from "lucide-react"; import * as React from "react"; import {cn} from "@/lib/utilities"; import styles from "./toast.module.css"; const DEFAULT_TOAST_DURATION = 5000; const DEFAULT_TOAST_LIMIT = 3; const DEFAULT_TOAST_CLOSE_LABEL = "Close notification"; const DEFAULT_VIEWPORT_ARIA_LABEL = "Notifications"; type ToastPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right" | "top-center" | "bottom-center"; type ToastVariant = "default" | "success" | "error" | "info" | "warning" | "loading"; type ToastIdentifier = number | string; type ToastRenderable = React.ReactNode | (() => React.ReactNode); type ToastPromise = Promise | (() => Promise); type ResolvedPromiseState = ToastRenderable | ToastPromiseResolvedOptions | undefined; interface ToastAction { label: React.ReactNode; onClick: (event: React.MouseEvent) => void; } type ToastActionRenderable = React.ReactNode | ToastAction; interface ToastOptions { id?: ToastIdentifier; description?: ToastRenderable; duration?: number; className?: string; descriptionClassName?: string; closeButton?: boolean; closeButtonAriaLabel?: string; style?: React.CSSProperties; priority?: "high" | "low"; action?: ToastActionRenderable; cancel?: ToastActionRenderable; onDismiss?: (toast: ToastSnapshot) => void; onAutoClose?: (toast: ToastSnapshot) => void; } interface ToastPromiseResolvedOptions extends ToastOptions { message: ToastRenderable; } type ToastPromiseState = | ToastRenderable | ToastPromiseResolvedOptions | ((value: Value) => ToastRenderable | ToastPromiseResolvedOptions | Promise); interface ToastPromiseOptions extends ToastOptions { loading?: ToastRenderable | ToastPromiseResolvedOptions; success?: ToastPromiseState; error?: ToastPromiseState; finally?: () => void | Promise; } interface ToasterProps { /** * Screen position used for the toast viewport. * @default "bottom-right" */ position?: ToastPosition; /** * Default auto-dismiss duration, in milliseconds, for each toast. * @default 5000 */ duration?: number; /** * Maximum number of simultaneously visible toasts. * @default 3 */ visibleToasts?: number; /** * Whether to render a close button for each toast by default. * @default true */ closeButton?: boolean; /** * Accessible label announced for the toast viewport container. * @default "Notifications" */ containerAriaLabel?: string; /** * Additional CSS classes merged with the toast viewport. * @default undefined */ className?: string; /** * Inline styles applied to the toast viewport. * @default undefined */ style?: React.CSSProperties; /** * Default options merged into each toast created while this toaster is mounted. * @default undefined */ toastOptions?: ToastOptions; } interface ToastSnapshot { id: string; variant: ToastVariant; title?: React.ReactNode; description?: React.ReactNode; } interface ToastMetadata { variant: ToastVariant; className?: string; descriptionClassName?: string; closeButton: boolean; closeButtonAriaLabel: string; style?: React.CSSProperties; action?: ToastActionRenderable; cancel?: ToastActionRenderable; customContent?: React.ReactNode; } interface ToastUpdateOptions extends ToastOptions { message?: ToastRenderable; variant?: ToastVariant; } interface ToasterRegistration { closeButton: boolean; toastOptions: ToastOptions; } interface ToastRecord { metadata: ToastMetadata; options: ToastOptions; snapshot: ToastSnapshot; } interface ToastApi { (message: ToastRenderable, options?: ToastOptions): string; success: (message: ToastRenderable, options?: ToastOptions) => string; error: (message: ToastRenderable, options?: ToastOptions) => string; info: (message: ToastRenderable, options?: ToastOptions) => string; warning: (message: ToastRenderable, options?: ToastOptions) => string; loading: (message: ToastRenderable, options?: ToastOptions) => string; message: (message: ToastRenderable, options?: ToastOptions) => string; update: (toastId: ToastIdentifier, options: ToastUpdateOptions) => string; dismiss: (toastId?: ToastIdentifier) => string | undefined; promise: (promise: ToastPromise, options?: ToastPromiseOptions) => Promise; custom: (renderer: (toastId: string) => React.ReactElement, options?: ToastOptions) => string; getToasts: () => ReadonlyArray; getHistory: () => ReadonlyArray; } const positionStyles: Record = { "bottom-center": styles.viewportBottomCenter, "bottom-left": styles.viewportBottomLeft, "bottom-right": styles.viewportBottomRight, "top-center": styles.viewportTopCenter, "top-left": styles.viewportTopLeft, "top-right": styles.viewportTopRight, }; const variantStyles: Record = { default: styles.default, error: styles.error, info: styles.info, loading: styles.loading, success: styles.success, warning: styles.warning, }; const toastManager = Toast.createToastManager(); type ToastAddOptions = Parameters[0]; type ToastUpdatePayload = Parameters[1]; const toastHistory: ToastSnapshot[] = []; const activeToasts = new Map(); const toastRecords = new Map(); const toasterRegistrations = new Map(); let toastSequence = 0; function createToastIdentifier(identifier?: ToastIdentifier): string { if (identifier !== undefined) { return String(identifier); } if (typeof globalThis.crypto?.randomUUID === "function") { return globalThis.crypto.randomUUID(); } toastSequence += 1; return `toast-${String(toastSequence)}`; } function isRenderableFactory(value: ToastRenderable | undefined): value is () => React.ReactNode { return typeof value === "function"; } // eslint-disable-next-line sonarjs/function-return-type -- React renderables intentionally normalize to one public node type. function resolveRenderable(value?: ToastRenderable): React.ReactNode | undefined { const resolvedValue = isRenderableFactory(value) ? value() : value; return resolvedValue; } function getSnapshot(id: string, variant: ToastVariant, title?: React.ReactNode, description?: React.ReactNode): ToastSnapshot { return { description, id, title, variant, }; } function registerToast(snapshot: ToastSnapshot): void { activeToasts.set(snapshot.id, snapshot); toastHistory.push(snapshot); } function registerToastRecord(record: ToastRecord): void { toastRecords.set(record.snapshot.id, record); registerToast(record.snapshot); } function replaceToastHistorySnapshot(snapshot: ToastSnapshot): void { for (let index = toastHistory.length - 1; index >= 0; index -= 1) { if (toastHistory[index]?.id === snapshot.id) { toastHistory[index] = snapshot; return; } } toastHistory.push(snapshot); } function replaceToastRecord(record: ToastRecord): void { toastRecords.set(record.snapshot.id, record); activeToasts.set(record.snapshot.id, record.snapshot); replaceToastHistorySnapshot(record.snapshot); } function unregisterToast(toastId?: string): void { if (!toastId) { activeToasts.clear(); toastRecords.clear(); return; } activeToasts.delete(toastId); toastRecords.delete(toastId); } function getActiveToasterRegistration(): ToasterRegistration { const registrations = [...toasterRegistrations.values()]; return registrations.at(-1) ?? {closeButton: true, toastOptions: {}}; } function mergeToastOptions(options?: ToastOptions): ToastOptions { return { ...getActiveToasterRegistration().toastOptions, ...options, }; } function isToastVariant(value: string | undefined): value is ToastVariant { return value === "default" || value === "error" || value === "info" || value === "loading" || value === "success" || value === "warning"; } function createToastMetadata( variant: ToastVariant, options: ToastOptions, closeButton: boolean, customContent?: React.ReactNode, ): ToastMetadata { return { action: options.action, cancel: options.cancel, className: options.className, closeButton, closeButtonAriaLabel: options.closeButtonAriaLabel ?? DEFAULT_TOAST_CLOSE_LABEL, customContent, descriptionClassName: options.descriptionClassName, style: options.style, variant, }; } function createToastRecord({ id, message, options, variant, customContent, }: Readonly<{ customContent?: React.ReactNode; id: string; message?: ToastRenderable; options: ToastOptions; variant: ToastVariant; }>): ToastRecord { const activeRegistration = getActiveToasterRegistration(); const closeButton = options.closeButton ?? activeRegistration.closeButton; const title = message === undefined ? undefined : resolveRenderable(message); const description = resolveRenderable(options.description); const snapshot = getSnapshot(id, variant, title, description); return { metadata: createToastMetadata(variant, options, closeButton, customContent), options, snapshot, }; } function createToastLifecycleHandlers(toastId: string, record: ToastRecord): Pick { return { onClose: () => { record.options.onDismiss?.(activeToasts.get(toastId) ?? record.snapshot); }, onRemove: () => { const snapshot = activeToasts.get(toastId) ?? record.snapshot; unregisterToast(toastId); record.options.onAutoClose?.(snapshot); }, }; } function createToastAddPayload(record: ToastRecord): ToastAddOptions { const lifecycleHandlers = createToastLifecycleHandlers(record.snapshot.id, record); return { description: record.snapshot.description, id: record.snapshot.id, priority: record.options.priority, timeout: record.options.duration, title: record.snapshot.title, type: record.snapshot.variant, data: record.metadata, ...lifecycleHandlers, }; } function buildToastOptions(message: ToastRenderable, variant: ToastVariant, options?: ToastOptions): ToastAddOptions { const mergedOptions = mergeToastOptions(options); const toastId = createToastIdentifier(mergedOptions.id); const record = createToastRecord({ id: toastId, message, options: mergedOptions, variant, }); registerToastRecord(record); return createToastAddPayload(record); } function showToast(message: ToastRenderable, variant: ToastVariant, options?: ToastOptions): string { const payload = buildToastOptions(message, variant, options); return toastManager.add(payload); } function dismissToast(toastId?: ToastIdentifier): string | undefined { if (toastId === undefined) { unregisterToast(); toastManager.close(); return undefined; } const normalizedToastId = String(toastId); unregisterToast(normalizedToastId); toastManager.close(normalizedToastId); return normalizedToastId; } function updateToast(toastId: ToastIdentifier, options: ToastUpdateOptions): string { const normalizedToastId = String(toastId); const existingRecord = toastRecords.get(normalizedToastId); const mergedOptions: ToastOptions = { ...existingRecord?.options, ...options, }; const variant = options.variant ?? existingRecord?.snapshot.variant ?? "default"; const nextMessage = options.message === undefined ? existingRecord?.snapshot.title : resolveRenderable(options.message); const nextDescription = options.description === undefined ? existingRecord?.snapshot.description : resolveRenderable(options.description); const metadata = createToastMetadata( variant, mergedOptions, mergedOptions.closeButton ?? existingRecord?.metadata.closeButton ?? getActiveToasterRegistration().closeButton, existingRecord?.metadata.customContent, ); const record: ToastRecord = { metadata, options: mergedOptions, snapshot: getSnapshot(normalizedToastId, variant, nextMessage, nextDescription), }; replaceToastRecord(record); const payload: ToastUpdatePayload = { description: record.snapshot.description, priority: record.options.priority, timeout: record.options.duration, title: record.snapshot.title, type: record.snapshot.variant, data: record.metadata, ...createToastLifecycleHandlers(normalizedToastId, record), }; toastManager.update(normalizedToastId, payload); return normalizedToastId; } function resolvePromiseState(state: ToastPromiseState | ResolvedPromiseState, value: Value): Promise { if (typeof state === "function") { return Promise.resolve(state(value)); } return Promise.resolve(state); } function isPromiseResolvedOptions(value: ToastRenderable | ToastPromiseResolvedOptions | undefined): value is ToastPromiseResolvedOptions { return typeof value === "object" && value !== null && "message" in value; } function isToastAction(value: ToastActionRenderable | undefined): value is ToastAction { if (React.isValidElement(value)) { return false; } return typeof value === "object" && value !== null && "label" in value && "onClick" in value; } // eslint-disable-next-line sonarjs/function-return-type -- Variant selection intentionally maps to React nodes for a single icon slot. function getVariantIcon(variant: ToastVariant): React.ReactNode { const icons: Record = { default: (