@carbon/react
Version:
React components for the Carbon Design System
362 lines (360 loc) • 13.5 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("../../internal/usePrefix.js");
const require_keys = require("../../internal/keyboard/keys.js");
const require_match = require("../../internal/keyboard/match.js");
const require_setupGetInstanceId = require("../../tools/setupGetInstanceId.js");
const require_noopFn = require("../../internal/noopFn.js");
const require_deprecate = require("../../prop-types/deprecate.js");
const require_deprecateValuesWithin = require("../../prop-types/deprecateValuesWithin.js");
const require_mapPopoverAlign = require("../../tools/mapPopoverAlign.js");
const require_index = require("../IconButton/index.js");
const require_mergeRefs = require("../../tools/mergeRefs.js");
const require_FloatingMenu = require("../../internal/FloatingMenu.js");
const require_useOutsideClick = require("../../internal/useOutsideClick.js");
let classnames = require("classnames");
classnames = require_runtime.__toESM(classnames);
let react = require("react");
react = require_runtime.__toESM(react);
let prop_types = require("prop-types");
prop_types = require_runtime.__toESM(prop_types);
let react_jsx_runtime = require("react/jsx-runtime");
let _carbon_icons_react = require("@carbon/icons-react");
let invariant = require("invariant");
invariant = require_runtime.__toESM(invariant);
//#region src/components/OverflowMenu/OverflowMenu.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 getInstanceId = require_setupGetInstanceId.setupGetInstanceId();
const on = (target, ...args) => {
target.addEventListener(...args);
return { release() {
target.removeEventListener(...args);
return null;
} };
};
/**
* The CSS property names of the arrow keyed by the floating menu direction.
*/
const triggerButtonPositionProps = {
["top"]: "bottom",
[require_FloatingMenu.DIRECTION_BOTTOM]: "top"
};
/**
* Determines how the position of the arrow should affect the floating menu
* position.
*/
const triggerButtonPositionFactors = {
["top"]: -2,
[require_FloatingMenu.DIRECTION_BOTTOM]: -1
};
/**
* Calculates the offset for the floating menu.
*
* @param menuBody - The menu body with the menu arrow.
* @param direction - The floating menu direction.
* @returns The adjustment of the floating menu position, upon the position of
* the menu arrow.
*/
const getMenuOffset = (menuBody, direction, trigger, flip) => {
const triggerButtonPositionProp = triggerButtonPositionProps[direction];
const triggerButtonPositionFactor = triggerButtonPositionFactors[direction];
if (process.env.NODE_ENV !== "production") (0, invariant.default)(triggerButtonPositionProp && triggerButtonPositionFactor, "[OverflowMenu] wrong floating menu direction: `%s`", direction);
const { offsetWidth: menuWidth } = menuBody;
switch (triggerButtonPositionProp) {
case "top":
case "bottom": {
const triggerWidth = !trigger ? 0 : trigger.offsetWidth;
return {
left: (!flip ? 1 : -1) * (menuWidth / 2 - triggerWidth / 2),
top: 0
};
}
default: return {
left: 0,
top: 0
};
}
};
const OverflowMenu = (0, react.forwardRef)(({ align, ["aria-label"]: ariaLabel = null, ariaLabel: deprecatedAriaLabel, children, className, direction = require_FloatingMenu.DIRECTION_BOTTOM, flipped = false, focusTrap = false, iconClass, iconDescription = "Options", id, light, menuOffset = getMenuOffset, menuOffsetFlip = getMenuOffset, menuOptionsClass, onClick = require_noopFn.noopFn, onClose = require_noopFn.noopFn, onOpen = require_noopFn.noopFn, open: openProp, renderIcon: IconElement = _carbon_icons_react.OverflowMenuVertical, selectorPrimaryFocus = "[data-floating-menu-primary-focus]", size = "md", innerRef, ...other }, ref) => {
const prefix = (0, react.useContext)(require_usePrefix.PrefixContext);
const [open, setOpen] = (0, react.useState)(openProp ?? false);
const [click, setClick] = (0, react.useState)(false);
const [hasMountedTrigger, setHasMountedTrigger] = (0, react.useState)(false);
/** The handle of `onfocusin` or `focus` event handler. */
const hFocusIn = (0, react.useRef)(null);
const instanceId = (0, react.useRef)(getInstanceId());
const menuBodyRef = (0, react.useRef)(null);
const menuItemRefs = (0, react.useRef)({});
const prevOpenProp = (0, react.useRef)(openProp);
const prevOpenState = (0, react.useRef)(open);
/** The element ref of the tooltip's trigger button. */
const triggerRef = (0, react.useRef)(null);
const wrapperRef = (0, react.useRef)(null);
(0, react.useEffect)(() => {
if (prevOpenProp.current !== openProp) {
setOpen(!!openProp);
prevOpenProp.current = openProp;
}
}, [openProp]);
(0, react.useEffect)(() => {
if (triggerRef.current) setHasMountedTrigger(true);
}, []);
(0, react.useEffect)(() => {
if (open && !prevOpenState.current) onOpen();
else if (!open && prevOpenState.current) onClose();
prevOpenState.current = open;
}, [
open,
onClose,
onOpen
]);
require_useOutsideClick.useOutsideClick(wrapperRef, ({ target }) => {
if (open && (!menuBodyRef.current || target instanceof Node && !menuBodyRef.current.contains(target))) closeMenu();
});
const focusMenuEl = (0, react.useCallback)(() => {
if (triggerRef.current) triggerRef.current.focus();
}, []);
const closeMenu = (0, react.useCallback)((onCloseMenu) => {
setOpen(false);
if (onCloseMenu) onCloseMenu();
}, []);
const closeMenuAndFocus = (0, react.useCallback)(() => {
const wasClicked = click;
const wasOpen = open;
closeMenu(() => {
if (wasOpen && !wasClicked) focusMenuEl();
});
}, [
click,
open,
closeMenu,
focusMenuEl
]);
const closeMenuOnEscape = (0, react.useCallback)(() => {
const wasOpen = open;
closeMenu(() => {
if (wasOpen) focusMenuEl();
});
}, [
open,
closeMenu,
focusMenuEl
]);
const handleClick = (evt) => {
setClick(true);
if (!menuBodyRef.current || !menuBodyRef.current.contains(evt.target)) {
setOpen((prev) => !prev);
onClick(evt);
}
};
const handleKeyPress = (evt) => {
if (open && require_match.matches(evt, [
require_keys.ArrowUp,
require_keys.ArrowRight,
require_keys.ArrowDown,
require_keys.ArrowLeft
])) evt.preventDefault();
if (require_match.matches(evt, [require_keys.Escape, require_keys.Tab])) {
closeMenuOnEscape();
evt.stopPropagation();
evt.preventDefault();
}
};
/**
* Focuses the next enabled overflow menu item given the currently focused
* item index and direction to move.
*/
const handleOverflowMenuItemFocus = ({ currentIndex = 0, direction }) => {
const enabledIndices = react.Children.toArray(children).reduce((acc, curr, i) => {
if (react.default.isValidElement(curr) && !curr.props.disabled) acc.push(i);
return acc;
}, []);
const nextValidIndex = (() => {
const nextIndex = enabledIndices.indexOf(currentIndex) + direction;
switch (nextIndex) {
case -1: return enabledIndices.length - 1;
case enabledIndices.length: return 0;
default: return nextIndex;
}
})();
menuItemRefs.current[enabledIndices[nextValidIndex]]?.focus();
};
const bindMenuBody = (menuBody) => {
if (!menuBody) menuBodyRef.current = menuBody;
if (!menuBody && hFocusIn.current) hFocusIn.current = hFocusIn.current.release();
};
const handlePlace = (menuBody) => {
if (!menuBody) return;
menuBodyRef.current = menuBody;
const hasFocusin = "onfocusin" in window;
const focusinEventName = hasFocusin ? "focusin" : "focus";
hFocusIn.current = on(menuBody.ownerDocument, focusinEventName, (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const triggerEl = triggerRef.current;
if (typeof target.matches === "function") {
if (!menuBody.contains(target) && triggerEl && !target.matches(`.${prefix}--overflow-menu:first-child, .${prefix}--overflow-menu-options:first-child`)) closeMenuAndFocus();
}
}, !hasFocusin);
};
const getTarget = () => {
const triggerEl = triggerRef.current;
if (triggerEl instanceof Element) return triggerEl.closest("[data-floating-menu-container]") || document.body;
return document.body;
};
const menuBodyId = `overflow-menu-${instanceId.current}__menu-body`;
const overflowMenuClasses = (0, classnames.default)(className, `${prefix}--overflow-menu`, {
[`${prefix}--overflow-menu--open`]: open,
[`${prefix}--overflow-menu--light`]: light,
[`${prefix}--overflow-menu--${size}`]: size
});
const overflowMenuOptionsClasses = (0, classnames.default)(menuOptionsClass, `${prefix}--overflow-menu-options`, {
[`${prefix}--overflow-menu--flip`]: flipped,
[`${prefix}--overflow-menu-options--open`]: open,
[`${prefix}--overflow-menu-options--light`]: light,
[`${prefix}--overflow-menu-options--${size}`]: size
});
const overflowMenuIconClasses = (0, classnames.default)(`${prefix}--overflow-menu__icon`, iconClass);
const childrenWithProps = react.Children.toArray(children).map((child, index) => {
if ((0, react.isValidElement)(child)) {
const childElement = child;
return (0, react.cloneElement)(childElement, {
closeMenu: childElement.props.closeMenu || closeMenuAndFocus,
handleOverflowMenuItemFocus,
ref: (el) => {
menuItemRefs.current[index] = el;
},
index
});
}
return null;
});
const wrappedMenuBody = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_FloatingMenu.FloatingMenu, {
focusTrap,
triggerRef,
menuDirection: direction,
menuOffset: flipped ? menuOffsetFlip : menuOffset,
menuRef: bindMenuBody,
flipped,
target: getTarget,
onPlace: handlePlace,
selectorPrimaryFocus,
children: (0, react.cloneElement)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
className: overflowMenuOptionsClasses,
tabIndex: -1,
role: "menu",
"aria-label": ariaLabel || deprecatedAriaLabel,
onKeyDown: handleKeyPress,
id: menuBodyId,
children: childrenWithProps
}), { "data-floating-menu-direction": direction })
});
const combinedRef = innerRef ? require_mergeRefs.mergeRefs(triggerRef, innerRef, ref) : require_mergeRefs.mergeRefs(triggerRef, ref);
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
className: `${prefix}--overflow-menu__wrapper`,
"aria-owns": open ? menuBodyId : void 0,
ref: wrapperRef,
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index.IconButton, {
...other,
align,
type: "button",
"aria-haspopup": true,
"aria-expanded": open,
"aria-controls": open ? menuBodyId : void 0,
className: overflowMenuClasses,
onClick: handleClick,
id,
ref: combinedRef,
size,
label: iconDescription,
kind: "ghost",
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconElement, {
className: overflowMenuIconClasses,
"aria-label": iconDescription
})
}), open && hasMountedTrigger && wrappedMenuBody]
}) });
});
OverflowMenu.propTypes = {
align: require_deprecateValuesWithin.deprecateValuesWithin(prop_types.default.oneOf([
"top",
"top-left",
"top-right",
"bottom",
"bottom-left",
"bottom-right",
"left",
"left-bottom",
"left-top",
"right",
"right-bottom",
"right-top",
"top-start",
"top-end",
"bottom-start",
"bottom-end",
"left-end",
"left-start",
"right-end",
"right-start"
]), [
"top",
"top-start",
"top-end",
"bottom",
"bottom-start",
"bottom-end",
"left",
"left-start",
"left-end",
"right",
"right-start",
"right-end"
], require_mapPopoverAlign.mapPopoverAlign),
["aria-label"]: prop_types.default.string,
ariaLabel: require_deprecate.deprecate(prop_types.default.string, "This prop syntax has been deprecated. Please use the new `aria-label`."),
children: prop_types.default.node,
className: prop_types.default.string,
direction: prop_types.default.oneOf(["top", require_FloatingMenu.DIRECTION_BOTTOM]),
flipped: prop_types.default.bool,
focusTrap: prop_types.default.bool,
iconClass: prop_types.default.string,
iconDescription: prop_types.default.string,
id: prop_types.default.string,
light: require_deprecate.deprecate(prop_types.default.bool, "The `light` prop for `OverflowMenu` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead."),
menuOffset: prop_types.default.oneOfType([prop_types.default.shape({
top: prop_types.default.number.isRequired,
left: prop_types.default.number.isRequired
}), prop_types.default.func]),
menuOffsetFlip: prop_types.default.oneOfType([prop_types.default.shape({
top: prop_types.default.number.isRequired,
left: prop_types.default.number.isRequired
}), prop_types.default.func]),
menuOptionsClass: prop_types.default.string,
onClick: prop_types.default.func,
onClose: prop_types.default.func,
onFocus: prop_types.default.func,
onKeyDown: prop_types.default.func,
onOpen: prop_types.default.func,
open: prop_types.default.bool,
renderIcon: prop_types.default.oneOfType([prop_types.default.func, prop_types.default.object]),
selectorPrimaryFocus: prop_types.default.string,
size: prop_types.default.oneOf([
"xs",
"sm",
"md",
"lg"
])
};
//#endregion
exports.default = OverflowMenu;