@primer/react
Version:
An implementation of GitHub's Primer Design System using React
295 lines (288 loc) • 12.4 kB
JavaScript
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 };