@carbon/react
Version:
React components for the Carbon Design System
296 lines (283 loc) • 10.2 kB
JavaScript
/**
* 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.
*/
import React, { useContext, useState, useRef, useCallback, useEffect, cloneElement } from 'react';
import * as FeatureFlags from '@carbon/feature-flags';
import ReactDOM from 'react-dom';
import window from 'window-or-global';
import { Tab } from './keyboard/keys.js';
import { match } from './keyboard/match.js';
import { selectorTabbable, selectorFocusable } from './keyboard/navigation.js';
import { OptimizedResize } from './OptimizedResize.js';
import { PrefixContext } from './usePrefix.js';
import { warning } from './warning.js';
import { wrapFocusWithoutSentinels, wrapFocus } from './wrapFocus.js';
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 = useContext(PrefixContext);
const [floatingPosition, setFloatingPosition] = useState(undefined);
const menuBodyRef = useRef(null);
const startSentinelRef = useRef(null);
const endSentinelRef = useRef(null);
const placeInProgressRef = useRef(false);
const updateMenuPosition = useCallback(isAdjustment => {
const menuBody = menuBodyRef.current;
if (!menuBody) {
process.env.NODE_ENV !== "production" ? 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;
if (updateOrientation) {
updateOrientation({
menuSize,
refPosition,
direction: menuDirection,
offset: offsetValue,
scrollX: window.pageXOffset,
scrollY: window.pageYOffset,
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: window.pageXOffset,
scrollY: window.pageYOffset,
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();
// TODO: Was there a bug in the old code? How could one `DOMRect` be
// compared to another using `!==`?
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(selectorTabbable);
const focusableNode = menuBody.querySelector(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(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.
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;
}
}, [floatingPosition, onPlace]);
// Attach a resize listener.
useEffect(() => {
const resizeHandler = OptimizedResize.add(() => {
updateMenuPosition();
});
return () => {
resizeHandler.remove();
};
}, [triggerRef, menuOffset, menuDirection, flipped, target, updateOrientation]);
// Update menu position when key props change.
useEffect(() => {
updateMenuPosition();
}, [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__*/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({
bodyNode: menuBodyRef.current,
startTrapNode: startSentinelRef.current,
endTrapNode: endSentinelRef.current,
currentActiveNode: relatedTarget,
oldActiveNode: target
});
}
};
/**
* Keydown handler for focus wrapping when experimental focus trap is enabled.
*/
const handleKeyDown = event => {
if (match(event, Tab) && menuBodyRef.current && event.target instanceof HTMLElement) {
wrapFocusWithoutSentinels({
containerNode: menuBodyRef.current,
currentActiveNode: event.target,
event
});
}
};
const focusTrapWithoutSentinels = FeatureFlags.enabled('enable-experimental-focus-wrap-without-sentinels');
if (typeof document !== 'undefined') {
const portalTarget = target ? target() : document.body;
return /*#__PURE__*/ReactDOM.createPortal(/*#__PURE__*/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;
};
export { DIRECTION_BOTTOM, DIRECTION_LEFT, DIRECTION_RIGHT, DIRECTION_TOP, FloatingMenu };