UNPKG

@base-ui-components/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

305 lines (299 loc) 9.97 kB
'use client'; import * as React from 'react'; import { useLatestRef } from '@base-ui-components/utils/useLatestRef'; import { ownerDocument } from '@base-ui-components/utils/owner'; import { useEventCallback } from '@base-ui-components/utils/useEventCallback'; import { generateId } from '@base-ui-components/utils/generateId'; import { Timeout } from '@base-ui-components/utils/useTimeout'; import { activeElement, contains } from "../../floating-ui-react/utils.js"; import { ToastContext } from "./ToastProviderContext.js"; import { isFocusVisible } from "../utils/focusVisible.js"; import { resolvePromiseOptions } from "../utils/resolvePromiseOptions.js"; import { jsx as _jsx } from "react/jsx-runtime"; /** * Provides a context for creating and managing toasts. * * Documentation: [Base UI Toast](https://base-ui.com/react/components/toast) */ export const ToastProvider = function ToastProvider(props) { const { children, timeout = 5000, limit = 3, toastManager } = props; const [toasts, setToasts] = React.useState([]); const [hovering, setHovering] = React.useState(false); const [focused, setFocused] = React.useState(false); const [prevFocusElement, setPrevFocusElement] = React.useState(null); if (toasts.length === 0) { if (hovering) { setHovering(false); } if (focused) { setFocused(false); } } // It's not possible to stack a smaller height toast onto a larger height toast, but // the reverse is possible. For simplicity, we'll enforce the expanded state if the // toasts aren't all the same height. const hasDifferingHeights = React.useMemo(() => { const heights = toasts.map(t => t.height).filter(h => h !== 0); return heights.length > 0 && new Set(heights).size > 1; }, [toasts]); const timersRef = React.useRef(new Map()); const viewportRef = React.useRef(null); const windowFocusedRef = React.useRef(true); const isPausedRef = React.useRef(false); const hoveringRef = useLatestRef(hovering); const focusedRef = useLatestRef(focused); const handleFocusManagement = useEventCallback(toastId => { const activeEl = activeElement(ownerDocument(viewportRef.current)); if (!viewportRef.current || !contains(viewportRef.current, activeEl) || !isFocusVisible(activeEl)) { return; } const currentIndex = toasts.findIndex(toast => toast.id === toastId); let nextToast = null; // Try to find the next toast that isn't animating out let index = currentIndex + 1; while (index < toasts.length) { if (toasts[index].transitionStatus !== 'ending') { nextToast = toasts[index]; break; } index += 1; } // Go backwards if no next toast is found if (!nextToast) { index = currentIndex - 1; while (index >= 0) { if (toasts[index].transitionStatus !== 'ending') { nextToast = toasts[index]; break; } index -= 1; } } if (nextToast) { nextToast.ref?.current?.focus(); } else { prevFocusElement?.focus({ preventScroll: true }); } }); const pauseTimers = useEventCallback(() => { if (isPausedRef.current) { return; } isPausedRef.current = true; timersRef.current.forEach(timer => { if (timer.timeout) { timer.timeout.clear(); const elapsed = Date.now() - timer.start; const remaining = timer.delay - elapsed; timer.remaining = remaining > 0 ? remaining : 0; } }); }); const resumeTimers = useEventCallback(() => { if (!isPausedRef.current) { return; } isPausedRef.current = false; timersRef.current.forEach((timer, id) => { timer.remaining = timer.remaining > 0 ? timer.remaining : timer.delay; timer.timeout ??= Timeout.create(); timer.timeout.start(timer.remaining, () => { timersRef.current.delete(id); timer.callback(); }); timer.start = Date.now(); }); }); const close = useEventCallback(toastId => { setToasts(prevToasts => { const toastsWithEnding = prevToasts.map(toast => toast.id === toastId ? { ...toast, transitionStatus: 'ending', height: 0 } : toast); const activeToasts = toastsWithEnding.filter(t => t.transitionStatus !== 'ending'); return toastsWithEnding.map(toast => { if (toast.transitionStatus === 'ending') { return toast; } const isActiveToastLimited = activeToasts.indexOf(toast) >= limit; return { ...toast, limited: isActiveToastLimited }; }); }); const timer = timersRef.current.get(toastId); if (timer && timer.timeout) { timer.timeout.clear(); timersRef.current.delete(toastId); } const toast = toasts.find(t => t.id === toastId); toast?.onClose?.(); handleFocusManagement(toastId); if (toasts.length === 1) { hoveringRef.current = false; focusedRef.current = false; } }); const remove = useEventCallback(toastId => { setToasts(prev => prev.filter(toast => toast.id !== toastId)); const toast = toasts.find(t => t.id === toastId); toast?.onRemove?.(); }); const scheduleTimer = useEventCallback((id, delay, callback) => { const start = Date.now(); const shouldStartActive = windowFocusedRef.current && !hoveringRef.current && !focusedRef.current; const currentTimeout = shouldStartActive ? Timeout.create() : undefined; currentTimeout?.start(delay, () => { timersRef.current.delete(id); callback(); }); timersRef.current.set(id, { timeout: currentTimeout, start: shouldStartActive ? start : 0, delay, remaining: delay, callback }); }); const add = useEventCallback(toast => { const id = toast.id || generateId('toast'); const toastToAdd = { ...toast, id, transitionStatus: 'starting' }; setToasts(prev => { const updatedToasts = [toastToAdd, ...prev]; const activeToasts = updatedToasts.filter(t => t.transitionStatus !== 'ending'); // Mark oldest toasts for removal when over limit if (activeToasts.length > limit) { const excessCount = activeToasts.length - limit; const oldestActiveToasts = activeToasts.slice(-excessCount); return updatedToasts.map(t => oldestActiveToasts.some(old => old.id === t.id) ? { ...t, limited: true } : { ...t, limited: false }); } return updatedToasts.map(t => ({ ...t, limited: false })); }); const duration = toastToAdd.timeout ?? timeout; if (toastToAdd.type !== 'loading' && duration > 0) { scheduleTimer(id, duration, () => close(id)); } if (hoveringRef.current || focusedRef.current || !windowFocusedRef.current) { pauseTimers(); } return id; }); const update = useEventCallback((id, updates) => { setToasts(prev => prev.map(toast => toast.id === id ? { ...toast, ...updates } : toast)); }); const promise = useEventCallback((promiseValue, options) => { // Create a loading toast (which does not auto-dismiss). const loadingOptions = resolvePromiseOptions(options.loading); const id = add({ ...loadingOptions, type: 'loading' }); const handledPromise = promiseValue.then(result => { const successOptions = resolvePromiseOptions(options.success, result); update(id, { ...successOptions, type: 'success' }); const successTimeout = successOptions.timeout ?? timeout; if (successTimeout > 0) { scheduleTimer(id, successTimeout, () => close(id)); } if (hoveringRef.current || focusedRef.current || !windowFocusedRef.current) { pauseTimers(); } return result; }).catch(error => { const errorOptions = resolvePromiseOptions(options.error, error); update(id, { ...errorOptions, type: 'error' }); const errorTimeout = errorOptions.timeout ?? timeout; if (errorTimeout > 0) { scheduleTimer(id, errorTimeout, () => close(id)); } if (hoveringRef.current || focusedRef.current || !windowFocusedRef.current) { pauseTimers(); } return Promise.reject(error); }); // Private API used exclusively by `Manager` to handoff the promise // back to the manager after it's handled here. if ({}.hasOwnProperty.call(options, 'setPromise')) { options.setPromise(handledPromise); } return handledPromise; }); React.useEffect(function subscribeToToastManager() { if (!toastManager) { return undefined; } const unsubscribe = toastManager[' subscribe'](({ action, options }) => { const id = options.id; if (action === 'promise' && options.promise) { promise(options.promise, options); } else if (action === 'update' && id) { update(id, options); } else if (action === 'close' && id) { close(id); } else { add(options); } }); return unsubscribe; }, [add, update, scheduleTimer, timeout, toastManager, promise, close]); const contextValue = React.useMemo(() => ({ toasts, setToasts, hovering, setHovering, focused, setFocused, add, close, remove, update, promise, pauseTimers, resumeTimers, prevFocusElement, setPrevFocusElement, viewportRef, scheduleTimer, windowFocusedRef, hasDifferingHeights }), [add, close, focused, hovering, pauseTimers, prevFocusElement, promise, remove, resumeTimers, scheduleTimer, toasts, update, hasDifferingHeights]); return /*#__PURE__*/_jsx(ToastContext.Provider, { value: contextValue, children: children }); }; if (process.env.NODE_ENV !== "production") ToastProvider.displayName = "ToastProvider";