dropdownMenuItem.tsx 6.3 KB

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