import styled from 'styled-components'; import React, { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import type { ReactElement } from 'react'; import type { ToastContextValue, ToastItem, ToastOptions, ToastProviderProps, ToastType, } from '../../../core/types/toast'; import { Portal } from '@redocly/theme/components/Portal/Portal'; import { Toast } from '@redocly/theme/components/Toast/Toast'; import { ToastContext } from '../../../core/contexts/Toast/ToastContext'; import { TOAST_SLIDE_DURATION_MS } from '../../../core/constants/toast'; type ToastState = ToastItem[]; type ToastReducerAction = | { type: 'add'; payload: ToastItem; } | { type: 'startExit'; payload: { id: string; }; } | { type: 'remove'; payload: { id: string; }; } | { type: 'update'; payload: { id: string; updates: Partial; }; }; function toastReducer(state: ToastState, action: ToastReducerAction): ToastState { switch (action.type) { case 'add': return [action.payload, ...state]; case 'startExit': return state.map((toast) => toast.id === action.payload.id ? { ...toast, isExiting: true } : toast, ); case 'remove': return state.filter((toast) => toast.id !== action.payload.id); case 'update': return state.map((toast) => toast.id === action.payload.id ? { ...toast, ...action.payload.updates, type: action.payload.updates.type ?? toast.type, } : toast, ); default: return state; } } function getToastType(type?: ToastType): ToastType { return type ?? 'info'; } function createToastId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } export function ToastProvider({ children, mountId }: ToastProviderProps): ReactElement { const [toasts, dispatch] = useReducer(toastReducer, []); const toastsRef = useRef([]); const removeTimeoutsRef = useRef>>(new Map()); useEffect(() => { toastsRef.current = toasts; }, [toasts]); useEffect(() => { const timeoutsMap = removeTimeoutsRef.current; return () => { timeoutsMap.forEach((timeoutId) => { clearTimeout(timeoutId); }); timeoutsMap.clear(); }; }, []); const dismissToast = useCallback((id: string): void => { const currentToast = toastsRef.current.find((toast) => toast.id === id); if (!currentToast || currentToast.isExiting) { return; } currentToast.onClose?.(); dispatch({ type: 'startExit', payload: { id } }); const existingTimeout = removeTimeoutsRef.current.get(id); if (existingTimeout) { clearTimeout(existingTimeout); } const timeoutId = setTimeout(() => { dispatch({ type: 'remove', payload: { id } }); removeTimeoutsRef.current.delete(id); }, TOAST_SLIDE_DURATION_MS); removeTimeoutsRef.current.set(id, timeoutId); }, []); const showToast = useCallback((options: ToastOptions): string => { const id = createToastId(); dispatch({ type: 'add', payload: { ...options, id, type: getToastType(options.type), isExiting: false, }, }); return id; }, []); const updateToast = useCallback((id: string, updates: Partial): void => { dispatch({ type: 'update', payload: { id, updates, }, }); }, []); const contextValue = useMemo( () => ({ showToast, dismissToast, updateToast, }), [dismissToast, showToast, updateToast], ); return ( {children} {toasts.length > 0 ? ( {toasts.map((toast, index) => ( ))} ) : null} ); } const ToastViewport = styled.div` position: fixed; right: var(--spacing-md); bottom: var(--spacing-md); z-index: 1100; display: flex; flex-direction: column-reverse; gap: var(--spacing-sm); width: 320px; min-width: 240px; max-width: 360px; pointer-events: none; @media (max-width: 480px) { left: 50%; right: auto; transform: translateX(-50%); width: calc(100vw - var(--spacing-md) * 2); min-width: 0; max-width: none; } `;