@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.
240 lines (233 loc) • 8.38 kB
JavaScript
'use client';
import * as React from 'react';
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 "../../utils/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,
children,
...elementProps
} = componentProps;
const store = useToastProviderContext();
const windowFocusTimeout = useTimeout();
const handlingFocusGuardRef = React.useRef(false);
const markedReadyForMouseLeaveRef = 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' && event.target !== viewport) {
event.preventDefault();
store.setPrevFocusElement(activeElement(ownerDocument(viewport)));
viewport?.focus({
preventScroll: true
});
store.pauseTimers();
store.setFocused(true);
}
}
const win = ownerWindow(viewport);
win.addEventListener('keydown', handleGlobalKeyDown);
return () => {
win.removeEventListener('keydown', handleGlobalKeyDown);
};
}, [store, isEmpty]);
React.useEffect(() => {
const viewport = store.state.viewport;
if (!viewport || isEmpty) {
return undefined;
}
const win = ownerWindow(viewport);
function handleWindowBlur(event) {
if (event.target !== win) {
return;
}
store.setIsWindowFocused(false);
store.pauseTimers();
}
function handleWindowFocus(event) {
if (event.relatedTarget || event.target === win) {
return;
}
const target = getTarget(event);
const activeEl = activeElement(ownerDocument(viewport));
if (!contains(viewport, target) || !isFocusVisible(activeEl)) {
store.resumeTimers();
}
// Wait for the `handleFocus` event to fire.
windowFocusTimeout.start(0, () => store.setIsWindowFocused(true));
}
win.addEventListener('blur', handleWindowBlur, true);
win.addEventListener('focus', handleWindowFocus, true);
return () => {
win.removeEventListener('blur', handleWindowBlur, true);
win.removeEventListener('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);
doc.addEventListener('pointerdown', store.handleDocumentPointerDown, true);
return () => {
doc.removeEventListener('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 && event.target === store.state.viewport) {
event.preventDefault();
store.restoreFocusToPrevElement();
store.resumeTimers();
}
}
React.useEffect(() => {
if (!store.state.isWindowFocused || hasTransitioningToasts || !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;
}, [hasTransitioningToasts, store]);
function handleMouseEnter() {
store.pauseTimers();
store.setHovering(true);
markedReadyForMouseLeaveRef.current = false;
}
function handleMouseLeave() {
if (hasTransitioningToasts) {
// When swiping to dismiss, wait until the transitions have settled
// to avoid the viewport collapsing while the user is interacting.
markedReadyForMouseLeaveRef.current = true;
} else {
store.resumeTimers();
store.setHovering(false);
}
}
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(ownerDocument(store.state.viewport).activeElement)) {
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
};
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";