@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.
204 lines (202 loc) • 7.46 kB
JavaScript
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 { visuallyHidden } from '@base-ui-components/utils/visuallyHidden';
import { FocusGuard } from "../../utils/FocusGuard.js";
import { enableFocusInside, disableFocusInside, getPreviousTabbable, getNextTabbable, isOutsideEvent } from "../utils.js";
import { createAttribute } from "../utils/createAttribute.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');
/**
* @see https://floating-ui.com/docs/FloatingPortal#usefloatingportalnode
*/
export function useFloatingPortalNode(props = {}) {
const {
id,
root
} = props;
const uniqueId = useId();
const portalContext = usePortalContext();
const [portalNode, setPortalNode] = React.useState(null);
const portalNodeRef = React.useRef(null);
useIsoLayoutEffect(() => {
return () => {
portalNode?.remove();
// Allow the subsequent layout effects to create a new node on updates.
// The portal node will still be cleaned up on unmount.
// https://github.com/floating-ui/floating-ui/issues/2454
queueMicrotask(() => {
portalNodeRef.current = null;
});
};
}, [portalNode]);
useIsoLayoutEffect(() => {
// Wait for the uniqueId to be generated before creating the portal node in
// React <18 (using `useFloatingId` instead of the native `useId`).
// https://github.com/floating-ui/floating-ui/issues/2778
if (!uniqueId) {
return;
}
if (portalNodeRef.current) {
return;
}
const existingIdRoot = id ? document.getElementById(id) : null;
if (!existingIdRoot) {
return;
}
const subRoot = document.createElement('div');
subRoot.id = uniqueId;
subRoot.setAttribute(attr, '');
existingIdRoot.appendChild(subRoot);
portalNodeRef.current = subRoot;
setPortalNode(subRoot);
}, [id, uniqueId]);
useIsoLayoutEffect(() => {
// Wait for the root to exist before creating the portal node. The root must
// be stored in state, not a ref, for this to work reactively.
if (root === null) {
return;
}
if (!uniqueId) {
return;
}
if (portalNodeRef.current) {
return;
}
let container = root || portalContext?.portalNode;
if (container && !isNode(container)) {
container = container.current;
}
container = container || document.body;
let idWrapper = null;
if (id) {
idWrapper = document.createElement('div');
idWrapper.id = id;
container.appendChild(idWrapper);
}
const subRoot = document.createElement('div');
subRoot.id = uniqueId;
subRoot.setAttribute(attr, '');
container = idWrapper || container;
container.appendChild(subRoot);
portalNodeRef.current = subRoot;
setPortalNode(subRoot);
}, [id, root, uniqueId, portalContext]);
return portalNode;
}
/**
* 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 function FloatingPortal(props) {
const {
children,
id,
root,
preserveTabOrder = true
} = props;
const portalNode = useFloatingPortalNode({
id,
root
});
const [focusManagerState, setFocusManagerState] = React.useState(null);
const beforeOutsideRef = React.useRef(null);
const afterOutsideRef = React.useRef(null);
const beforeInsideRef = React.useRef(null);
const afterInsideRef = React.useRef(null);
const modal = focusManagerState?.modal;
const open = focusManagerState?.open;
const shouldRenderGuards =
// The FocusManager and therefore floating element are currently open/
// rendered.
!!focusManagerState &&
// Guards are only for non-modal focus management.
!focusManagerState.modal &&
// Don't render if unmount is transitioning.
focusManagerState.open && preserveTabOrder && !!(root || portalNode);
// https://codesandbox.io/s/tabbable-portal-f4tng?file=/src/TabbablePortal.tsx
React.useEffect(() => {
if (!portalNode || !preserveTabOrder || 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, preserveTabOrder, modal]);
React.useEffect(() => {
if (!portalNode) {
return;
}
if (open) {
return;
}
enableFocusInside(portalNode);
}, [open, portalNode]);
return /*#__PURE__*/_jsxs(PortalContext.Provider, {
value: React.useMemo(() => ({
preserveTabOrder,
beforeOutsideRef,
afterOutsideRef,
beforeInsideRef,
afterInsideRef,
portalNode,
setFocusManagerState
}), [preserveTabOrder, portalNode]),
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: visuallyHidden
}), 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, event.nativeEvent, 'focus-out');
}
}
}
})]
});
}