@carbon/react
Version:
React components for the Carbon Design System
302 lines (300 loc) • 10 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 { Text } from "../Text/Text.js";
import { ArrowRight as ArrowRight$1, Enter, Space } from "../../internal/keyboard/keys.js";
import { match } from "../../internal/keyboard/match.js";
import { useId } from "../../internal/useId.js";
import { defaultItemToString } from "../../internal/defaultItemToString.js";
import { useMergedRefs } from "../../internal/useMergedRefs.js";
import { MenuContext } from "./MenuContext.js";
import { useLayoutDirection } from "../LayoutDirection/useLayoutDirection.js";
import { Menu as Menu$1 } from "./Menu.js";
import { useControllableState } from "../../internal/useControllableState.js";
import classNames from "classnames";
import React, { forwardRef, useContext, useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { CaretLeft, CaretRight, Checkmark } from "@carbon/icons-react";
import { FloatingFocusManager, autoUpdate, offset, safePolygon, useFloating, useHover, useInteractions } from "@floating-ui/react";
//#region src/components/Menu/MenuItem.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 MenuItem = forwardRef(function MenuItem({ children, className, dangerDescription = "danger", disabled, kind = "default", label, onClick, renderIcon: IconElement, shortcut, ...rest }, forwardRef) {
const [submenuOpen, setSubmenuOpen] = useState(false);
const [rtl, setRtl] = useState(false);
const { refs, floatingStyles, context: floatingContext } = useFloating({
open: submenuOpen,
onOpenChange: setSubmenuOpen,
placement: rtl ? "left-start" : "right-start",
whileElementsMounted: autoUpdate,
middleware: [offset({
mainAxis: -6,
crossAxis: -6
})],
strategy: "fixed"
});
const { getReferenceProps, getFloatingProps } = useInteractions([useHover(floatingContext, {
delay: 100,
enabled: true,
handleClose: safePolygon({ requireIntent: false })
})]);
const prefix = usePrefix();
const context = useContext(MenuContext);
const menuItem = useRef(null);
const ref = useMergedRefs([
forwardRef,
menuItem,
refs.setReference
]);
const hasChildren = React.Children.toArray(children).length > 0;
const isDisabled = disabled && !hasChildren;
const isDanger = kind === "danger" && !hasChildren;
function registerItem() {
context.dispatch({
type: "registerItem",
payload: {
ref: menuItem,
disabled: disabled ?? false
}
});
}
function openSubmenu() {
if (!menuItem.current) return;
setSubmenuOpen(true);
}
function closeSubmenu() {
setSubmenuOpen(false);
}
function handleClick(e) {
if (!isDisabled) if (hasChildren) openSubmenu();
else {
context.state.requestCloseRoot(e);
if (onClick) onClick(e);
}
}
const pendingKeyboardClick = useRef(false);
const keyboardClickEvent = (e) => match(e, Enter) || match(e, Space);
function handleKeyDown(e) {
if (hasChildren && match(e, ArrowRight$1)) {
openSubmenu();
requestAnimationFrame(() => {
refs.floating.current?.focus();
});
e.stopPropagation();
e.preventDefault();
}
pendingKeyboardClick.current = keyboardClickEvent(e);
if (rest.onKeyDown) rest.onKeyDown(e);
}
function handleKeyUp(e) {
if (pendingKeyboardClick.current && keyboardClickEvent(e)) handleClick(e);
pendingKeyboardClick.current = false;
}
const classNames$1 = classNames(className, `${prefix}--menu-item`, {
[`${prefix}--menu-item--disabled`]: isDisabled,
[`${prefix}--menu-item--danger`]: isDanger
});
useEffect(() => {
registerItem();
}, []);
const { direction } = useLayoutDirection();
useEffect(() => {
if (document?.dir === "rtl" || direction === "rtl") setRtl(true);
else setRtl(false);
}, [direction]);
useEffect(() => {
if (IconElement && !context.state.hasIcons) context.dispatch({ type: "enableIcons" });
}, [
IconElement,
context.state.hasIcons,
context
]);
useEffect(() => {
Object.keys(floatingStyles).forEach((style) => {
if (refs.floating.current && style !== "position") refs.floating.current.style[style] = floatingStyles[style];
});
}, [floatingStyles, refs.floating]);
const assistiveId = useId("danger-description");
return /* @__PURE__ */ jsx(FloatingFocusManager, {
context: floatingContext,
order: ["reference", "floating"],
modal: false,
children: /* @__PURE__ */ jsxs("li", {
role: "menuitem",
...rest,
ref,
className: classNames$1,
tabIndex: !disabled ? 0 : -1,
"aria-disabled": isDisabled ?? void 0,
"aria-haspopup": hasChildren ?? void 0,
"aria-expanded": hasChildren ? submenuOpen : void 0,
onClick: handleClick,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
title: label,
...getReferenceProps(),
children: [
/* @__PURE__ */ jsx("div", {
className: `${prefix}--menu-item__selection-icon`,
children: rest["aria-checked"] && /* @__PURE__ */ jsx(Checkmark, {})
}),
/* @__PURE__ */ jsx("div", {
className: `${prefix}--menu-item__icon`,
children: IconElement && /* @__PURE__ */ jsx(IconElement, {})
}),
/* @__PURE__ */ jsx(Text, {
as: "div",
className: `${prefix}--menu-item__label`,
children: label
}),
isDanger && /* @__PURE__ */ jsx("span", {
id: assistiveId,
className: `${prefix}--visually-hidden`,
children: dangerDescription
}),
shortcut && !hasChildren && /* @__PURE__ */ jsx("div", {
className: `${prefix}--menu-item__shortcut`,
children: shortcut
}),
hasChildren && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
className: `${prefix}--menu-item__shortcut`,
children: rtl ? /* @__PURE__ */ jsx(CaretLeft, {}) : /* @__PURE__ */ jsx(CaretRight, {})
}), /* @__PURE__ */ jsx(Menu$1, {
label,
open: submenuOpen,
onClose: () => {
closeSubmenu();
menuItem.current?.focus();
},
ref: refs.setFloating,
...getFloatingProps(),
children
})] })
]
})
});
});
MenuItem.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
dangerDescription: PropTypes.string,
disabled: PropTypes.bool,
kind: PropTypes.oneOf(["default", "danger"]),
label: PropTypes.string.isRequired,
onClick: PropTypes.func,
renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
shortcut: PropTypes.string
};
const MenuItemSelectable = forwardRef(function MenuItemSelectable({ className, defaultSelected, label, onChange, selected, ...rest }, forwardRef) {
const prefix = usePrefix();
const context = useContext(MenuContext);
const [checked, setChecked] = useControllableState({
value: selected,
onChange,
defaultValue: defaultSelected ?? false
});
function handleClick() {
setChecked(!checked);
}
useEffect(() => {
if (!context.state.hasSelectableItems) context.dispatch({ type: "enableSelectableItems" });
}, [context.state.hasSelectableItems, context]);
const classNames$2 = classNames(className, `${prefix}--menu-item-selectable--selected`);
return /* @__PURE__ */ jsx(MenuItem, {
...rest,
ref: forwardRef,
label,
className: classNames$2,
role: "menuitemcheckbox",
"aria-checked": checked,
onClick: handleClick
});
});
MenuItemSelectable.propTypes = {
className: PropTypes.string,
defaultSelected: PropTypes.bool,
label: PropTypes.string.isRequired,
onChange: PropTypes.func,
selected: PropTypes.bool
};
const MenuItemGroup = forwardRef(function MenuItemGroup({ children, className, label, ...rest }, forwardRef) {
return /* @__PURE__ */ jsx("li", {
className: classNames(className, `${usePrefix()}--menu-item-group`),
role: "none",
ref: forwardRef,
children: /* @__PURE__ */ jsx("ul", {
...rest,
role: "group",
"aria-label": label,
children
})
});
});
MenuItemGroup.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
label: PropTypes.string.isRequired
};
const MenuItemRadioGroup = forwardRef(function MenuItemRadioGroup({ className, defaultSelectedItem, items, itemToString = defaultItemToString, label, onChange, selectedItem, ...rest }, forwardRef) {
const prefix = usePrefix();
const context = useContext(MenuContext);
const [selection, setSelection] = useControllableState({
value: selectedItem,
onChange,
defaultValue: defaultSelectedItem ?? {}
});
function handleClick(item) {
setSelection(item);
}
useEffect(() => {
if (!context.state.hasSelectableItems) context.dispatch({ type: "enableSelectableItems" });
}, [context.state.hasSelectableItems, context]);
return /* @__PURE__ */ jsx("li", {
className: classNames(className, `${prefix}--menu-item-radio-group`),
role: "none",
ref: forwardRef,
children: /* @__PURE__ */ jsx("ul", {
...rest,
role: "group",
"aria-label": label,
children: items.map((item, i) => /* @__PURE__ */ jsx(MenuItem, {
label: itemToString(item),
role: "menuitemradio",
"aria-checked": item === selection,
onClick: () => {
handleClick(item);
}
}, i))
})
});
});
MenuItemRadioGroup.propTypes = {
className: PropTypes.string,
defaultSelectedItem: PropTypes.any,
itemToString: PropTypes.func,
items: PropTypes.array,
label: PropTypes.string.isRequired,
onChange: PropTypes.func,
selectedItem: PropTypes.any
};
const MenuItemDivider = forwardRef(function MenuItemDivider({ className, ...rest }, forwardRef) {
const classNames$3 = classNames(className, `${usePrefix()}--menu-item-divider`);
return /* @__PURE__ */ jsx("li", {
...rest,
className: classNames$3,
role: "separator",
ref: forwardRef
});
});
MenuItemDivider.propTypes = { className: PropTypes.string };
//#endregion
export { MenuItem, MenuItemDivider, MenuItemGroup, MenuItemRadioGroup, MenuItemSelectable };