@carbon/react
Version:
React components for the Carbon Design System
242 lines (240 loc) • 9.87 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_deprecate = require("../../prop-types/deprecate.js");
const require_useMergedRefs = require("../../internal/useMergedRefs.js");
const require_MenuContext = require("./MenuContext.js");
const require_useLayoutDirection = require("../LayoutDirection/useLayoutDirection.js");
const require_environment = require("../../internal/environment.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 react_dom = require("react-dom");
//#region src/components/Menu/Menu.tsx
/**
* Copyright IBM Corp. 2023, 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 spacing = 8;
const Menu = (0, react.forwardRef)(function Menu({ backgroundToken = "layer", border = false, children, className, containerRef, label, menuAlignment, onClose, onOpen, open, size = "sm", legacyAutoalign = true, target = require_environment.canUseDOM && document.body, x = 0, y = 0, ...rest }, forwardRef) {
const prefix = require_usePrefix.usePrefix();
const focusReturn = (0, react.useRef)(null);
const context = (0, react.useContext)(require_MenuContext.MenuContext);
const isRoot = context.state.isRoot;
const menuSize = isRoot ? size : context.state.size;
const [childState, childDispatch] = (0, react.useReducer)(require_MenuContext.menuReducer, {
...context.state,
isRoot: false,
hasIcons: false,
hasSelectableItems: false,
size,
requestCloseRoot: isRoot ? handleClose : context.state.requestCloseRoot
});
const childContext = (0, react.useMemo)(() => {
return {
state: childState,
dispatch: childDispatch
};
}, [childState, childDispatch]);
const menu = (0, react.useRef)(null);
const ref = require_useMergedRefs.useMergedRefs([forwardRef, menu]);
const [position, setPosition] = (0, react.useState)([-1, -1]);
const focusableItems = (0, react.useMemo)(() => childContext.state.items.filter((item) => !item.disabled && item.ref.current), [childContext.state.items]);
let actionButtonWidth;
if (containerRef?.current) {
const { width: w } = containerRef.current.getBoundingClientRect();
actionButtonWidth = w;
}
const { direction } = require_useLayoutDirection.useLayoutDirection();
function returnFocus() {
if (focusReturn.current) focusReturn.current.focus();
}
function handleOpen() {
if (menu.current) {
const { activeElement, dir } = document;
focusReturn.current = activeElement instanceof HTMLElement ? activeElement : null;
if (legacyAutoalign) {
const pos = calculatePosition();
if ((dir === "rtl" || direction === "rtl") && !rest?.id?.includes("MenuButton")) {
menu.current.style.insetInlineStart = `initial`;
menu.current.style.insetInlineEnd = `${pos[0]}px`;
} else {
menu.current.style.insetInlineStart = `${pos[0]}px`;
menu.current.style.insetInlineEnd = `initial`;
}
menu.current.style.insetBlockStart = `${pos[1]}px`;
setPosition(pos);
}
menu.current.focus();
if (onOpen) onOpen();
}
}
function handleClose() {
returnFocus();
if (onClose) onClose();
}
function handleKeyDown(e) {
e.stopPropagation();
if ((require_match.match(e, require_keys.Escape) || require_match.match(e, require_keys.Tab) || !isRoot && require_match.match(e, require_keys.ArrowLeft)) && onClose) {
e.preventDefault();
handleClose();
} else focusItem(e);
}
function focusItem(e) {
const validItems = focusableItems?.filter((item) => item?.ref?.current);
if (!validItems?.length) return;
const currentItem = focusableItems.findIndex((item) => item.ref?.current?.contains(document.activeElement));
let indexToFocus = currentItem;
if (currentItem === -1) indexToFocus = 0;
else if (e) {
if (require_match.match(e, require_keys.ArrowUp)) indexToFocus = indexToFocus - 1;
if (require_match.match(e, require_keys.ArrowDown)) indexToFocus = indexToFocus + 1;
}
if (indexToFocus < 0) indexToFocus = validItems.length - 1;
if (indexToFocus >= validItems.length) indexToFocus = 0;
if (indexToFocus !== currentItem) {
validItems[indexToFocus]?.ref?.current?.focus();
e?.preventDefault();
}
}
function handleBlur(e) {
if (open && onClose && isRoot && e.relatedTarget && !menu.current?.contains(e.relatedTarget)) handleClose();
}
function fitValue(range, axis) {
if (!menu.current) return;
const { width, height } = menu.current.getBoundingClientRect();
const alignment = isRoot ? "vertical" : "horizontal";
const axes = {
x: {
max: window.innerWidth,
size: width,
anchor: alignment === "horizontal" ? range[1] : range[0],
reversedAnchor: alignment === "horizontal" ? range[0] : range[1],
offset: 0
},
y: {
max: window.innerHeight,
size: height,
anchor: alignment === "horizontal" ? range[0] : range[1],
reversedAnchor: alignment === "horizontal" ? range[1] : range[0],
offset: isRoot ? 0 : 4
}
};
if (actionButtonWidth && actionButtonWidth < axes.x.size && (menuAlignment === "bottom" || menuAlignment === "top")) axes.x.size = actionButtonWidth;
if (actionButtonWidth && (menuAlignment === "bottom-end" || menuAlignment === "top-end") && axes.x.anchor >= 87 && actionButtonWidth < axes.x.size) {
const diff = axes.x.anchor + axes.x.reversedAnchor;
axes.x.anchor = axes.x.anchor + diff;
}
const { max, size, anchor, reversedAnchor, offset } = axes[axis];
const options = [
max - spacing - size - anchor >= 0 ? anchor - offset : false,
reversedAnchor - size >= 0 ? reversedAnchor - size + offset : false,
max - spacing - size
];
const topAlignment = menuAlignment === "top" || menuAlignment === "top-end" || menuAlignment === "top-start";
if (typeof options[0] === "number" && topAlignment && options[0] >= 0 && !options[1] && axis === "y") menu.current.style.transform = "translate(0)";
else if (topAlignment && !options[0] && axis === "y") options[0] = anchor - offset;
const bestOption = options.find((option) => option !== false);
return bestOption >= spacing ? bestOption : spacing;
}
function notEmpty(value) {
return value !== null && value !== void 0;
}
function getPosition(x) {
if (Array.isArray(x)) {
const filtered = x.filter(notEmpty);
if (filtered.length === 2) return filtered;
else return;
} else return [x, x];
}
function calculatePosition() {
const ranges = {
x: getPosition(x),
y: getPosition(y)
};
if (!ranges.x || !ranges.y) return [-1, -1];
return [fitValue(ranges.x, "x") ?? -1, fitValue(ranges.y, "y") ?? -1];
}
(0, react.useEffect)(() => {
if (open) {
const raf = requestAnimationFrame(() => {
const activeElement = menu.current?.ownerDocument.activeElement;
const menuContainsFocus = activeElement instanceof Node && menu.current?.contains(activeElement);
if (focusableItems.length > 0 && (!isRoot || menuContainsFocus)) focusItem();
});
return () => cancelAnimationFrame(raf);
}
}, [
open,
focusableItems,
isRoot,
position
]);
(0, react.useEffect)(() => {
if (open) handleOpen();
else setPosition([-1, -1]);
}, [open]);
const classNames = (0, classnames.default)(className, `${prefix}--menu`, `${prefix}--menu--${menuSize}`, {
[`${prefix}--menu--box-shadow-top`]: menuAlignment && menuAlignment.slice(0, 3) === "top",
[`${prefix}--menu--open`]: open,
[`${prefix}--menu--shown`]: open && !legacyAutoalign || position[0] >= 0 && position[1] >= 0,
[`${prefix}--menu--with-icons`]: childContext.state.hasIcons,
[`${prefix}--menu--with-selectable-items`]: childContext.state.hasSelectableItems,
[`${prefix}--autoalign`]: !legacyAutoalign,
[`${prefix}--menu--border`]: border,
[`${prefix}--menu--background-token__background`]: backgroundToken === "background"
});
const rendered = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_MenuContext.MenuContext.Provider, {
value: childContext,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
...rest,
className: classNames,
role: "menu",
ref,
"aria-label": label,
tabIndex: -1,
onKeyDown: handleKeyDown,
onBlur: handleBlur,
children
})
});
if (!target) return rendered;
return isRoot ? open && (0, react_dom.createPortal)(rendered, target) || null : rendered;
});
Menu.propTypes = {
backgroundToken: prop_types.default.oneOf(["layer", "background"]),
border: prop_types.default.bool,
children: prop_types.default.node,
className: prop_types.default.string,
label: prop_types.default.string,
menuAlignment: prop_types.default.string,
mode: require_deprecate.deprecate(prop_types.default.oneOf(["full", "basic"]), "Menus now always support both icons as well as selectable items and nesting."),
onClose: prop_types.default.func,
onOpen: prop_types.default.func,
open: prop_types.default.bool,
size: prop_types.default.oneOf([
"xs",
"sm",
"md",
"lg"
]),
target: prop_types.default.object,
x: prop_types.default.oneOfType([prop_types.default.number, prop_types.default.arrayOf(prop_types.default.number)]),
y: prop_types.default.oneOfType([prop_types.default.number, prop_types.default.arrayOf(prop_types.default.number)]),
legacyAutoalign: prop_types.default.bool
};
//#endregion
exports.Menu = Menu;