@carbon/react
Version:
React components for the Carbon Design System
145 lines (143 loc) • 5.6 kB
JavaScript
/**
* 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 };