UNPKG

@carbon/react

Version:

React components for the Carbon Design System

145 lines (143 loc) 5.6 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 { Enter, Escape, Space } from "../../internal/keyboard/keys.js"; import { matches } from "../../internal/keyboard/match.js"; import { deprecate } from "../../prop-types/deprecate.js"; import { useMergedRefs } from "../../internal/useMergedRefs.js"; import { composeEventHandlers } from "../../tools/events.js"; import { AriaLabelPropType } from "../../prop-types/AriaPropTypes.js"; import classNames from "classnames"; import { Children, cloneElement, forwardRef, isValidElement, useContext, useRef, useState } from "react"; import PropTypes from "prop-types"; import { jsx, jsxs } from "react/jsx-runtime"; import { ChevronDown } from "@carbon/icons-react"; const HeaderMenu = forwardRef((props, ref) => { const { isActive, isCurrentPage, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: customClassName, children, renderMenuContent: MenuContent, menuLinkName, focusRef, onBlur, onClick, onKeyDown, ...rest } = props; const prefix = useContext(PrefixContext); const [expanded, setExpanded] = useState(false); const menuButtonRef = useRef(null); const subMenusRef = useRef(null); const itemRefs = useRef([]); const mergedButtonRef = useMergedRefs([ ref, focusRef, menuButtonRef ]); /** * Toggle the expanded state of the menu on click. */ const handleOnClick = (e) => { if (!subMenusRef.current || e.target instanceof Node && !subMenusRef.current.contains(e.target)) e.preventDefault(); setExpanded((prev) => !prev); }; /** * Keyboard event handler for the entire menu. */ const handleOnKeyDown = (event) => { if (matches(event, [Enter, Space])) { event.stopPropagation(); event.preventDefault(); setExpanded((prev) => !prev); return; } }; /** * Handle our blur event from our underlying menuitems. Will mostly be used * for closing our menu in response to a user clicking off or tabbing out of * the menu or menubar. */ const handleOnBlur = (event) => { const siblingItemBlurredTo = itemRefs.current.find((element) => element === event.relatedTarget); const childItemBlurredTo = subMenusRef.current?.contains(event.relatedTarget); if (!siblingItemBlurredTo && !childItemBlurredTo) setExpanded(false); }; /** * Handles individual menuitem refs. We assign them to a class instance * property so that we can properly manage focus of our children. */ const handleItemRef = (index) => (node) => { itemRefs.current[index] = node; }; const handleMenuClose = (event) => { if (matches(event, [Escape]) && expanded) { event.stopPropagation(); event.preventDefault(); setExpanded(false); if (menuButtonRef.current) menuButtonRef.current.focus(); } }; const hasActiveDescendant = (childrenArg) => Children.toArray(childrenArg).some((child) => { if (!isValidElement(child)) return false; const { isActive, isCurrentPage, children } = child.props; return isActive || isCurrentPage || Array.isArray(children) && hasActiveDescendant(children); }); /** * We capture the `ref` for each child inside of `this.items` to properly * manage focus. In addition to this focus management, all items receive a * `tabIndex: -1` so the user won't hit a large number of items in their tab * sequence when they might not want to go through all the items. */ const renderMenuItem = (item, index) => { if (isValidElement(item)) return cloneElement(item, { ref: handleItemRef(index) }); return item; }; const accessibilityLabel = { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy }; const itemClassName = classNames({ [`${prefix}--header__submenu`]: true, [`${customClassName}`]: !!customClassName }); const isActivePage = isActive ? isActive : isCurrentPage; const linkClassName = classNames({ [`${prefix}--header__menu-item`]: true, [`${prefix}--header__menu-title`]: true, [`${prefix}--header__menu-item--current`]: isActivePage || hasActiveDescendant(children) && !expanded }); return /* @__PURE__ */ jsxs("li", { ...rest, className: itemClassName, onKeyDown: composeEventHandlers([onKeyDown, handleMenuClose]), onClick: composeEventHandlers([onClick, handleOnClick]), onBlur: composeEventHandlers([onBlur, handleOnBlur]), ref, children: [/* @__PURE__ */ jsxs("a", { "aria-haspopup": "menu", "aria-expanded": expanded, className: linkClassName, href: "#", onKeyDown: handleOnKeyDown, ref: mergedButtonRef, tabIndex: 0, ...accessibilityLabel, children: [menuLinkName, MenuContent ? /* @__PURE__ */ jsx(MenuContent, {}) : /* @__PURE__ */ jsx(ChevronDown, { className: `${prefix}--header__menu-arrow` })] }), /* @__PURE__ */ jsx("ul", { ...accessibilityLabel, ref: subMenusRef, className: `${prefix}--header__menu`, children: Children.map(children, renderMenuItem) })] }); }); HeaderMenu.displayName = "HeaderMenu"; HeaderMenu.propTypes = { ...AriaLabelPropType, className: PropTypes.string, focusRef: PropTypes.func, isActive: PropTypes.bool, isCurrentPage: deprecate(PropTypes.bool, "The `isCurrentPage` prop for `HeaderMenu` has been deprecated. Please use `isActive` instead. This will be removed in the next major release."), menuLinkName: PropTypes.string.isRequired, onBlur: PropTypes.func, onClick: PropTypes.func, onKeyDown: PropTypes.func, renderMenuContent: PropTypes.func, tabIndex: PropTypes.number }; //#endregion export { HeaderMenu as default };