dropdownMenuItemV2.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import {Fragment, useEffect, useRef, useState} from 'react';
  2. import {useHover, useKeyboard} from '@react-aria/interactions';
  3. import {useMenuItem} from '@react-aria/menu';
  4. import {mergeProps} from '@react-aria/utils';
  5. import {TreeState} from '@react-stately/tree';
  6. import {Node} from '@react-types/shared';
  7. import {LocationDescriptor} from 'history';
  8. import Link from 'sentry/components/links/link';
  9. import MenuListItem, {
  10. InnerWrap as MenuListItemInnerWrap,
  11. MenuListItemProps,
  12. } from 'sentry/components/menuListItem';
  13. import {IconChevron} from 'sentry/icons';
  14. export type MenuItemProps = MenuListItemProps & {
  15. /**
  16. * Item key. Must be unique across the entire menu, including sub-menus.
  17. */
  18. key: string;
  19. /**
  20. * Sub-items that are nested inside this item. By default, sub-items are
  21. * rendered collectively as menu sections inside the current menu. If
  22. * `isSubmenu` is true, then they will be rendered together in a sub-menu.
  23. */
  24. children?: MenuItemProps[];
  25. /**
  26. * Hide item from the dropdown menu. Note: this will also remove the item
  27. * from the selection manager.
  28. */
  29. hidden?: boolean;
  30. /*
  31. * Whether this menu item is a trigger for a nested sub-menu. Only works
  32. * when `children` is also defined.
  33. */
  34. isSubmenu?: boolean;
  35. /**
  36. * Function to call when user selects/clicks/taps on the menu item. The
  37. * item's key is passed as an argument.
  38. */
  39. onAction?: (key: MenuItemProps['key']) => void;
  40. /**
  41. * Whether to show a line divider below this menu item
  42. */
  43. showDividers?: boolean;
  44. /**
  45. * Passed as the `menuTitle` prop onto the associated sub-menu (applicable
  46. * if `children` is defined and `isSubmenu` is true)
  47. */
  48. submenuTitle?: string;
  49. /**
  50. * Destination if this menu item is a link. See also: `isExternalLink`.
  51. */
  52. to?: LocationDescriptor;
  53. };
  54. type Props = {
  55. /**
  56. * Whether to close the menu when an item has been clicked/selected
  57. */
  58. closeOnSelect: boolean;
  59. /**
  60. * Whether this is the last node in the collection
  61. */
  62. isLastNode: boolean;
  63. /**
  64. * Node representation (from @react-aria) of the item
  65. */
  66. node: Node<MenuItemProps>;
  67. /**
  68. * Used to close the menu when needed (e.g. when the item is
  69. * clicked/selected)
  70. */
  71. onClose: () => void;
  72. /**
  73. * Tree state (from @react-stately) inherited from parent menu
  74. */
  75. state: TreeState<MenuItemProps>;
  76. /**
  77. * Whether this is a trigger button (displayed as a normal menu item) for a
  78. * submenu
  79. */
  80. isSubmenuTrigger?: boolean;
  81. /**
  82. * Tag name for item wrapper
  83. */
  84. renderAs?: React.ElementType;
  85. /**
  86. * If isSubmenuTrigger is true, then replace the internal ref object with
  87. * this ref
  88. */
  89. submenuTriggerRef?: React.RefObject<HTMLLIElement>;
  90. };
  91. /**
  92. * A menu item with a label, optional details, leading and trailing elements.
  93. * Can also be used as a trigger button for a submenu. See:
  94. * https://react-spectrum.adobe.com/react-aria/useMenu.html
  95. */
  96. const MenuItem = ({
  97. node,
  98. isLastNode,
  99. state,
  100. onClose,
  101. closeOnSelect,
  102. isSubmenuTrigger = false,
  103. submenuTriggerRef,
  104. renderAs = 'li' as React.ElementType,
  105. ...submenuTriggerProps
  106. }: Props) => {
  107. const [isHovering, setIsHovering] = useState(false);
  108. const ourRef = useRef(null);
  109. const isDisabled = state.disabledKeys.has(node.key);
  110. const isFocused = state.selectionManager.focusedKey === node.key;
  111. const {key, onAction, to, label, showDividers, ...itemProps} = node.value;
  112. const ref = submenuTriggerRef ?? ourRef;
  113. const actionHandler = () => {
  114. if (to) {
  115. return;
  116. }
  117. if (isSubmenuTrigger) {
  118. state.selectionManager.select(node.key);
  119. return;
  120. }
  121. onAction?.(key);
  122. };
  123. // Open submenu on hover
  124. const {hoverProps} = useHover({onHoverChange: setIsHovering});
  125. useEffect(() => {
  126. if (isHovering && isFocused) {
  127. if (isSubmenuTrigger) {
  128. state.selectionManager.select(node.key);
  129. return;
  130. }
  131. state.selectionManager.clearSelection();
  132. }
  133. }, [isHovering, isFocused]);
  134. // Open submenu on arrow right key press
  135. const {keyboardProps} = useKeyboard({
  136. onKeyDown: e => {
  137. if (e.key === 'Enter' && to) {
  138. const mouseEvent = new MouseEvent('click', {
  139. ctrlKey: e.ctrlKey,
  140. metaKey: e.metaKey,
  141. });
  142. ref.current?.querySelector(`${MenuListItemInnerWrap}`)?.dispatchEvent(mouseEvent);
  143. onClose();
  144. return;
  145. }
  146. if (e.key === 'ArrowRight' && isSubmenuTrigger) {
  147. state.selectionManager.select(node.key);
  148. return;
  149. }
  150. e.continuePropagation();
  151. },
  152. });
  153. // Manage interactive events & create aria attributes
  154. const {menuItemProps, labelProps, descriptionProps} = useMenuItem(
  155. {
  156. key: node.key,
  157. onAction: actionHandler,
  158. closeOnSelect: to ? false : closeOnSelect,
  159. onClose,
  160. isDisabled,
  161. },
  162. state,
  163. ref
  164. );
  165. // Merged menu item props, class names are combined, event handlers chained,
  166. // etc. See: https://react-spectrum.adobe.com/react-aria/mergeProps.html
  167. const props = mergeProps(submenuTriggerProps, menuItemProps, hoverProps, keyboardProps);
  168. const itemLabel = node.rendered ?? label;
  169. const showDivider = showDividers && !isLastNode;
  170. const innerWrapProps = {as: to ? Link : 'div', to};
  171. return (
  172. <MenuListItem
  173. ref={ref}
  174. as={renderAs}
  175. data-test-id={key}
  176. label={itemLabel}
  177. isDisabled={isDisabled}
  178. isFocused={isFocused}
  179. showDivider={showDivider}
  180. innerWrapProps={innerWrapProps}
  181. labelProps={labelProps}
  182. detailsProps={descriptionProps}
  183. {...props}
  184. {...itemProps}
  185. {...(isSubmenuTrigger && {
  186. role: 'menuitemradio',
  187. trailingItems: (
  188. <Fragment>
  189. {itemProps.trailingItems}
  190. <IconChevron size="xs" direction="right" aria-hidden="true" />
  191. </Fragment>
  192. ),
  193. })}
  194. />
  195. );
  196. };
  197. export default MenuItem;