UNPKG

@carbon/react

Version:

React components for the Carbon Design System

229 lines (218 loc) 8.2 kB
/** * 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 };