@carbon/react
Version:
React components for the Carbon Design System
357 lines (355 loc) • 12 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.
*/
import { PrefixContext } from "../../internal/usePrefix.js";
import { ArrowDown, ArrowLeft, ArrowRight as ArrowRight$1, ArrowUp as ArrowUp$1, Escape, Tab } from "../../internal/keyboard/keys.js";
import { matches } from "../../internal/keyboard/match.js";
import { setupGetInstanceId } from "../../tools/setupGetInstanceId.js";
import { noopFn } from "../../internal/noopFn.js";
import { deprecate } from "../../prop-types/deprecate.js";
import { deprecateValuesWithin } from "../../prop-types/deprecateValuesWithin.js";
import { mapPopoverAlign } from "../../tools/mapPopoverAlign.js";
import { IconButton } from "../IconButton/index.js";
import { mergeRefs } from "../../tools/mergeRefs.js";
import { DIRECTION_BOTTOM, FloatingMenu } from "../../internal/FloatingMenu.js";
import { useOutsideClick } from "../../internal/useOutsideClick.js";
import classNames from "classnames";
import React, { Children, cloneElement, forwardRef, isValidElement, useCallback, useContext, useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { OverflowMenuVertical } from "@carbon/icons-react";
import invariant from "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 = 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",
[DIRECTION_BOTTOM]: "top"
};
/**
* Determines how the position of the arrow should affect the floating menu
* position.
*/
const triggerButtonPositionFactors = {
["top"]: -2,
[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];
invariant(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 = forwardRef(({ align, ["aria-label"]: ariaLabel = null, ariaLabel: deprecatedAriaLabel, children, className, direction = DIRECTION_BOTTOM, flipped = false, focusTrap = false, iconClass, iconDescription = "Options", id, light, menuOffset = getMenuOffset, menuOffsetFlip = getMenuOffset, menuOptionsClass, onClick = noopFn, onClose = noopFn, onOpen = noopFn, open: openProp, renderIcon: IconElement = OverflowMenuVertical, selectorPrimaryFocus = "[data-floating-menu-primary-focus]", size = "md", innerRef, ...other }, ref) => {
const prefix = useContext(PrefixContext);
const [open, setOpen] = useState(openProp ?? false);
const [click, setClick] = useState(false);
const [hasMountedTrigger, setHasMountedTrigger] = useState(false);
/** The handle of `onfocusin` or `focus` event handler. */
const hFocusIn = useRef(null);
const instanceId = useRef(getInstanceId());
const menuBodyRef = useRef(null);
const menuItemRefs = useRef({});
const prevOpenProp = useRef(openProp);
const prevOpenState = useRef(open);
/** The element ref of the tooltip's trigger button. */
const triggerRef = useRef(null);
const wrapperRef = useRef(null);
useEffect(() => {
if (prevOpenProp.current !== openProp) {
setOpen(!!openProp);
prevOpenProp.current = openProp;
}
}, [openProp]);
useEffect(() => {
if (triggerRef.current) setHasMountedTrigger(true);
}, []);
useEffect(() => {
if (open && !prevOpenState.current) onOpen();
else if (!open && prevOpenState.current) onClose();
prevOpenState.current = open;
}, [
open,
onClose,
onOpen
]);
useOutsideClick(wrapperRef, ({ target }) => {
if (open && (!menuBodyRef.current || target instanceof Node && !menuBodyRef.current.contains(target))) closeMenu();
});
const focusMenuEl = useCallback(() => {
if (triggerRef.current) triggerRef.current.focus();
}, []);
const closeMenu = useCallback((onCloseMenu) => {
setOpen(false);
if (onCloseMenu) onCloseMenu();
}, []);
const closeMenuAndFocus = useCallback(() => {
const wasClicked = click;
const wasOpen = open;
closeMenu(() => {
if (wasOpen && !wasClicked) focusMenuEl();
});
}, [
click,
open,
closeMenu,
focusMenuEl
]);
const closeMenuOnEscape = 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 && matches(evt, [
ArrowUp$1,
ArrowRight$1,
ArrowDown,
ArrowLeft
])) evt.preventDefault();
if (matches(evt, [Escape, 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 = Children.toArray(children).reduce((acc, curr, i) => {
if (React.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 = classNames(className, `${prefix}--overflow-menu`, {
[`${prefix}--overflow-menu--open`]: open,
[`${prefix}--overflow-menu--light`]: light,
[`${prefix}--overflow-menu--${size}`]: size
});
const overflowMenuOptionsClasses = classNames(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 = classNames(`${prefix}--overflow-menu__icon`, iconClass);
const childrenWithProps = Children.toArray(children).map((child, index) => {
if (isValidElement(child)) {
const childElement = child;
return cloneElement(childElement, {
closeMenu: childElement.props.closeMenu || closeMenuAndFocus,
handleOverflowMenuItemFocus,
ref: (el) => {
menuItemRefs.current[index] = el;
},
index
});
}
return null;
});
const wrappedMenuBody = /* @__PURE__ */ jsx(FloatingMenu, {
focusTrap,
triggerRef,
menuDirection: direction,
menuOffset: flipped ? menuOffsetFlip : menuOffset,
menuRef: bindMenuBody,
flipped,
target: getTarget,
onPlace: handlePlace,
selectorPrimaryFocus,
children: cloneElement(/* @__PURE__ */ 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 ? mergeRefs(triggerRef, innerRef, ref) : mergeRefs(triggerRef, ref);
return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs("span", {
className: `${prefix}--overflow-menu__wrapper`,
"aria-owns": open ? menuBodyId : void 0,
ref: wrapperRef,
children: [/* @__PURE__ */ jsx(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__ */ jsx(IconElement, {
className: overflowMenuIconClasses,
"aria-label": iconDescription
})
}), open && hasMountedTrigger && wrappedMenuBody]
}) });
});
OverflowMenu.propTypes = {
align: deprecateValuesWithin(PropTypes.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"
], mapPopoverAlign),
["aria-label"]: PropTypes.string,
ariaLabel: deprecate(PropTypes.string, "This prop syntax has been deprecated. Please use the new `aria-label`."),
children: PropTypes.node,
className: PropTypes.string,
direction: PropTypes.oneOf(["top", DIRECTION_BOTTOM]),
flipped: PropTypes.bool,
focusTrap: PropTypes.bool,
iconClass: PropTypes.string,
iconDescription: PropTypes.string,
id: PropTypes.string,
light: deprecate(PropTypes.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: PropTypes.oneOfType([PropTypes.shape({
top: PropTypes.number.isRequired,
left: PropTypes.number.isRequired
}), PropTypes.func]),
menuOffsetFlip: PropTypes.oneOfType([PropTypes.shape({
top: PropTypes.number.isRequired,
left: PropTypes.number.isRequired
}), PropTypes.func]),
menuOptionsClass: PropTypes.string,
onClick: PropTypes.func,
onClose: PropTypes.func,
onFocus: PropTypes.func,
onKeyDown: PropTypes.func,
onOpen: PropTypes.func,
open: PropTypes.bool,
renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
selectorPrimaryFocus: PropTypes.string,
size: PropTypes.oneOf([
"xs",
"sm",
"md",
"lg"
])
};
//#endregion
export { OverflowMenu as default };