@base-ui/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.
250 lines (243 loc) • 8.85 kB
JavaScript
'use client';
import * as React from 'react';
import { addEventListener } from '@base-ui/utils/addEventListener';
import { mergeCleanups } from '@base-ui/utils/mergeCleanups';
import { ownerDocument, ownerWindow } from '@base-ui/utils/owner';
import { visuallyHidden } from '@base-ui/utils/visuallyHidden';
import { useTimeout } from '@base-ui/utils/useTimeout';
import { activeElement, contains, getTarget } from "../../floating-ui-react/utils.js";
import { FocusGuard } from "../../utils/FocusGuard.js";
import { useToastProviderContext } from "../provider/ToastProviderContext.js";
import { useRenderElement } from "../../internals/useRenderElement.js";
import { isFocusVisible } from "../utils/focusVisible.js";
import { ToastViewportCssVars } from "./ToastViewportCssVars.js";
/**
* A container viewport for toasts.
* Renders a `<div>` element.
*
* Documentation: [Base UI Toast](https://base-ui.com/react/components/toast)
*/
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export const ToastViewport = /*#__PURE__*/React.forwardRef(function ToastViewport(componentProps, forwardedRef) {
const {
render,
className,
style,
children,
...elementProps
} = componentProps;
const store = useToastProviderContext();
const windowFocusTimeout = useTimeout();
const handlingFocusGuardRef = React.useRef(false);
const markedReadyForMouseLeaveRef = React.useRef(false);
const touchActiveRef = React.useRef(false);
const isEmpty = store.useState('isEmpty');
const toasts = store.useState('toasts');
const focused = store.useState('focused');
const expanded = store.useState('expanded');
const prevFocusElement = store.useState('prevFocusElement');
const frontmostHeight = toasts[0]?.height ?? 0;
const hasTransitioningToasts = React.useMemo(() => toasts.some(toast => toast.transitionStatus === 'ending'), [toasts]);
// Listen globally for F6 so we can force-focus the viewport.
React.useEffect(() => {
const viewport = store.state.viewport;
if (!viewport) {
return undefined;
}
function handleGlobalKeyDown(event) {
if (isEmpty) {
return;
}
if (event.key === 'F6' && getTarget(event) !== viewport) {
event.preventDefault();
store.setPrevFocusElement(activeElement(ownerDocument(viewport)));
viewport?.focus({
preventScroll: true
});
store.pauseTimers();
store.setFocused(true);
}
}
const win = ownerWindow(viewport);
return addEventListener(win, 'keydown', handleGlobalKeyDown);
}, [store, isEmpty]);
React.useEffect(() => {
const viewport = store.state.viewport;
if (!viewport || isEmpty) {
return undefined;
}
const win = ownerWindow(viewport);
function handleWindowBlur(event) {
if (getTarget(event) !== win) {
return;
}
store.setIsWindowFocused(false);
store.pauseTimers();
}
function handleWindowFocus(event) {
if (event.relatedTarget) {
return;
}
const target = getTarget(event);
const activeEl = activeElement(ownerDocument(viewport));
if (target === win || !contains(viewport, target) || !isFocusVisible(activeEl)) {
store.resumeTimers();
}
// Wait for the `handleFocus` event to fire.
windowFocusTimeout.start(0, () => store.setIsWindowFocused(true));
}
return mergeCleanups(addEventListener(win, 'blur', handleWindowBlur, true), addEventListener(win, 'focus', handleWindowFocus, true));
}, [store, windowFocusTimeout,
// `store.state.viewport` isn't available on the first render,
// since the portal node hasn't yet been created.
// By adding this dependency, we ensure the window listeners
// are added when toasts have been created, once the ref is available.
isEmpty]);
React.useEffect(() => {
const viewport = store.state.viewport;
if (!viewport || isEmpty) {
return undefined;
}
const doc = ownerDocument(viewport);
return addEventListener(doc, 'pointerdown', store.handleDocumentPointerDown, true);
}, [isEmpty, store]);
function handleFocusGuard(event) {
const viewport = store.state.viewport;
if (!viewport) {
return;
}
handlingFocusGuardRef.current = true;
// If we're coming off the container, move to the first toast
if (event.relatedTarget === viewport) {
toasts[0]?.ref?.current?.focus();
} else {
store.restoreFocusToPrevElement();
}
}
function handleKeyDown(event) {
if (event.key === 'Tab' && event.shiftKey && getTarget(event.nativeEvent) === store.state.viewport) {
event.preventDefault();
store.restoreFocusToPrevElement();
store.resumeTimers();
}
}
function flushMouseLeave() {
const hasEndingToasts = store.state.toasts.some(toast => toast.transitionStatus === 'ending');
if (!store.state.isWindowFocused || hasEndingToasts || touchActiveRef.current || !markedReadyForMouseLeaveRef.current) {
return;
}
// Once transitions have finished, see if a mouseleave was already triggered
// but blocked from taking effect. If so, we can now safely resume timers and
// collapse the viewport.
store.resumeTimers();
store.setHovering(false);
markedReadyForMouseLeaveRef.current = false;
}
React.useEffect(flushMouseLeave, [hasTransitioningToasts, store]);
function handleMouseEnter() {
store.pauseTimers();
store.setHovering(true);
markedReadyForMouseLeaveRef.current = false;
}
function handleMouseLeave() {
if (hasTransitioningToasts || touchActiveRef.current) {
// When swiping to dismiss, wait until the transitions have settled
// or the touch interaction ends to avoid collapsing mid-gesture.
markedReadyForMouseLeaveRef.current = true;
} else {
store.resumeTimers();
store.setHovering(false);
}
}
function handlePointerDown(event) {
if (event.pointerType === 'touch') {
touchActiveRef.current = true;
}
}
function handlePointerEnd(event) {
if (event.pointerType !== 'touch') {
return;
}
touchActiveRef.current = false;
flushMouseLeave();
}
function handleFocus() {
if (handlingFocusGuardRef.current) {
handlingFocusGuardRef.current = false;
return;
}
if (focused) {
return;
}
// Only set focused when the active element is focus-visible.
// This prevents the viewport from staying expanded when clicking inside without
// keyboard navigation.
if (isFocusVisible(activeElement(ownerDocument(store.state.viewport)))) {
store.setFocused(true);
store.pauseTimers();
}
}
function handleBlur(event) {
if (!focused || contains(store.state.viewport, event.relatedTarget)) {
return;
}
store.setFocused(false);
store.resumeTimers();
}
const defaultProps = {
tabIndex: -1,
role: 'region',
'aria-live': 'polite',
'aria-atomic': false,
'aria-relevant': 'additions text',
'aria-label': 'Notifications',
onMouseEnter: handleMouseEnter,
onMouseMove: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
onClick: handleFocus,
onPointerDown: handlePointerDown,
onPointerUp: handlePointerEnd,
onPointerCancel: handlePointerEnd
};
const state = {
expanded
};
const element = useRenderElement('div', componentProps, {
ref: [forwardedRef, store.setViewport],
state,
props: [defaultProps, {
style: {
[ToastViewportCssVars.frontmostHeight]: frontmostHeight ? `${frontmostHeight}px` : undefined
}
}, elementProps, {
children: /*#__PURE__*/_jsxs(React.Fragment, {
children: [!isEmpty && prevFocusElement && /*#__PURE__*/_jsx(FocusGuard, {
onFocus: handleFocusGuard
}), children, !isEmpty && prevFocusElement && /*#__PURE__*/_jsx(FocusGuard, {
onFocus: handleFocusGuard
})]
})
}]
});
const highPriorityToasts = React.useMemo(() => toasts.filter(toast => toast.priority === 'high'), [toasts]);
return /*#__PURE__*/_jsxs(React.Fragment, {
children: [!isEmpty && prevFocusElement && /*#__PURE__*/_jsx(FocusGuard, {
onFocus: handleFocusGuard
}), element, !focused && highPriorityToasts.length > 0 && /*#__PURE__*/_jsx("div", {
style: visuallyHidden,
children: highPriorityToasts.map(toast => /*#__PURE__*/_jsxs("div", {
role: "alert",
"aria-atomic": true,
children: [/*#__PURE__*/_jsx("div", {
children: toast.title
}), /*#__PURE__*/_jsx("div", {
children: toast.description
})]
}, toast.id))
})]
});
});
if (process.env.NODE_ENV !== "production") ToastViewport.displayName = "ToastViewport";