@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
JavaScript
'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";