dropdownMenuItemV2.tsx 11 KB

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