@carbon/react
Version:
React components for the Carbon Design System
229 lines (218 loc) • 8.2 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2023
*
* 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 { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js';
import { ChevronDown } from '@carbon/icons-react';
import cx from 'classnames';
import React, { forwardRef, useContext, useState, useRef, Children, isValidElement, cloneElement } from 'react';
import PropTypes from 'prop-types';
import { Escape, Enter, Space } from '../../internal/keyboard/keys.js';
import { matches } from '../../internal/keyboard/match.js';
import { AriaLabelPropType } from '../../prop-types/AriaPropTypes.js';
import { PrefixContext } from '../../internal/usePrefix.js';
import { deprecate } from '../../prop-types/deprecate.js';
import { composeEventHandlers } from '../../tools/events.js';
import { useMergedRefs } from '../../internal/useMergedRefs.js';
const frFn = forwardRef;
const HeaderMenu = frFn((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 => {
// Handle enter or space key for toggling the expanded state of the menu.
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 => {
// Close the menu on blur when the related target is not a sibling menu item
// or a child in a submenu
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 => {
// Handle ESC keydown for closing the expanded menu.
if (matches(event, [Escape]) && expanded) {
event.stopPropagation();
event.preventDefault();
setExpanded(false);
// Return focus to menu button when the user hits ESC.
if (menuButtonRef.current) {
menuButtonRef.current.focus();
}
}
};
const hasActiveDescendant = childrenArg => Children.toArray(childrenArg).some(child => {
if (! /*#__PURE__*/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 (/*#__PURE__*/isValidElement(item)) {
return /*#__PURE__*/cloneElement(item, {
ref: handleItemRef(index)
});
}
return item;
};
const accessibilityLabel = {
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy
};
const itemClassName = cx({
[`${prefix}--header__submenu`]: true,
[`${customClassName}`]: !!customClassName
});
const isActivePage = isActive ? isActive : isCurrentPage;
const linkClassName = cx({
[`${prefix}--header__menu-item`]: true,
[`${prefix}--header__menu-title`]: true,
// We set the current class only if `isActive` is passed in and we do
// not have an `aria-current="page"` set for the breadcrumb item
[`${prefix}--header__menu-item--current`]: isActivePage || hasActiveDescendant(children) && !expanded
});
// Notes on eslint comments and based on the examples in:
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-1/menubar-1.html#
// - The focus is handled by the <a> menuitem, onMouseOver is for mouse
// users
// - aria-haspopup can definitely have the value "menu"
// - aria-expanded is on their example node with role="menuitem"
// - href can be set to javascript:void(0), ideally this will be a button
return /*#__PURE__*/React.createElement("li", _extends({}, rest, {
className: itemClassName,
onKeyDown: composeEventHandlers([onKeyDown, handleMenuClose]),
onClick: composeEventHandlers([onClick, handleOnClick]),
onBlur: composeEventHandlers([onBlur, handleOnBlur]),
ref: ref
}), /*#__PURE__*/React.createElement("a", _extends({
// eslint-disable-line jsx-a11y/role-supports-aria-props,jsx-a11y/anchor-is-valid
"aria-haspopup": "menu" // eslint-disable-line jsx-a11y/aria-proptypes
,
"aria-expanded": expanded,
className: linkClassName,
href: "#",
onKeyDown: handleOnKeyDown,
ref: mergedButtonRef,
tabIndex: 0
}, accessibilityLabel), menuLinkName, MenuContent ? /*#__PURE__*/React.createElement(MenuContent, null) : /*#__PURE__*/React.createElement(ChevronDown, {
className: `${prefix}--header__menu-arrow`
})), /*#__PURE__*/React.createElement("ul", _extends({}, accessibilityLabel, {
ref: subMenusRef,
className: `${prefix}--header__menu`
}), Children.map(children, renderMenuItem)));
});
HeaderMenu.displayName = 'HeaderMenu';
HeaderMenu.propTypes = {
/**
* Required props for the accessibility label of the menu
*/
...AriaLabelPropType,
/**
* Optionally provide a custom class to apply to the underlying `<li>` node
*/
className: PropTypes.string,
/**
* Provide a custom ref handler for the menu button
*/
focusRef: PropTypes.func,
/**
* Applies selected styles to the item if a user sets this to true and `aria-current !== 'page'`.
*/
isActive: PropTypes.bool,
/**
* Applies selected styles to the item if a user sets this to true and `aria-current !== 'page'`.
* @deprecated Please use `isActive` instead. This will be removed in the next major release.
*/
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.'),
/**
* Provide a label for the link text
*/
menuLinkName: PropTypes.string.isRequired,
/**
* Optionally provide an onBlur handler that is called when the underlying
* button fires it's onblur event
*/
onBlur: PropTypes.func,
/**
* Optionally provide an onClick handler that is called when the underlying
* button fires it's onclick event
*/
onClick: PropTypes.func,
/**
* Optionally provide an onKeyDown handler that is called when the underlying
* button fires it's onkeydown event
*/
onKeyDown: PropTypes.func,
/**
* Optional component to render instead of string
*/
renderMenuContent: PropTypes.func,
/**
* Optionally provide a tabIndex for the underlying menu button
*/
tabIndex: PropTypes.number
};
export { HeaderMenu, HeaderMenu as default };