UNPKG

@carbon/react

Version:

React components for the Carbon Design System

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