UNPKG

@carbon/react

Version:

React components for the Carbon Design System

238 lines (236 loc) 9.01 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 { 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 };