UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

295 lines (288 loc) 12.4 kB
import React, { useContext, useState, useEffect, useCallback, useMemo } from 'react'; import { ChevronRightIcon, TriangleDownIcon } from '@primer/octicons-react'; import { Divider } from '../ActionList/Divider.js'; import { ActionListContainerContext } from '../ActionList/ActionListContainerContext.js'; import { useId } from '../hooks/useId.js'; import { Tooltip } from '../TooltipV2/Tooltip.js'; import styles from './ActionMenu.module.css.js'; import { useResponsiveValue } from '../hooks/useResponsiveValue.js'; import { jsx } from 'react/jsx-runtime'; import { useProvidedStateOrCreate } from '../hooks/useProvidedStateOrCreate.js'; import { useProvidedRefOrCreate } from '../hooks/useProvidedRefOrCreate.js'; import { useMenuKeyboardNavigation } from '../hooks/useMenuKeyboardNavigation.js'; import { AnchoredOverlay } from '../AnchoredOverlay/AnchoredOverlay.js'; import { ButtonComponent } from '../Button/Button.js'; const MenuContext = /*#__PURE__*/React.createContext({ renderAnchor: null, open: false }); // anchorProps adds onClick and onKeyDown, so we need to merge them with buttonProps const mergeAnchorHandlers = (anchorProps, buttonProps) => { const mergedAnchorProps = { ...anchorProps }; if (typeof buttonProps.onClick === 'function') { const anchorOnClick = anchorProps.onClick; const mergedOnClick = event => { var _buttonProps$onClick; (_buttonProps$onClick = buttonProps.onClick) === null || _buttonProps$onClick === void 0 ? void 0 : _buttonProps$onClick.call(buttonProps, event); anchorOnClick === null || anchorOnClick === void 0 ? void 0 : anchorOnClick(event); }; mergedAnchorProps.onClick = mergedOnClick; } if (typeof buttonProps.onKeyDown === 'function') { const anchorOnKeyDown = anchorProps.onKeyDown; const mergedOnAnchorKeyDown = event => { var _buttonProps$onKeyDow; (_buttonProps$onKeyDow = buttonProps.onKeyDown) === null || _buttonProps$onKeyDow === void 0 ? void 0 : _buttonProps$onKeyDow.call(buttonProps, event); anchorOnKeyDown === null || anchorOnKeyDown === void 0 ? void 0 : anchorOnKeyDown(event); }; mergedAnchorProps.onKeyDown = mergedOnAnchorKeyDown; } return mergedAnchorProps; }; const Menu = ({ anchorRef: externalAnchorRef, open, onOpenChange, children }) => { const parentMenuContext = useContext(MenuContext); const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false); const onOpen = React.useCallback(() => setCombinedOpenState(true), [setCombinedOpenState]); const isNarrow = useResponsiveValue({ narrow: true }, false); const onClose = React.useCallback(gesture => { var _parentMenuContext$on; if (isNarrow && open && gesture === 'tab') { return; } setCombinedOpenState(false); // Close the parent stack when an item is selected or the user tabs out of the menu entirely switch (gesture) { case 'tab': case 'item-select': (_parentMenuContext$on = parentMenuContext.onClose) === null || _parentMenuContext$on === void 0 ? void 0 : _parentMenuContext$on.call(parentMenuContext, gesture); } }, [setCombinedOpenState, parentMenuContext, open, isNarrow]); const menuButtonChild = React.Children.toArray(children).find(child => /*#__PURE__*/React.isValidElement(child) && (child.type === MenuButton || child.type === Anchor)); const menuButtonChildId = /*#__PURE__*/React.isValidElement(menuButtonChild) ? menuButtonChild.props.id : undefined; const anchorRef = useProvidedRefOrCreate(externalAnchorRef); const anchorId = useId(menuButtonChildId); let renderAnchor = null; // 🚨 Hack for good API! // we strip out Anchor from children and pass it to AnchoredOverlay to render // with additional props for accessibility // 🚨 Accounting for Tooltip wrapping ActionMenu.Button or being a direct child of ActionMenu.Anchor. const contents = React.Children.map(children, child => { // Is ActionMenu.Button wrapped with Tooltip? If this is the case, our anchor is the tooltip's trigger (ActionMenu.Button's grandchild) if (child.type === Tooltip) { // tooltip trigger const anchorChildren = child.props.children; if (anchorChildren.type === MenuButton) { // eslint-disable-next-line react-compiler/react-compiler renderAnchor = anchorProps => { // We need to attach the anchor props to the tooltip trigger (ActionMenu.Button's grandchild) not the tooltip itself. const triggerButton = /*#__PURE__*/React.cloneElement(anchorChildren, mergeAnchorHandlers({ ...anchorProps }, anchorChildren.props)); return /*#__PURE__*/React.cloneElement(child, { children: triggerButton, ref: anchorRef }); }; } return null; } else if (child.type === Anchor) { const anchorChildren = child.props.children; const isWrappedWithTooltip = anchorChildren !== undefined ? anchorChildren.type === Tooltip : false; if (isWrappedWithTooltip) { if (anchorChildren.props.children !== null) { renderAnchor = anchorProps => { // ActionMenu.Anchor's children can be wrapped with Tooltip. If this is the case, our anchor is the tooltip's trigger const tooltipTrigger = anchorChildren.props.children; // We need to attach the anchor props to the tooltip trigger not the tooltip itself. const tooltipTriggerEl = /*#__PURE__*/React.cloneElement(tooltipTrigger, mergeAnchorHandlers({ ...anchorProps }, tooltipTrigger.props)); const tooltip = /*#__PURE__*/React.cloneElement(anchorChildren, { children: tooltipTriggerEl }); return /*#__PURE__*/React.cloneElement(child, { children: tooltip, ref: anchorRef }); }; } } else { renderAnchor = anchorProps => /*#__PURE__*/React.cloneElement(child, anchorProps); } return null; } else if (child.type === MenuButton) { renderAnchor = anchorProps => /*#__PURE__*/React.cloneElement(child, mergeAnchorHandlers(anchorProps, child.props)); return null; } else { return child; } }); return /*#__PURE__*/jsx(MenuContext.Provider, { value: { anchorRef, renderAnchor, anchorId, open: combinedOpenState, onOpen, onClose, // will be undefined for the outermost level, then false for the top menu, then true inside that isSubmenu: parentMenuContext.isSubmenu !== undefined }, children: contents }); }; Menu.displayName = "Menu"; const Anchor = /*#__PURE__*/React.forwardRef(({ children: child, ...anchorProps }, anchorRef) => { const { onOpen, isSubmenu } = React.useContext(MenuContext); const openSubmenuOnRightArrow = useCallback(event => { if (isSubmenu && event.key === 'ArrowRight' && !event.defaultPrevented) onOpen === null || onOpen === void 0 ? void 0 : onOpen('anchor-key-press'); }, [isSubmenu, onOpen]); const onButtonClick = event => { var _child$props$onClick, _child$props, _anchorProps$onClick; (_child$props$onClick = (_child$props = child.props).onClick) === null || _child$props$onClick === void 0 ? void 0 : _child$props$onClick.call(_child$props, event); (_anchorProps$onClick = anchorProps.onClick) === null || _anchorProps$onClick === void 0 ? void 0 : _anchorProps$onClick.call(anchorProps, event); // onClick is passed from AnchoredOverlay }; const onButtonKeyDown = event => { var _child$props$onKeyDow, _child$props2, _anchorProps$onKeyDow; (_child$props$onKeyDow = (_child$props2 = child.props).onKeyDown) === null || _child$props$onKeyDow === void 0 ? void 0 : _child$props$onKeyDow.call(_child$props2, event); openSubmenuOnRightArrow(event); (_anchorProps$onKeyDow = anchorProps.onKeyDown) === null || _anchorProps$onKeyDow === void 0 ? void 0 : _anchorProps$onKeyDow.call(anchorProps, event); // onKeyDown is passed from AnchoredOverlay }; // Add right chevron icon to submenu anchors rendered using `ActionList.Item` const parentActionListContext = useContext(ActionListContainerContext); const thisActionListContext = useMemo(() => isSubmenu ? { ...parentActionListContext, defaultTrailingVisual: /*#__PURE__*/jsx(ChevronRightIcon, {}), // Default behavior is to close after selecting; we want to open the submenu instead afterSelect: () => onOpen === null || onOpen === void 0 ? void 0 : onOpen('anchor-click') } : parentActionListContext, [isSubmenu, onOpen, parentActionListContext]); return /*#__PURE__*/jsx(ActionListContainerContext.Provider, { value: thisActionListContext, children: /*#__PURE__*/React.cloneElement(child, { ...anchorProps, ref: anchorRef, onClick: onButtonClick, onKeyDown: onButtonKeyDown }) }); }); /** this component is syntactical sugar 🍭 */ const MenuButton = /*#__PURE__*/React.forwardRef(({ ...props }, anchorRef) => { return /*#__PURE__*/jsx(Anchor, { ref: anchorRef, children: /*#__PURE__*/jsx(ButtonComponent, { type: "button", trailingAction: TriangleDownIcon, ...props }) }); }); const defaultVariant = { regular: 'anchored', narrow: 'anchored' }; const Overlay = ({ children, align = 'start', side, onPositionChange, 'aria-labelledby': ariaLabelledby, variant = defaultVariant, ...overlayProps }) => { // we typecast anchorRef as required instead of optional // because we know that we're setting it in context in Menu const { anchorRef, renderAnchor, anchorId, open, onOpen, onClose, isSubmenu = false } = React.useContext(MenuContext); const containerRef = React.useRef(null); useMenuKeyboardNavigation(open, onClose, containerRef, anchorRef, isSubmenu); const isNarrow = useResponsiveValue({ narrow: true }, false); const responsiveVariant = useResponsiveValue(variant, { regular: 'anchored', narrow: 'anchored' }); const isNarrowFullscreen = !!isNarrow && variant.narrow === 'fullscreen'; // If the menu anchor is an icon button, we need to label the menu by tooltip that also labelled the anchor. const [anchorAriaLabelledby, setAnchorAriaLabelledby] = useState(null); useEffect(() => { // Necessary for HMR reloads // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (anchorRef !== null && anchorRef !== void 0 && anchorRef.current) { const ariaLabelledby = anchorRef.current.getAttribute('aria-labelledby'); if (ariaLabelledby) { setAnchorAriaLabelledby(ariaLabelledby); } } }, [anchorRef]); return /*#__PURE__*/jsx(AnchoredOverlay, { anchorRef: anchorRef, renderAnchor: renderAnchor, anchorId: anchorId, open: open, onOpen: onOpen, onClose: onClose, align: align, side: side !== null && side !== void 0 ? side : isSubmenu ? 'outside-right' : 'outside-bottom', overlayProps: overlayProps, focusZoneSettings: isNarrowFullscreen ? { disabled: true } : { focusOutBehavior: 'wrap' }, onPositionChange: onPositionChange, variant: variant, children: /*#__PURE__*/jsx("div", { ref: containerRef, className: styles.ActionMenuContainer, "data-variant": responsiveVariant, children: /*#__PURE__*/jsx(ActionListContainerContext.Provider, { value: { container: 'ActionMenu', listRole: 'menu', // If there is a custom aria-labelledby, use that. Otherwise, if exists, use the id that labels the anchor such as tooltip. If none of them exist, use anchor id. listLabelledBy: ariaLabelledby || anchorAriaLabelledby || anchorId, selectionAttribute: 'aria-checked', // Should this be here? afterSelect: () => onClose === null || onClose === void 0 ? void 0 : onClose('item-select'), enableFocusZone: isNarrowFullscreen // AnchoredOverlay takes care of focus zone. We only want to enable this if menu is narrow fullscreen. }, children: children }) }) }); }; Overlay.displayName = "Overlay"; Menu.displayName = 'ActionMenu'; const ActionMenu = Object.assign(Menu, { Button: MenuButton, Anchor, Overlay, Divider }); export { ActionMenu };