123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- import {forwardRef, Fragment, useContext, useEffect, useRef} from 'react';
- import {useHover, useKeyboard} from '@react-aria/interactions';
- import {useMenuItem} from '@react-aria/menu';
- import {mergeProps} from '@react-aria/utils';
- import type {TreeState} from '@react-stately/tree';
- import type {Node} from '@react-types/shared';
- import type {LocationDescriptor} from 'history';
- import Link from 'sentry/components/links/link';
- import type {MenuListItemProps} from 'sentry/components/menuListItem';
- import MenuListItem, {
- InnerWrap as MenuListItemInnerWrap,
- } from 'sentry/components/menuListItem';
- import {IconChevron} from 'sentry/icons';
- import mergeRefs from 'sentry/utils/mergeRefs';
- import usePrevious from 'sentry/utils/usePrevious';
- import {DropdownMenuContext} from './list';
- export interface MenuItemProps extends MenuListItemProps {
- /**
- * Item key. Must be unique across the entire menu, including sub-menus.
- */
- key: string;
- /**
- * Sub-items that are nested inside this item. By default, sub-items are
- * rendered collectively as menu sections inside the current menu. If
- * `isSubmenu` is true, then they will be rendered together in a sub-menu.
- */
- children?: MenuItemProps[];
- /**
- * Pass a class name to the menu item.
- */
- className?: string;
- /**
- * Hide item from the dropdown menu. Note: this will also remove the item
- * from the selection manager.
- */
- hidden?: boolean;
- /**
- * Whether this menu item is a trigger for a nested sub-menu. Only works
- * when `children` is also defined.
- */
- isSubmenu?: boolean;
- /**
- * Menu item label. Should preferably be a string. If not, provide a `textValue` prop
- * to enable search & keyboard select.
- */
- label?: MenuListItemProps['label'];
- /**
- * Function to call when user selects/clicks/taps on the menu item. The
- * item's key is passed as an argument.
- */
- onAction?: () => void;
- /**
- * Passed as the `menuTitle` prop onto the associated sub-menu (applicable
- * if `children` is defined and `isSubmenu` is true)
- */
- submenuTitle?: string;
- /**
- * A plain text version of the `label` prop if the label is not a string. Used for
- * filtering and keyboard select (quick-focusing on options by typing the first letter).
- */
- textValue?: string;
- /**
- * Destination if this menu item is a link.
- */
- to?: LocationDescriptor;
- }
- interface DropdownMenuItemProps {
- /**
- * Whether to close the menu when an item has been clicked/selected
- */
- closeOnSelect: boolean;
- /**
- * Node representation (from @react-aria) of the item
- */
- node: Node<MenuItemProps>;
- /**
- * Tree state (from @react-stately) inherited from parent menu
- */
- state: TreeState<MenuItemProps>;
- /**
- * Handler that is called when the menu should close after selecting an item
- */
- onClose?: () => void;
- /**
- * Tag name for item wrapper
- */
- renderAs?: React.ElementType;
- /**
- * Whether to show a divider below this item
- */
- showDivider?: boolean;
- }
- /**
- * A menu item with a label, optional details, leading and trailing elements.
- * Can also be used as a trigger button for a submenu. See:
- * https://react-spectrum.adobe.com/react-aria/useMenu.html
- */
- function BaseDropdownMenuItem(
- {
- node,
- state,
- closeOnSelect,
- onClose,
- showDivider,
- renderAs = 'li',
- ...props
- }: DropdownMenuItemProps,
- forwardedRef: React.Ref<HTMLLIElement>
- ) {
- const ref = useRef<HTMLLIElement | null>(null);
- const isDisabled = state.disabledKeys.has(node.key);
- const isFocused = state.selectionManager.focusedKey === node.key;
- const {key, onAction, to, label, isSubmenu, trailingItems, ...itemProps} =
- node.value ?? {};
- const {size} = node.props;
- const actionHandler = () => {
- if (to) {
- return;
- }
- if (isSubmenu) {
- state.selectionManager.toggleSelection(node.key);
- return;
- }
- onAction?.();
- };
- // Open submenu on hover
- const {hoverProps, isHovered} = useHover({});
- const prevIsHovered = usePrevious(isHovered);
- const prevIsFocused = usePrevious(isFocused);
- useEffect(() => {
- if (isHovered === prevIsHovered && isFocused === prevIsFocused) {
- return;
- }
- if (isHovered && isFocused) {
- if (isSubmenu) {
- state.selectionManager.replaceSelection(node.key);
- return;
- }
- state.selectionManager.clearSelection();
- }
- }, [
- isHovered,
- isFocused,
- prevIsHovered,
- prevIsFocused,
- isSubmenu,
- node.key,
- state.selectionManager,
- ]);
- // Open submenu on arrow right key press
- const {keyboardProps} = useKeyboard({
- onKeyDown: e => {
- if (e.key === 'Enter' && to) {
- const mouseEvent = new MouseEvent('click', {
- ctrlKey: e.ctrlKey,
- metaKey: e.metaKey,
- });
- ref.current?.querySelector(`${MenuListItemInnerWrap}`)?.dispatchEvent(mouseEvent);
- return;
- }
- if (e.key === 'ArrowRight' && isSubmenu) {
- state.selectionManager.replaceSelection(node.key);
- return;
- }
- e.continuePropagation();
- },
- });
- // Manage interactive events & create aria attributes
- const {rootOverlayState} = useContext(DropdownMenuContext);
- const {menuItemProps, labelProps, descriptionProps} = useMenuItem(
- {
- key: node.key,
- onAction: actionHandler,
- onClose: () => {
- onClose?.();
- rootOverlayState?.close();
- },
- closeOnSelect: to ? false : closeOnSelect,
- isDisabled,
- },
- state,
- ref
- );
- // Merged menu item props, class names are combined, event handlers chained,
- // etc. See: https://react-spectrum.adobe.com/react-aria/mergeProps.html
- const mergedProps = mergeProps(props, menuItemProps, hoverProps, keyboardProps);
- const itemLabel = node.rendered ?? label;
- const innerWrapProps = {as: to ? Link : 'div', to};
- return (
- <MenuListItem
- ref={mergeRefs([ref, forwardedRef])}
- as={renderAs}
- data-test-id={key}
- label={itemLabel}
- disabled={isDisabled}
- isFocused={isFocused}
- showDivider={showDivider}
- innerWrapProps={innerWrapProps}
- labelProps={labelProps}
- detailsProps={descriptionProps}
- trailingItems={
- isSubmenu ? (
- <Fragment>
- {trailingItems as React.ReactNode}
- <IconChevron size="xs" direction="right" aria-hidden="true" />
- </Fragment>
- ) : (
- trailingItems
- )
- }
- size={size}
- {...mergedProps}
- {...itemProps}
- />
- );
- }
- const DropdownMenuItem = forwardRef(BaseDropdownMenuItem);
- export default DropdownMenuItem;
|