item.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import {forwardRef, Fragment, useContext, useEffect, useRef} from 'react';
  2. import {useHover, useKeyboard, usePress} 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. 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. * Plass 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. * Function to call when user selects/clicks/taps on the menu item. The
  44. * item's key is passed as an argument.
  45. */
  46. onAction?: (key: MenuItemProps['key']) => void;
  47. /**
  48. * Passed as the `menuTitle` prop onto the associated sub-menu (applicable
  49. * if `children` is defined and `isSubmenu` is true)
  50. */
  51. submenuTitle?: string;
  52. /**
  53. * Destination if this menu item is a link.
  54. */
  55. to?: LocationDescriptor;
  56. }
  57. interface DropdownMenuItemProps {
  58. /**
  59. * Whether to close the menu when an item has been clicked/selected
  60. */
  61. closeOnSelect: boolean;
  62. /**
  63. * Node representation (from @react-aria) of the item
  64. */
  65. node: Node<MenuItemProps>;
  66. /**
  67. * Tree state (from @react-stately) inherited from parent menu
  68. */
  69. state: TreeState<MenuItemProps>;
  70. /**
  71. * Handler that is called when the menu should close after selecting an item
  72. */
  73. onClose?: () => void;
  74. /**
  75. * Tag name for item wrapper
  76. */
  77. renderAs?: React.ElementType;
  78. /**
  79. * Whether to show a divider below this item
  80. */
  81. showDivider?: boolean;
  82. }
  83. /**
  84. * A menu item with a label, optional details, leading and trailing elements.
  85. * Can also be used as a trigger button for a submenu. See:
  86. * https://react-spectrum.adobe.com/react-aria/useMenu.html
  87. */
  88. function BaseDropdownMenuItem(
  89. {
  90. node,
  91. state,
  92. closeOnSelect,
  93. onClose,
  94. showDivider,
  95. renderAs = 'li',
  96. ...props
  97. }: DropdownMenuItemProps,
  98. forwardedRef: React.Ref<HTMLLIElement>
  99. ) {
  100. const ref = useRef<HTMLLIElement | null>(null);
  101. const isDisabled = state.disabledKeys.has(node.key);
  102. const isFocused = state.selectionManager.focusedKey === node.key;
  103. const {key, onAction, to, label, isSubmenu, trailingItems, ...itemProps} =
  104. node.value ?? {};
  105. const {size} = node.props;
  106. const actionHandler = () => {
  107. if (to) {
  108. return;
  109. }
  110. if (isSubmenu) {
  111. state.selectionManager.toggleSelection(node.key);
  112. return;
  113. }
  114. key && onAction?.(key);
  115. };
  116. // Open submenu on hover
  117. const {hoverProps, isHovered} = useHover({});
  118. // Toggle submenu on press
  119. const {pressProps} = usePress({
  120. onPress: () => state.selectionManager.toggleSelection(node.key),
  121. });
  122. const prevIsHovered = usePrevious(isHovered);
  123. const prevIsFocused = usePrevious(isFocused);
  124. useEffect(() => {
  125. if (isHovered === prevIsHovered && isFocused === prevIsFocused) {
  126. return;
  127. }
  128. if (isHovered && isFocused) {
  129. if (isSubmenu) {
  130. state.selectionManager.replaceSelection(node.key);
  131. return;
  132. }
  133. state.selectionManager.clearSelection();
  134. }
  135. }, [
  136. isHovered,
  137. isFocused,
  138. prevIsHovered,
  139. prevIsFocused,
  140. isSubmenu,
  141. node.key,
  142. state.selectionManager,
  143. ]);
  144. // Open submenu on arrow right key press
  145. const {keyboardProps} = useKeyboard({
  146. onKeyDown: e => {
  147. if (e.key === 'Enter' && to) {
  148. const mouseEvent = new MouseEvent('click', {
  149. ctrlKey: e.ctrlKey,
  150. metaKey: e.metaKey,
  151. });
  152. ref.current?.querySelector(`${MenuListItemInnerWrap}`)?.dispatchEvent(mouseEvent);
  153. return;
  154. }
  155. if (e.key === 'ArrowRight' && isSubmenu) {
  156. state.selectionManager.replaceSelection(node.key);
  157. return;
  158. }
  159. e.continuePropagation();
  160. },
  161. });
  162. // Manage interactive events & create aria attributes
  163. const {rootOverlayState} = useContext(DropdownMenuContext);
  164. const {menuItemProps, labelProps, descriptionProps} = useMenuItem(
  165. {
  166. key: node.key,
  167. onAction: actionHandler,
  168. onClose: () => {
  169. onClose?.();
  170. rootOverlayState?.close();
  171. },
  172. closeOnSelect: to ? false : closeOnSelect,
  173. isDisabled,
  174. },
  175. state,
  176. ref
  177. );
  178. // Merged menu item props, class names are combined, event handlers chained,
  179. // etc. See: https://react-spectrum.adobe.com/react-aria/mergeProps.html
  180. const mergedProps = mergeProps(
  181. props,
  182. menuItemProps,
  183. hoverProps,
  184. keyboardProps,
  185. pressProps
  186. );
  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}
  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;