dropdownMenuItemV2.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import {useEffect, useRef, useState} from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {useHover, useKeyboard} from '@react-aria/interactions';
  5. import {useMenuItem} from '@react-aria/menu';
  6. import {mergeProps} from '@react-aria/utils';
  7. import {TreeState} from '@react-stately/tree';
  8. import {Node} from '@react-types/shared';
  9. import {IconChevron} from 'sentry/icons';
  10. import overflowEllipsis from 'sentry/styles/overflowEllipsis';
  11. import space from 'sentry/styles/space';
  12. export type MenuItemProps = {
  13. /**
  14. * Item key. Must be unique across the entire menu, including sub-menus.
  15. */
  16. key: string;
  17. /**
  18. * Item label. Should prefereably be a string. If not, make sure that
  19. * there are appropriate aria-labels.
  20. */
  21. label: React.ReactNode;
  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. * Optional descriptive text. Like 'label', should preferably be a string or
  30. * have appropriate aria-labels.
  31. */
  32. details?: React.ReactNode;
  33. /*
  34. * Whether this menu item is a trigger for a nested sub-menu. Only works
  35. * when `children` is also defined.
  36. */
  37. isSubmenu?: boolean;
  38. /*
  39. * Items to be added to the left of the label
  40. */
  41. leadingItems?: React.ReactNode;
  42. /*
  43. * Whether leading items should be centered with respect to the entire
  44. * height of the menu item. If false (default), they will be centered with
  45. * respect to the first line of the label element.
  46. */
  47. leadingItemsSpanFullHeight?: boolean;
  48. /**
  49. * Function to call when user selects/clicks/taps on the menu item. The
  50. * item's key is passed as an argument.
  51. */
  52. onAction?: (key: MenuItemProps['key']) => void;
  53. /**
  54. * Whether to show a line divider below this menu item
  55. */
  56. showDividers?: boolean;
  57. /**
  58. * Passed as the `menuTitle` prop onto the associated sub-menu (applicable
  59. * if `children` is defined and `isSubmenu` is true)
  60. */
  61. submenuTitle?: string;
  62. /**
  63. * React-router destination if menu item is a link. Note: currently only
  64. * internal links (callable with `router.push()`) are supported.
  65. */
  66. to?: string;
  67. /*
  68. * Items to be added to the right of the label.
  69. */
  70. trailingItems?: React.ReactNode;
  71. /*
  72. * Whether trailing items should be centered wrt/ the entire height of the
  73. * menu item. If false (default), they will be centered wrt/ the first line of the
  74. * label element.
  75. */
  76. trailingItemsSpanFullHeight?: boolean;
  77. };
  78. type Props = {
  79. /**
  80. * Whether to close the menu when an item has been clicked/selected
  81. */
  82. closeOnSelect: boolean;
  83. /**
  84. * Whether this is the last node in the collection
  85. */
  86. isLastNode: boolean;
  87. /**
  88. * Node representation (from @react-aria) of the item
  89. */
  90. node: Node<MenuItemProps>;
  91. /**
  92. * Used to close the menu when needed (e.g. when the item is
  93. * clicked/selected)
  94. */
  95. onClose: () => void;
  96. /**
  97. * Tree state (from @react-stately) inherited from parent menu
  98. */
  99. state: TreeState<MenuItemProps>;
  100. /**
  101. * Whether this is a trigger button (displayed as a normal menu item) for a
  102. * submenu
  103. */
  104. isSubmenuTrigger?: boolean;
  105. /**
  106. * Tag name for item wrapper
  107. */
  108. renderAs?: React.ElementType;
  109. /**
  110. * If isSubmenuTrigger is true, then replace the internal ref object with
  111. * this ref
  112. */
  113. submenuTriggerRef?: React.RefObject<HTMLLIElement>;
  114. } & WithRouterProps;
  115. /**
  116. * A menu item with a label, optional details, leading and trailing elements.
  117. * Can also be used as a trigger button for a submenu. See:
  118. * https://react-spectrum.adobe.com/react-aria/useMenu.html
  119. */
  120. const MenuItem = withRouter(
  121. ({
  122. node,
  123. isLastNode,
  124. state,
  125. onClose,
  126. closeOnSelect,
  127. isSubmenuTrigger = false,
  128. submenuTriggerRef,
  129. renderAs = 'li' as React.ElementType,
  130. router,
  131. ...submenuTriggerProps
  132. }: Props) => {
  133. const [isHovering, setIsHovering] = useState(false);
  134. const ref = submenuTriggerRef ?? useRef(null);
  135. const isDisabled = state.disabledKeys.has(node.key);
  136. const isFocused = state.selectionManager.focusedKey === node.key;
  137. const item = node.value;
  138. const actionHandler = () => {
  139. if (isSubmenuTrigger) {
  140. state.selectionManager.select(node.key);
  141. return;
  142. }
  143. item.onAction?.(item.key);
  144. item.to && router.push(item.to);
  145. };
  146. // Open submenu on hover
  147. const {hoverProps} = useHover({onHoverChange: setIsHovering});
  148. useEffect(() => {
  149. if (isHovering && isFocused) {
  150. if (isSubmenuTrigger) {
  151. state.selectionManager.select(node.key);
  152. return;
  153. }
  154. state.selectionManager.clearSelection();
  155. }
  156. }, [isHovering, isFocused]);
  157. // Open submenu on arrow right key press
  158. const {keyboardProps} = useKeyboard({
  159. onKeyDown: e => {
  160. if (isSubmenuTrigger && e.key === 'ArrowRight') {
  161. state.selectionManager.select(node.key);
  162. return;
  163. }
  164. e.continuePropagation();
  165. },
  166. });
  167. // Manage interactive events & create aria attributes
  168. const {menuItemProps, labelProps, descriptionProps} = useMenuItem(
  169. {
  170. key: node.key,
  171. onAction: actionHandler,
  172. onClose,
  173. closeOnSelect,
  174. isDisabled,
  175. },
  176. state,
  177. ref
  178. );
  179. // Merged menu item props, class names are combined, event handlers chained,
  180. // etc. See: https://react-spectrum.adobe.com/react-aria/mergeProps.html
  181. const props = mergeProps(
  182. submenuTriggerProps,
  183. menuItemProps,
  184. hoverProps,
  185. keyboardProps
  186. );
  187. const {
  188. details,
  189. leadingItems,
  190. leadingItemsSpanFullHeight,
  191. trailingItems,
  192. trailingItemsSpanFullHeight,
  193. } = item;
  194. const label = node.rendered ?? item.label;
  195. const showDividers = item.showDividers && !isLastNode;
  196. return (
  197. <MenuItemWrap
  198. ref={ref}
  199. as={renderAs}
  200. isDisabled={isDisabled}
  201. data-test-id={item.key}
  202. {...props}
  203. {...(isSubmenuTrigger && {role: 'menuitemradio'})}
  204. >
  205. <InnerWrap isFocused={isFocused}>
  206. {leadingItems && (
  207. <LeadingItems
  208. isDisabled={isDisabled}
  209. spanFullHeight={leadingItemsSpanFullHeight}
  210. >
  211. {leadingItems}
  212. </LeadingItems>
  213. )}
  214. <ContentWrap isFocused={isFocused} showDividers={showDividers}>
  215. <LabelWrap>
  216. <Label isDisabled={isDisabled} {...labelProps} aria-hidden="true">
  217. {label}
  218. </Label>
  219. {details && <Details {...descriptionProps}>{details}</Details>}
  220. </LabelWrap>
  221. {(trailingItems || isSubmenuTrigger) && (
  222. <TrailingItems
  223. isDisabled={isDisabled}
  224. spanFullHeight={trailingItemsSpanFullHeight}
  225. >
  226. {trailingItems}
  227. {isSubmenuTrigger && (
  228. <IconChevron size="xs" direction="right" aria-hidden="true" />
  229. )}
  230. </TrailingItems>
  231. )}
  232. </ContentWrap>
  233. </InnerWrap>
  234. </MenuItemWrap>
  235. );
  236. }
  237. );
  238. export default MenuItem;
  239. const MenuItemWrap = styled('li')<{isDisabled?: boolean}>`
  240. position: static;
  241. list-style-type: none;
  242. margin: 0;
  243. padding: 0 ${space(0.5)};
  244. cursor: pointer;
  245. color: ${p => p.theme.textColor};
  246. ${p => p.isDisabled && `cursor: initial;`}
  247. &:focus {
  248. outline: none;
  249. }
  250. &:focus-visible {
  251. outline: none;
  252. }
  253. `;
  254. const InnerWrap = styled('div')<{isFocused: boolean}>`
  255. display: flex;
  256. position: relative;
  257. padding: 0 ${space(1)};
  258. border-radius: ${p => p.theme.borderRadius};
  259. box-sizing: border-box;
  260. ${p => p.isFocused && `background: ${p.theme.hover}; z-index: 1;`}
  261. `;
  262. const LeadingItems = styled('div')<{isDisabled?: boolean; spanFullHeight?: boolean}>`
  263. display: flex;
  264. align-items: center;
  265. height: 1.4em;
  266. gap: ${space(1)};
  267. padding: ${space(1)} 0;
  268. margin-top: ${space(1)};
  269. margin-right: ${space(0.5)};
  270. ${p => p.isDisabled && `opacity: 0.5;`}
  271. ${p => p.spanFullHeight && `height: 100%;`}
  272. `;
  273. const ContentWrap = styled('div')<{isFocused: boolean; showDividers?: boolean}>`
  274. position: relative;
  275. width: 100%;
  276. display: flex;
  277. gap: ${space(2)};
  278. justify-content: space-between;
  279. padding: ${space(1)} 0;
  280. margin-left: ${space(0.5)};
  281. ${p =>
  282. p.showDividers &&
  283. !p.isFocused &&
  284. `
  285. &::after {
  286. content: '';
  287. position: absolute;
  288. left: 0;
  289. bottom: 0;
  290. width: 100%;
  291. height: 1px;
  292. box-shadow: 0 1px 0 0 ${p.theme.innerBorder};
  293. }
  294. `}
  295. `;
  296. const LabelWrap = styled('div')`
  297. padding-right: ${space(1)};
  298. width: 100%;
  299. `;
  300. const Label = styled('p')<{isDisabled?: boolean}>`
  301. margin-bottom: 0;
  302. line-height: 1.4;
  303. white-space: nowrap;
  304. ${overflowEllipsis}
  305. ${p => p.isDisabled && `color: ${p.theme.subText};`}
  306. `;
  307. const Details = styled('p')`
  308. font-size: ${p => p.theme.fontSizeSmall};
  309. color: ${p => p.theme.subText};
  310. line-height: 1.2;
  311. margin-bottom: 0;
  312. ${overflowEllipsis}
  313. `;
  314. const TrailingItems = styled('div')<{isDisabled?: boolean; spanFullHeight?: boolean}>`
  315. display: flex;
  316. align-items: center;
  317. height: 1.4em;
  318. gap: ${space(1)};
  319. margin-right: ${space(0.5)};
  320. ${p => p.isDisabled && `opacity: 0.5;`}
  321. ${p => p.spanFullHeight && `height: 100%;`}
  322. `;