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.

193 lines (187 loc) 7.51 kB
import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { isNode } from '@floating-ui/utils/dom'; import { useId } from '@base-ui-components/utils/useId'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { FocusGuard } from "../../utils/FocusGuard.js"; import { enableFocusInside, disableFocusInside, getPreviousTabbable, getNextTabbable, isOutsideEvent } from "../utils.js"; import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js"; import { REASONS } from "../../utils/reasons.js"; import { createAttribute } from "../utils/createAttribute.js"; import { useRenderElement } from "../../utils/useRenderElement.js"; import { EMPTY_OBJECT, ownerVisuallyHidden } from "../../utils/constants.js"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const PortalContext = /*#__PURE__*/React.createContext(null); if (process.env.NODE_ENV !== "production") PortalContext.displayName = "PortalContext"; export const usePortalContext = () => React.useContext(PortalContext); const attr = createAttribute('portal'); export function useFloatingPortalNode(props = {}) { const { ref, container: containerProp, componentProps = EMPTY_OBJECT, elementProps, elementState } = props; const uniqueId = useId(); const portalContext = usePortalContext(); const parentPortalNode = portalContext?.portalNode; const [containerElement, setContainerElement] = React.useState(null); const [portalNode, setPortalNode] = React.useState(null); const containerRef = React.useRef(null); useIsoLayoutEffect(() => { // Wait for the container to be resolved if explicitly `null`. if (containerProp === null) { if (containerRef.current) { containerRef.current = null; setPortalNode(null); setContainerElement(null); } return; } // React 17 does not use React.useId(). if (uniqueId == null) { return; } const resolvedContainer = (containerProp && (isNode(containerProp) ? containerProp : containerProp.current)) ?? parentPortalNode ?? document.body; if (resolvedContainer == null) { if (containerRef.current) { containerRef.current = null; setPortalNode(null); setContainerElement(null); } return; } if (containerRef.current !== resolvedContainer) { containerRef.current = resolvedContainer; setPortalNode(null); setContainerElement(resolvedContainer); } }, [containerProp, parentPortalNode, uniqueId]); const portalElement = useRenderElement('div', componentProps, { ref: [ref, setPortalNode], state: elementState, props: [{ id: uniqueId, [attr]: '' }, elementProps] }); // This `createPortal` call injects `portalElement` into the `container`. // Another call inside `FloatingPortal`/`FloatingPortalLite` then injects the children into `portalElement`. const portalSubtree = containerElement && portalElement ? /*#__PURE__*/ReactDOM.createPortal(portalElement, containerElement) : null; return { portalNode, portalSubtree }; } /** * Portals the floating element into a given container element — by default, * outside of the app root and into the body. * This is necessary to ensure the floating element can appear outside any * potential parent containers that cause clipping (such as `overflow: hidden`), * while retaining its location in the React tree. * @see https://floating-ui.com/docs/FloatingPortal * @internal */ export const FloatingPortal = /*#__PURE__*/React.forwardRef(function FloatingPortal(componentProps, forwardedRef) { const { children, container, className, render, renderGuards, ...elementProps } = componentProps; const { portalNode, portalSubtree } = useFloatingPortalNode({ container, ref: forwardedRef, componentProps, elementProps }); const beforeOutsideRef = React.useRef(null); const afterOutsideRef = React.useRef(null); const beforeInsideRef = React.useRef(null); const afterInsideRef = React.useRef(null); const [focusManagerState, setFocusManagerState] = React.useState(null); const modal = focusManagerState?.modal; const open = focusManagerState?.open; const shouldRenderGuards = typeof renderGuards === 'boolean' ? renderGuards : !!focusManagerState && !focusManagerState.modal && focusManagerState.open && !!portalNode; // https://codesandbox.io/s/tabbable-portal-f4tng?file=/src/TabbablePortal.tsx React.useEffect(() => { if (!portalNode || modal) { return undefined; } // Make sure elements inside the portal element are tabbable only when the // portal has already been focused, either by tabbing into a focus trap // element outside or using the mouse. function onFocus(event) { if (portalNode && isOutsideEvent(event)) { const focusing = event.type === 'focusin'; const manageFocus = focusing ? enableFocusInside : disableFocusInside; manageFocus(portalNode); } } // Listen to the event on the capture phase so they run before the focus // trap elements onFocus prop is called. portalNode.addEventListener('focusin', onFocus, true); portalNode.addEventListener('focusout', onFocus, true); return () => { portalNode.removeEventListener('focusin', onFocus, true); portalNode.removeEventListener('focusout', onFocus, true); }; }, [portalNode, modal]); React.useEffect(() => { if (!portalNode || open) { return; } enableFocusInside(portalNode); }, [open, portalNode]); const portalContextValue = React.useMemo(() => ({ beforeOutsideRef, afterOutsideRef, beforeInsideRef, afterInsideRef, portalNode, setFocusManagerState }), [portalNode]); return /*#__PURE__*/_jsxs(React.Fragment, { children: [portalSubtree, /*#__PURE__*/_jsxs(PortalContext.Provider, { value: portalContextValue, children: [shouldRenderGuards && portalNode && /*#__PURE__*/_jsx(FocusGuard, { "data-type": "outside", ref: beforeOutsideRef, onFocus: event => { if (isOutsideEvent(event, portalNode)) { beforeInsideRef.current?.focus(); } else { const domReference = focusManagerState ? focusManagerState.domReference : null; const prevTabbable = getPreviousTabbable(domReference); prevTabbable?.focus(); } } }), shouldRenderGuards && portalNode && /*#__PURE__*/_jsx("span", { "aria-owns": portalNode.id, style: ownerVisuallyHidden }), portalNode && /*#__PURE__*/ReactDOM.createPortal(children, portalNode), shouldRenderGuards && portalNode && /*#__PURE__*/_jsx(FocusGuard, { "data-type": "outside", ref: afterOutsideRef, onFocus: event => { if (isOutsideEvent(event, portalNode)) { afterInsideRef.current?.focus(); } else { const domReference = focusManagerState ? focusManagerState.domReference : null; const nextTabbable = getNextTabbable(domReference); nextTabbable?.focus(); if (focusManagerState?.closeOnFocusOut) { focusManagerState?.onOpenChange(false, createChangeEventDetails(REASONS.focusOut, event.nativeEvent)); } } } })] })] }); }); if (process.env.NODE_ENV !== "production") FloatingPortal.displayName = "FloatingPortal";