dropdownMenuItemV2.tsx 11 KB

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