'use client'; import type * as React from 'react'; export type ToastKind = 'default' | 'success' | 'info' | 'warning' | 'error' | 'loading'; export type ToastId = string | number; export interface ToastAction { label: React.ReactNode; onClick: (event: React.MouseEvent) => void; } export interface ToastOptions { /** Reuse an id to replace a toast in place — how `toast.promise` updates. */ id?: ToastId; description?: React.ReactNode; /** Milliseconds on screen. `Infinity` pins it until dismissed. */ duration?: number; action?: ToastAction; cancel?: ToastAction; /** Replaces the whole surface. The kit only supplies the positioning. */ render?: (id: ToastId) => React.ReactNode; onDismiss?: (toast: ToastRecord) => void; onAutoClose?: (toast: ToastRecord) => void; className?: string; } export interface ToastRecord extends Omit { id: ToastId; kind: ToastKind; title?: React.ReactNode; /** Set while the exit animation plays, just before removal. */ dismissing?: boolean; } /** How long the exit animation gets before the record is dropped. */ const EXIT_DURATION = 180; let sequence = 0; let toasts: ToastRecord[] = []; const listeners = new Set<() => void>(); /* One shared instance, never written to: `getServerSnapshot` has to return the same reference every call, or React re-renders forever looking for a stable value. Every mutation below replaces `toasts` rather than editing in place. */ const EMPTY: ToastRecord[] = []; const emit = () => { listeners.forEach((listener) => listener()); }; export const toastStore = { subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, getSnapshot: () => toasts, getServerSnapshot: () => EMPTY, /** Adds a toast, or replaces the one already holding this id. */ publish(record: ToastRecord) { const at = toasts.findIndex((existing) => existing.id === record.id); if (at === -1) { toasts = [...toasts, record]; } else { /* Replaced in place rather than appended, so a promise resolving does not make the toast jump to the end of the stack. */ toasts = toasts.map((existing, index) => index === at ? { ...existing, ...record, dismissing: false } : existing ); } emit(); return record.id; }, /** Starts the exit animation; the record is dropped once it has played. */ dismiss(id?: ToastId) { const targets = id === undefined ? toasts : toasts.filter((entry) => entry.id === id); if (targets.length === 0) return; for (const target of targets) target.onDismiss?.(target); const ids = new Set(targets.map((target) => target.id)); toasts = toasts.map((entry) => ids.has(entry.id) ? { ...entry, dismissing: true } : entry ); emit(); setTimeout(() => { toasts = toasts.filter((entry) => !ids.has(entry.id)); emit(); }, EXIT_DURATION); }, /** Removes without the exit animation — used when a toast times out mid-flight. */ remove(id: ToastId) { toasts = toasts.filter((entry) => entry.id !== id); emit(); }, }; const publish = (kind: ToastKind, title: React.ReactNode, options: ToastOptions = {}) => { const { id, ...rest } = options; sequence += 1; return toastStore.publish({ id: id ?? sequence, kind, title, ...rest }); }; type PromiseMessage = React.ReactNode | ((value: T) => React.ReactNode); const resolveMessage = (message: PromiseMessage, value: T): React.ReactNode => typeof message === 'function' ? (message as (value: T) => React.ReactNode)(value) : message; /** * Shows a toast. Call it from anywhere — the store is a module singleton, so no * provider or hook is involved. * * ```ts * toast('Draft saved'); * toast.success('Deployment finished', { description: 'Live in 3 regions.' }); * toast.promise(publish(), { loading: 'Publishing…', success: 'Published' }); * ``` */ export const toast = Object.assign( (title: React.ReactNode, options?: ToastOptions) => publish('default', title, options), { message: (title: React.ReactNode, options?: ToastOptions) => publish('default', title, options), success: (title: React.ReactNode, options?: ToastOptions) => publish('success', title, options), info: (title: React.ReactNode, options?: ToastOptions) => publish('info', title, options), warning: (title: React.ReactNode, options?: ToastOptions) => publish('warning', title, options), error: (title: React.ReactNode, options?: ToastOptions) => publish('error', title, options), /** Pinned until something dismisses or replaces it. */ loading: (title: React.ReactNode, options?: ToastOptions) => publish('loading', title, { duration: Infinity, ...options }), /** Full control of the surface; only placement and dismissal stay ours. */ custom: (render: (id: ToastId) => React.ReactNode, options?: ToastOptions) => publish('default', undefined, { ...options, render }), /** * Tracks a promise through one toast: pinned while pending, then replaced in * place with the success or error message. */ promise( promise: Promise, messages: { loading: React.ReactNode; success?: PromiseMessage; error?: PromiseMessage; }, options?: ToastOptions ) { const id = publish('loading', messages.loading, { duration: Infinity, ...options }); promise.then( (value) => { if (messages.success === undefined) toastStore.dismiss(id); else publish('success', resolveMessage(messages.success, value), { ...options, id }); }, (reason: unknown) => { if (messages.error === undefined) toastStore.dismiss(id); else publish('error', resolveMessage(messages.error, reason), { ...options, id }); } ); return promise; }, /** Dismisses one toast, or all of them when called with no id. */ dismiss: (id?: ToastId) => toastStore.dismiss(id), } ); export { EXIT_DURATION };