index.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import {useContext, useMemo} from 'react';
  2. import {createPortal} from 'react-dom';
  3. import styled from '@emotion/styled';
  4. import {useButton} from '@react-aria/button';
  5. import {useMenuTrigger} from '@react-aria/menu';
  6. import {Item, Section} from '@react-stately/collections';
  7. import type {DropdownButtonProps} from 'sentry/components/dropdownButton';
  8. import DropdownButton from 'sentry/components/dropdownButton';
  9. import type {FormSize} from 'sentry/utils/theme';
  10. import type {UseOverlayProps} from 'sentry/utils/useOverlay';
  11. import useOverlay from 'sentry/utils/useOverlay';
  12. import type {MenuItemProps} from './item';
  13. import type {DropdownMenuListProps} from './list';
  14. import DropdownMenuList, {DropdownMenuContext} from './list';
  15. export type {MenuItemProps};
  16. /**
  17. * Recursively removes hidden items, including those nested in submenus
  18. */
  19. function removeHiddenItems(source: MenuItemProps[]): MenuItemProps[] {
  20. return source
  21. .filter(item => !item.hidden)
  22. .map(item => ({
  23. ...item,
  24. ...(item.children ? {children: removeHiddenItems(item.children)} : {}),
  25. }));
  26. }
  27. /**
  28. * Recursively finds and returns disabled items
  29. */
  30. function getDisabledKeys(source: MenuItemProps[]): MenuItemProps['key'][] {
  31. return source.reduce<string[]>((acc, cur) => {
  32. if (cur.disabled) {
  33. // If an item is disabled, then its children will be inaccessible, so we
  34. // can skip them and just return the parent item
  35. return acc.concat([cur.key]);
  36. }
  37. if (cur.children) {
  38. return acc.concat(getDisabledKeys(cur.children));
  39. }
  40. return acc;
  41. }, []);
  42. }
  43. interface DropdownMenuProps
  44. extends Omit<
  45. DropdownMenuListProps,
  46. 'overlayState' | 'overlayPositionProps' | 'items' | 'children' | 'menuTitle'
  47. >,
  48. Pick<
  49. UseOverlayProps,
  50. | 'isOpen'
  51. | 'offset'
  52. | 'position'
  53. | 'isDismissable'
  54. | 'shouldCloseOnBlur'
  55. | 'shouldCloseOnInteractOutside'
  56. | 'onInteractOutside'
  57. | 'onOpenChange'
  58. | 'preventOverflowOptions'
  59. | 'flipOptions'
  60. > {
  61. /**
  62. * Items to display inside the dropdown menu. If the item has a `children`
  63. * prop, it will be rendered as a menu section. If it has a `children` prop
  64. * and its `isSubmenu` prop is true, it will be rendered as a submenu.
  65. */
  66. items: MenuItemProps[];
  67. /**
  68. * Pass class name to the outer wrap
  69. */
  70. className?: string;
  71. /**
  72. * Whether the trigger is disabled.
  73. */
  74. isDisabled?: boolean;
  75. /**
  76. * Title for the current menu.
  77. */
  78. menuTitle?: React.ReactChild;
  79. /**
  80. * Tag name for the outer wrap, defaults to `div`
  81. */
  82. renderWrapAs?: React.ElementType;
  83. /**
  84. * Affects the size of the trigger button and menu items.
  85. */
  86. size?: FormSize;
  87. /**
  88. * Optionally replace the trigger button with a different component. Note
  89. * that the replacement must have the `props` and `ref` (supplied in
  90. * TriggerProps) forwarded its outer wrap, otherwise the accessibility
  91. * features won't work correctly.
  92. */
  93. trigger?: (
  94. props: Omit<React.HTMLAttributes<HTMLElement>, 'children'>,
  95. isOpen: boolean
  96. ) => React.ReactNode;
  97. /**
  98. * By default, the menu trigger will be rendered as a button, with
  99. * triggerLabel as the button label.
  100. */
  101. triggerLabel?: React.ReactNode;
  102. /**
  103. * If using the default button trigger (i.e. the custom `trigger` prop has
  104. * not been provided), then `triggerProps` will be passed on to the button
  105. * component.
  106. */
  107. triggerProps?: DropdownButtonProps;
  108. /**
  109. * Whether to render the menu inside a React portal (false by default). This should
  110. * only be enabled if necessary, e.g. when the dropdown menu is inside a small,
  111. * scrollable container that messes with the menu's position. Some features, namely
  112. * submenus, will not work correctly inside portals.
  113. */
  114. usePortal?: boolean;
  115. }
  116. /**
  117. * A menu component that renders both the trigger button and the dropdown
  118. * menu. See: https://react-spectrum.adobe.com/react-aria/useMenuTrigger.html
  119. */
  120. function DropdownMenu({
  121. items,
  122. disabledKeys,
  123. trigger,
  124. triggerLabel,
  125. triggerProps = {},
  126. isDisabled: disabledProp,
  127. isOpen: isOpenProp,
  128. renderWrapAs = 'div',
  129. size = 'md',
  130. className,
  131. // Overlay props
  132. usePortal = false,
  133. offset = 8,
  134. position = 'bottom-start',
  135. isDismissable = true,
  136. shouldCloseOnBlur = true,
  137. shouldCloseOnInteractOutside,
  138. onInteractOutside,
  139. onOpenChange,
  140. preventOverflowOptions,
  141. flipOptions,
  142. ...props
  143. }: DropdownMenuProps) {
  144. const isDisabled = disabledProp ?? (!items || items.length === 0);
  145. const {rootOverlayState} = useContext(DropdownMenuContext);
  146. const {
  147. isOpen,
  148. state: overlayState,
  149. triggerRef,
  150. triggerProps: overlayTriggerProps,
  151. overlayProps,
  152. } = useOverlay({
  153. isOpen: isOpenProp,
  154. onClose: rootOverlayState?.close,
  155. offset,
  156. position,
  157. isDismissable,
  158. disableTrigger: isDisabled,
  159. shouldCloseOnBlur,
  160. shouldCloseOnInteractOutside,
  161. onInteractOutside,
  162. preventOverflowOptions,
  163. flipOptions,
  164. onOpenChange,
  165. });
  166. const {menuTriggerProps, menuProps} = useMenuTrigger(
  167. {type: 'menu', isDisabled},
  168. {...overlayState, focusStrategy: 'first'},
  169. triggerRef
  170. );
  171. // We manually handle focus in the dropdown menu, so we don't want the default autofocus behavior
  172. // Avoids the menu from focusing before popper has placed it in the correct position
  173. menuProps.autoFocus = false;
  174. const {buttonProps} = useButton(
  175. {
  176. isDisabled,
  177. ...menuTriggerProps,
  178. },
  179. triggerRef
  180. );
  181. function renderTrigger() {
  182. if (trigger) {
  183. return trigger({...overlayTriggerProps, ...buttonProps}, isOpen);
  184. }
  185. return (
  186. <DropdownButton
  187. size={size}
  188. isOpen={isOpen}
  189. {...buttonProps}
  190. {...overlayTriggerProps}
  191. {...triggerProps}
  192. >
  193. {triggerLabel}
  194. </DropdownButton>
  195. );
  196. }
  197. const activeItems = useMemo(() => removeHiddenItems(items), [items]);
  198. const defaultDisabledKeys = useMemo(() => getDisabledKeys(activeItems), [activeItems]);
  199. function renderMenu() {
  200. if (!isOpen) {
  201. return null;
  202. }
  203. const menu = (
  204. <DropdownMenuList
  205. {...props}
  206. {...menuProps}
  207. size={size}
  208. disabledKeys={disabledKeys ?? defaultDisabledKeys}
  209. overlayPositionProps={overlayProps}
  210. overlayState={overlayState}
  211. items={activeItems}
  212. >
  213. {(item: MenuItemProps) => {
  214. if (item.children && item.children.length > 0 && !item.isSubmenu) {
  215. return (
  216. <Section key={item.key} title={item.label} items={item.children}>
  217. {sectionItem => (
  218. <Item size={size} {...sectionItem}>
  219. {sectionItem.label}
  220. </Item>
  221. )}
  222. </Section>
  223. );
  224. }
  225. return (
  226. <Item size={size} {...item}>
  227. {item.label}
  228. </Item>
  229. );
  230. }}
  231. </DropdownMenuList>
  232. );
  233. return usePortal ? createPortal(menu, document.body) : menu;
  234. }
  235. return (
  236. <DropdownMenuWrap className={className} as={renderWrapAs} role="presentation">
  237. {renderTrigger()}
  238. {renderMenu()}
  239. </DropdownMenuWrap>
  240. );
  241. }
  242. export {DropdownMenu};
  243. const DropdownMenuWrap = styled('div')`
  244. display: contents;
  245. list-style-type: none;
  246. `;