item.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import {forwardRef, Fragment, useContext, useEffect, useRef} 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 type {TreeState} from '@react-stately/tree';
  6. import type {Node} from '@react-types/shared';
  7. import type {LocationDescriptor} from 'history';
  8. import Link from 'sentry/components/links/link';
  9. import type {MenuListItemProps} from 'sentry/components/menuListItem';
  10. import MenuListItem, {
  11. InnerWrap as MenuListItemInnerWrap,
  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. import {DropdownMenuContext} from './list';
  17. export interface MenuItemProps extends MenuListItemProps {
  18. /**
  19. * Item key. Must be unique across the entire menu, including sub-menus.
  20. */
  21. key: string;
  22. /**
  23. * Sub-items that are nested inside this item. By default, sub-items are
  24. * rendered collectively as menu sections inside the current menu. If
  25. * `isSubmenu` is true, then they will be rendered together in a sub-menu.
  26. */
  27. children?: MenuItemProps[];
  28. /**
  29. * Pass a class name to the menu item.
  30. */
  31. className?: string;
  32. /**
  33. * Hide item from the dropdown menu. Note: this will also remove the item
  34. * from the selection manager.
  35. */
  36. hidden?: boolean;
  37. /**
  38. * Whether this menu item is a trigger for a nested sub-menu. Only works
  39. * when `children` is also defined.
  40. */
  41. isSubmenu?: boolean;
  42. /**
  43. * Menu item label. Should preferably be a string. If not, provide a `textValue` prop
  44. * to enable search & keyboard select.
  45. */
  46. label?: MenuListItemProps['label'];
  47. /**
  48. * Function to call when user selects/clicks/taps on the menu item. The
  49. * item's key is passed as an argument.
  50. */
  51. onAction?: () => void;
  52. /**
  53. * Passed as the `menuTitle` prop onto the associated sub-menu (applicable
  54. * if `children` is defined and `isSubmenu` is true)
  55. */
  56. submenuTitle?: string;
  57. /**
  58. * A plain text version of the `label` prop if the label is not a string. Used for
  59. * filtering and keyboard select (quick-focusing on options by typing the first letter).
  60. */
  61. textValue?: string;
  62. /**
  63. * Destination if this menu item is a link.
  64. */
  65. to?: LocationDescriptor;
  66. }
  67. interface DropdownMenuItemProps {
  68. /**
  69. * Whether to close the menu when an item has been clicked/selected
  70. */
  71. closeOnSelect: boolean;
  72. /**
  73. * Node representation (from @react-aria) of the item
  74. */
  75. node: Node<MenuItemProps>;
  76. /**
  77. * Tree state (from @react-stately) inherited from parent menu
  78. */
  79. state: TreeState<MenuItemProps>;
  80. /**
  81. * Handler that is called when the menu should close after selecting an item
  82. */
  83. onClose?: () => void;
  84. /**
  85. * Tag name for item wrapper
  86. */
  87. renderAs?: React.ElementType;
  88. /**
  89. * Whether to show a divider below this item
  90. */
  91. showDivider?: boolean;
  92. }
  93. /**
  94. * A menu item with a label, optional details, leading and trailing elements.
  95. * Can also be used as a trigger button for a submenu. See:
  96. * https://react-spectrum.adobe.com/react-aria/useMenu.html
  97. */
  98. function BaseDropdownMenuItem(
  99. {
  100. node,
  101. state,
  102. closeOnSelect,
  103. onClose,
  104. showDivider,
  105. renderAs = 'li',
  106. ...props
  107. }: DropdownMenuItemProps,
  108. forwardedRef: React.Ref<HTMLLIElement>
  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, isSubmenu, trailingItems, ...itemProps} =
  114. node.value ?? {};
  115. const {size} = node.props;
  116. const actionHandler = () => {
  117. if (to) {
  118. return;
  119. }
  120. if (isSubmenu) {
  121. state.selectionManager.toggleSelection(node.key);
  122. return;
  123. }
  124. onAction?.();
  125. };
  126. // Open submenu on hover
  127. const {hoverProps, isHovered} = useHover({});
  128. const prevIsHovered = usePrevious(isHovered);
  129. const prevIsFocused = usePrevious(isFocused);
  130. useEffect(() => {
  131. if (isHovered === prevIsHovered && isFocused === prevIsFocused) {
  132. return;
  133. }
  134. if (isHovered && isFocused) {
  135. if (isSubmenu) {
  136. state.selectionManager.replaceSelection(node.key);
  137. return;
  138. }
  139. state.selectionManager.clearSelection();
  140. }
  141. }, [
  142. isHovered,
  143. isFocused,
  144. prevIsHovered,
  145. prevIsFocused,
  146. isSubmenu,
  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. return;
  160. }
  161. if (e.key === 'ArrowRight' && isSubmenu) {
  162. state.selectionManager.replaceSelection(node.key);
  163. return;
  164. }
  165. e.continuePropagation();
  166. },
  167. });
  168. // Manage interactive events & create aria attributes
  169. const {rootOverlayState} = useContext(DropdownMenuContext);
  170. const {menuItemProps, labelProps, descriptionProps} = useMenuItem(
  171. {
  172. key: node.key,
  173. onAction: actionHandler,
  174. onClose: () => {
  175. onClose?.();
  176. rootOverlayState?.close();
  177. },
  178. closeOnSelect: to ? false : closeOnSelect,
  179. isDisabled,
  180. },
  181. state,
  182. ref
  183. );
  184. // Merged menu item props, class names are combined, event handlers chained,
  185. // etc. See: https://react-spectrum.adobe.com/react-aria/mergeProps.html
  186. const mergedProps = mergeProps(props, menuItemProps, hoverProps, keyboardProps);
  187. const itemLabel = node.rendered ?? label;
  188. const innerWrapProps = {as: to ? Link : 'div', to};
  189. return (
  190. <MenuListItem
  191. ref={mergeRefs([ref, forwardedRef])}
  192. as={renderAs}
  193. data-test-id={key}
  194. label={itemLabel}
  195. disabled={isDisabled}
  196. isFocused={isFocused}
  197. showDivider={showDivider}
  198. innerWrapProps={innerWrapProps}
  199. labelProps={labelProps}
  200. detailsProps={descriptionProps}
  201. trailingItems={
  202. isSubmenu ? (
  203. <Fragment>
  204. {trailingItems as React.ReactNode}
  205. <IconChevron size="xs" direction="right" aria-hidden="true" />
  206. </Fragment>
  207. ) : (
  208. trailingItems
  209. )
  210. }
  211. size={size}
  212. {...mergedProps}
  213. {...itemProps}
  214. />
  215. );
  216. }
  217. const DropdownMenuItem = forwardRef(BaseDropdownMenuItem);
  218. export default DropdownMenuItem;