@carbon/react
Version:
React components for the Carbon Design System
238 lines (236 loc) • 9.01 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 { usePrefix } from "../../internal/usePrefix.js";
import { ArrowDown, ArrowLeft, ArrowUp, Escape, Tab } from "../../internal/keyboard/keys.js";
import { match } from "../../internal/keyboard/match.js";
import { deprecate } from "../../prop-types/deprecate.js";
import { useMergedRefs } from "../../internal/useMergedRefs.js";
import { MenuContext, menuReducer } from "./MenuContext.js";
import { useLayoutDirection } from "../LayoutDirection/useLayoutDirection.js";
import { canUseDOM } from "../../internal/environment.js";
import classNames from "classnames";
import { forwardRef, useContext, useEffect, useMemo, useReducer, useRef, useState } from "react";
import PropTypes from "prop-types";
import { jsx } from "react/jsx-runtime";
import { createPortal } from "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 = forwardRef(function Menu({ backgroundToken = "layer", border = false, children, className, containerRef, label, menuAlignment, onClose, onOpen, open, size = "sm", legacyAutoalign = true, target = canUseDOM && document.body, x = 0, y = 0, ...rest }, forwardRef) {
const prefix = usePrefix();
const focusReturn = useRef(null);
const context = useContext(MenuContext);
const isRoot = context.state.isRoot;
const menuSize = isRoot ? size : context.state.size;
const [childState, childDispatch] = useReducer(menuReducer, {
...context.state,
isRoot: false,
hasIcons: false,
hasSelectableItems: false,
size,
requestCloseRoot: isRoot ? handleClose : context.state.requestCloseRoot
});
const childContext = useMemo(() => {
return {
state: childState,
dispatch: childDispatch
};
}, [childState, childDispatch]);
const menu = useRef(null);
const ref = useMergedRefs([forwardRef, menu]);
const [position, setPosition] = useState([-1, -1]);
const focusableItems = 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 } = 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 ((match(e, Escape) || match(e, Tab) || !isRoot && match(e, 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 (match(e, ArrowUp)) indexToFocus = indexToFocus - 1;
if (match(e, 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];
}
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
]);
useEffect(() => {
if (open) handleOpen();
else setPosition([-1, -1]);
}, [open]);
const classNames$1 = classNames(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__ */ jsx(MenuContext.Provider, {
value: childContext,
children: /* @__PURE__ */ jsx("ul", {
...rest,
className: classNames$1,
role: "menu",
ref,
"aria-label": label,
tabIndex: -1,
onKeyDown: handleKeyDown,
onBlur: handleBlur,
children
})
});
if (!target) return rendered;
return isRoot ? open && createPortal(rendered, target) || null : rendered;
});
Menu.propTypes = {
backgroundToken: PropTypes.oneOf(["layer", "background"]),
border: PropTypes.bool,
children: PropTypes.node,
className: PropTypes.string,
label: PropTypes.string,
menuAlignment: PropTypes.string,
mode: deprecate(PropTypes.oneOf(["full", "basic"]), "Menus now always support both icons as well as selectable items and nesting."),
onClose: PropTypes.func,
onOpen: PropTypes.func,
open: PropTypes.bool,
size: PropTypes.oneOf([
"xs",
"sm",
"md",
"lg"
]),
target: PropTypes.object,
x: PropTypes.oneOfType([PropTypes.number, PropTypes.arrayOf(PropTypes.number)]),
y: PropTypes.oneOfType([PropTypes.number, PropTypes.arrayOf(PropTypes.number)]),
legacyAutoalign: PropTypes.bool
};
//#endregion
export { Menu };