UNPKG

@carbon/react

Version:

React components for the Carbon Design System

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