UNPKG

@carbon/react

Version:

React components for the Carbon Design System

329 lines (313 loc) 11.4 kB
/** * Copyright IBM Corp. 2016, 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; var React = require('react'); var FeatureFlags = require('@carbon/feature-flags'); var ReactDOM = require('react-dom'); var keys = require('./keyboard/keys.js'); var match = require('./keyboard/match.js'); var navigation = require('./keyboard/navigation.js'); var OptimizedResize = require('./OptimizedResize.js'); var usePrefix = require('./usePrefix.js'); var warning = require('./warning.js'); var wrapFocus = require('./wrapFocus.js'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var FeatureFlags__namespace = /*#__PURE__*/_interopNamespaceDefault(FeatureFlags); const DIRECTION_LEFT = 'left'; const DIRECTION_TOP = 'top'; const DIRECTION_RIGHT = 'right'; const DIRECTION_BOTTOM = 'bottom'; /** * Computes the floating menu's position based on the menu size, trigger element * position, offset, direction, and container. */ const getFloatingPosition = ({ menuSize, refPosition, offset, direction, scrollX, scrollY, container }) => { const { left: refLeft = 0, top: refTop = 0, right: refRight = 0, bottom: refBottom = 0 } = refPosition; const effectiveScrollX = container.position !== 'static' ? 0 : scrollX; const effectiveScrollY = container.position !== 'static' ? 0 : scrollY; const relativeDiff = { top: container.position !== 'static' ? container.rect.top : 0, left: container.position !== 'static' ? container.rect.left : 0 }; const { width, height } = menuSize; const { top = 0, left = 0 } = offset; const refCenterHorizontal = (refLeft + refRight) / 2; const refCenterVertical = (refTop + refBottom) / 2; const positions = { [DIRECTION_LEFT]: () => ({ left: refLeft - width + effectiveScrollX - left - relativeDiff.left, top: refCenterVertical - height / 2 + effectiveScrollY + top - 9 - relativeDiff.top }), [DIRECTION_TOP]: () => ({ left: refCenterHorizontal - width / 2 + effectiveScrollX + left - relativeDiff.left, top: refTop - height + effectiveScrollY - top - relativeDiff.top }), [DIRECTION_RIGHT]: () => ({ left: refRight + effectiveScrollX + left - relativeDiff.left, top: refCenterVertical - height / 2 + effectiveScrollY + top + 3 - relativeDiff.top }), [DIRECTION_BOTTOM]: () => ({ left: refCenterHorizontal - width / 2 + effectiveScrollX + left - relativeDiff.left, top: refBottom + effectiveScrollY + top - relativeDiff.top }) }; return positions[direction](); }; const FloatingMenu = ({ children, flipped, focusTrap, menuDirection = DIRECTION_BOTTOM, menuOffset = { top: 0, left: 0 }, menuRef: externalMenuRef, onPlace, selectorPrimaryFocus, styles, target = () => document.body, triggerRef, updateOrientation }) => { const prefix = React.useContext(usePrefix.PrefixContext); const [floatingPosition, setFloatingPosition] = React.useState(undefined); const menuBodyRef = React.useRef(null); const startSentinelRef = React.useRef(null); const endSentinelRef = React.useRef(null); const placeInProgressRef = React.useRef(false); const updateMenuPosition = React.useCallback(isAdjustment => { const menuBody = menuBodyRef.current; if (!menuBody) { process.env.NODE_ENV !== "production" ? warning.warning(!!menuBody, 'The DOM node for menu body for calculating its position is not available. Skipping...') : void 0; return; } const triggerEl = triggerRef.current; const menuSize = menuBody.getBoundingClientRect(); const refPosition = triggerEl ? triggerEl.getBoundingClientRect() : undefined; const offsetValue = typeof menuOffset === 'function' ? menuOffset(menuBody, menuDirection, triggerEl, flipped) : menuOffset; const scrollX = globalThis.scrollX ?? 0; const scrollY = globalThis.scrollY ?? 0; if (updateOrientation) { updateOrientation({ menuSize, refPosition, direction: menuDirection, offset: offsetValue, scrollX, scrollY, container: { rect: target().getBoundingClientRect(), position: getComputedStyle(target()).position } }); } // Only set position if the menu has a valid size or if no offset is provided. if (menuSize.width > 0 && menuSize.height > 0 || !offsetValue) { const newFloatingPosition = getFloatingPosition({ menuSize, refPosition: refPosition ?? { left: 0, top: 0, right: 0, bottom: 0 }, offset: offsetValue, direction: menuDirection, scrollX, scrollY, container: { rect: target().getBoundingClientRect(), position: getComputedStyle(target()).position } }); // Only update if the position has actually changed. if (!floatingPosition || floatingPosition.left !== newFloatingPosition.left || floatingPosition.top !== newFloatingPosition.top) { setFloatingPosition(newFloatingPosition); } // Re-check after setting the position if not already adjusting. if (!isAdjustment) { const newMenuSize = menuBody.getBoundingClientRect(); if (newMenuSize.width !== menuSize.width || newMenuSize.height !== menuSize.height) { updateMenuPosition(true); } } } }, [triggerRef, menuOffset, menuDirection, flipped, target, updateOrientation, floatingPosition]); const focusMenuContent = menuBody => { const primaryFocusNode = selectorPrimaryFocus ? menuBody.querySelector(selectorPrimaryFocus) : null; const tabbableNode = menuBody.querySelector(navigation.selectorTabbable); const focusableNode = menuBody.querySelector(navigation.selectorFocusable); const focusTarget = primaryFocusNode || // User defined focusable node tabbableNode || // First sequentially focusable node focusableNode || // First programmatic focusable node menuBody; focusTarget.focus(); if (focusTarget === menuBody) { process.env.NODE_ENV !== "production" ? warning.warning(focusableNode === null, 'Floating Menus must have at least a programmatically focusable child. This can be accomplished by adding tabIndex="-1" to the content element.') : void 0; } }; const handleMenuRef = node => { menuBodyRef.current = node; placeInProgressRef.current = !!node; if (externalMenuRef) { externalMenuRef(node); } if (node) { updateMenuPosition(); } }; // When the menu has been placed, focus the content and call onPlace. React.useEffect(() => { if (placeInProgressRef.current && floatingPosition && menuBodyRef.current) { if (!menuBodyRef.current.contains(document.activeElement)) { focusMenuContent(menuBodyRef.current); } if (typeof onPlace === 'function') { onPlace(menuBodyRef.current); } placeInProgressRef.current = false; } // eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452 }, [floatingPosition, onPlace]); // Attach a resize listener. React.useEffect(() => { const resizeHandler = OptimizedResize.OptimizedResize.add(() => { updateMenuPosition(); }); return () => { resizeHandler.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452 }, [triggerRef, menuOffset, menuDirection, flipped, target, updateOrientation]); // Update menu position when key props change. React.useEffect(() => { updateMenuPosition(); // eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452 }, [menuOffset, menuDirection, flipped, triggerRef, target, updateOrientation]); /** * Clones the child element to add a `ref` and positioning styles. */ const getChildrenWithProps = () => { const pos = floatingPosition; const positioningStyle = pos ? { left: `${pos.left}px`, top: `${pos.top}px`, right: 'auto' } : { visibility: 'hidden', top: '0px' }; const child = children; return /*#__PURE__*/React.cloneElement(child, { ref: handleMenuRef, style: { ...styles, ...positioningStyle, position: 'absolute', opacity: 1 } }); }; /** * Blur handler used when focus trapping is enabled. */ const handleBlur = event => { const { target, relatedTarget } = event; if (menuBodyRef.current && startSentinelRef.current && endSentinelRef.current && target instanceof HTMLElement && relatedTarget instanceof HTMLElement) { wrapFocus.wrapFocus({ bodyNode: menuBodyRef.current, startTrapNode: startSentinelRef.current, endTrapNode: endSentinelRef.current, currentActiveNode: relatedTarget, oldActiveNode: target, prefix }); } }; /** * Keydown handler for focus wrapping when experimental focus trap is enabled. */ const handleKeyDown = event => { if (match.match(event, keys.Tab) && menuBodyRef.current && event.target instanceof HTMLElement) { wrapFocus.wrapFocusWithoutSentinels({ containerNode: menuBodyRef.current, currentActiveNode: event.target, event }); } }; const deprecatedFlag = FeatureFlags__namespace.enabled('enable-experimental-focus-wrap-without-sentinels'); const focusTrapWithoutSentinelsFlag = FeatureFlags__namespace.enabled('enable-focus-wrap-without-sentinels'); const focusTrapWithoutSentinels = deprecatedFlag || focusTrapWithoutSentinelsFlag; if (typeof document !== 'undefined') { const portalTarget = target ? target() : document.body; return /*#__PURE__*/ReactDOM.createPortal( /*#__PURE__*/ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- https://github.com/carbon-design-system/carbon/issues/20452 React.createElement("div", { onBlur: focusTrap && !focusTrapWithoutSentinels ? handleBlur : undefined, onKeyDown: focusTrapWithoutSentinels ? handleKeyDown : undefined }, !focusTrapWithoutSentinels && /*#__PURE__*/React.createElement("span", { ref: startSentinelRef, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden` }, "Focus sentinel"), getChildrenWithProps(), !focusTrapWithoutSentinels && /*#__PURE__*/React.createElement("span", { ref: endSentinelRef, tabIndex: 0, role: "link", className: `${prefix}--visually-hidden` }, "Focus sentinel")), portalTarget); } return null; }; exports.DIRECTION_BOTTOM = DIRECTION_BOTTOM; exports.DIRECTION_LEFT = DIRECTION_LEFT; exports.DIRECTION_RIGHT = DIRECTION_RIGHT; exports.DIRECTION_TOP = DIRECTION_TOP; exports.FloatingMenu = FloatingMenu;