UNPKG

@carbon/react

Version:

React components for the Carbon Design System

302 lines (300 loc) 10 kB
/** * 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 };