@carbon/react
Version:
React components for the Carbon Design System
259 lines (257 loc) • 9.62 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
const require_runtime = require("../_virtual/_rolldown/runtime.js");
const require_usePrefix = require("./usePrefix.js");
const require_keys = require("./keyboard/keys.js");
const require_match = require("./keyboard/match.js");
const require_navigation = require("./keyboard/navigation.js");
const require_warning = require("./warning.js");
const require_wrapFocus = require("./wrapFocus.js");
const require_OptimizedResize = require("./OptimizedResize.js");
let _carbon_feature_flags = require("@carbon/feature-flags");
let react = require("react");
react = require_runtime.__toESM(react);
let react_jsx_runtime = require("react/jsx-runtime");
let react_dom = require("react-dom");
react_dom = require_runtime.__toESM(react_dom);
//#region src/internal/FloatingMenu.tsx
/**
* Copyright IBM Corp. 2016, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
const DIRECTION_LEFT = "left";
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;
return {
[DIRECTION_LEFT]: () => ({
left: refLeft - width + effectiveScrollX - left - relativeDiff.left,
top: refCenterVertical - height / 2 + effectiveScrollY + top - 9 - relativeDiff.top
}),
["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
})
}[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 = (0, react.useContext)(require_usePrefix.PrefixContext);
const [floatingPosition, setFloatingPosition] = (0, react.useState)(void 0);
const menuBodyRef = (0, react.useRef)(null);
const startSentinelRef = (0, react.useRef)(null);
const endSentinelRef = (0, react.useRef)(null);
const placeInProgressRef = (0, react.useRef)(false);
const updateMenuPosition = (0, react.useCallback)((isAdjustment) => {
const menuBody = menuBodyRef.current;
if (!menuBody) {
require_warning.warning(!!menuBody, "The DOM node for menu body for calculating its position is not available. Skipping...");
return;
}
const triggerEl = triggerRef.current;
const menuSize = menuBody.getBoundingClientRect();
const refPosition = triggerEl ? triggerEl.getBoundingClientRect() : void 0;
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
}
});
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
}
});
if (!floatingPosition || floatingPosition.left !== newFloatingPosition.left || floatingPosition.top !== newFloatingPosition.top) setFloatingPosition(newFloatingPosition);
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(require_navigation.selectorTabbable);
const focusableNode = menuBody.querySelector(require_navigation.selectorFocusable);
const focusTarget = primaryFocusNode || tabbableNode || focusableNode || menuBody;
focusTarget.focus();
if (focusTarget === menuBody) require_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.");
};
const handleMenuRef = (node) => {
menuBodyRef.current = node;
placeInProgressRef.current = !!node;
if (externalMenuRef) externalMenuRef(node);
if (node) updateMenuPosition();
};
(0, 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;
}
}, [floatingPosition, onPlace]);
(0, react.useEffect)(() => {
const resizeHandler = require_OptimizedResize.OptimizedResize.add(() => {
updateMenuPosition();
});
return () => {
resizeHandler.remove();
};
}, [
triggerRef,
menuOffset,
menuDirection,
flipped,
target,
updateOrientation
]);
(0, react.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"
};
return (0, react.cloneElement)(children, {
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) require_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 (require_match.match(event, require_keys.Tab) && menuBodyRef.current && event.target instanceof HTMLElement) require_wrapFocus.wrapFocusWithoutSentinels({
containerNode: menuBodyRef.current,
currentActiveNode: event.target,
event
});
};
const deprecatedFlag = (0, _carbon_feature_flags.enabled)("enable-experimental-focus-wrap-without-sentinels");
const focusTrapWithoutSentinelsFlag = (0, _carbon_feature_flags.enabled)("enable-focus-wrap-without-sentinels");
const focusTrapWithoutSentinels = deprecatedFlag || focusTrapWithoutSentinelsFlag;
if (typeof document !== "undefined") {
const portalTarget = target ? target() : document.body;
return react_dom.default.createPortal(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
onBlur: focusTrap && !focusTrapWithoutSentinels ? handleBlur : void 0,
onKeyDown: focusTrapWithoutSentinels ? handleKeyDown : void 0,
children: [
!focusTrapWithoutSentinels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
ref: startSentinelRef,
tabIndex: 0,
role: "link",
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
}),
getChildrenWithProps(),
!focusTrapWithoutSentinels && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
ref: endSentinelRef,
tabIndex: 0,
role: "link",
className: `${prefix}--visually-hidden`,
children: "Focus sentinel"
})
]
}), portalTarget);
}
return null;
};
//#endregion
exports.DIRECTION_BOTTOM = DIRECTION_BOTTOM;
exports.FloatingMenu = FloatingMenu;