index.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  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. export 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. * Reference to the container element that the portal should be rendered into.
  81. */
  82. portalContainerRef?: React.RefObject<HTMLElement>;
  83. /**
  84. * Tag name for the outer wrap, defaults to `div`
  85. */
  86. renderWrapAs?: React.ElementType;
  87. /**
  88. * Affects the size of the trigger button and menu items.
  89. */
  90. size?: FormSize;
  91. /**
  92. * Optionally replace the trigger button with a different component. Note
  93. * that the replacement must have the `props` and `ref` (supplied in
  94. * TriggerProps) forwarded its outer wrap, otherwise the accessibility
  95. * features won't work correctly.
  96. */
  97. trigger?: (
  98. props: Omit<React.HTMLAttributes<HTMLElement>, 'children'>,
  99. isOpen: boolean
  100. ) => React.ReactNode;
  101. /**
  102. * By default, the menu trigger will be rendered as a button, with
  103. * triggerLabel as the button label.
  104. */
  105. triggerLabel?: React.ReactNode;
  106. /**
  107. * If using the default button trigger (i.e. the custom `trigger` prop has
  108. * not been provided), then `triggerProps` will be passed on to the button
  109. * component.
  110. */
  111. triggerProps?: DropdownButtonProps;
  112. /**
  113. * Whether to render the menu inside a React portal (false by default). This should
  114. * only be enabled if necessary, e.g. when the dropdown menu is inside a small,
  115. * scrollable container that messes with the menu's position. Some features, namely
  116. * submenus, will not work correctly inside portals.
  117. */
  118. usePortal?: boolean;
  119. }
  120. /**
  121. * A menu component that renders both the trigger button and the dropdown
  122. * menu. See: https://react-spectrum.adobe.com/react-aria/useMenuTrigger.html
  123. */
  124. function DropdownMenu({
  125. items,
  126. disabledKeys,
  127. trigger,
  128. triggerLabel,
  129. triggerProps = {},
  130. isDisabled: disabledProp,
  131. isOpen: isOpenProp,
  132. renderWrapAs = 'div',
  133. size = 'md',
  134. className,
  135. // Overlay props
  136. usePortal = false,
  137. offset = 8,
  138. position = 'bottom-start',
  139. isDismissable = true,
  140. shouldCloseOnBlur = true,
  141. shouldCloseOnInteractOutside,
  142. onInteractOutside,
  143. onOpenChange,
  144. preventOverflowOptions,
  145. flipOptions,
  146. portalContainerRef,
  147. ...props
  148. }: DropdownMenuProps) {
  149. const isDisabled = disabledProp ?? (!items || items.length === 0);
  150. const {rootOverlayState} = useContext(DropdownMenuContext);
  151. const {
  152. isOpen,
  153. state: overlayState,
  154. triggerRef,
  155. triggerProps: overlayTriggerProps,
  156. overlayProps,
  157. } = useOverlay({
  158. isOpen: isOpenProp,
  159. onClose: rootOverlayState?.close,
  160. offset,
  161. position,
  162. isDismissable,
  163. disableTrigger: isDisabled,
  164. shouldCloseOnBlur,
  165. shouldCloseOnInteractOutside,
  166. onInteractOutside,
  167. preventOverflowOptions,
  168. flipOptions,
  169. onOpenChange,
  170. });
  171. const {menuTriggerProps, menuProps} = useMenuTrigger(
  172. {type: 'menu', isDisabled},
  173. {...overlayState, focusStrategy: 'first'},
  174. triggerRef
  175. );
  176. // We manually handle focus in the dropdown menu, so we don't want the default autofocus behavior
  177. // Avoids the menu from focusing before popper has placed it in the correct position
  178. menuProps.autoFocus = false;
  179. const {buttonProps} = useButton(
  180. {
  181. isDisabled,
  182. ...menuTriggerProps,
  183. },
  184. triggerRef
  185. );
  186. function renderTrigger() {
  187. if (trigger) {
  188. return trigger({...buttonProps, ...overlayTriggerProps}, isOpen);
  189. }
  190. return (
  191. <DropdownButton
  192. size={size}
  193. isOpen={isOpen}
  194. {...buttonProps}
  195. {...overlayTriggerProps}
  196. {...triggerProps}
  197. >
  198. {triggerLabel}
  199. </DropdownButton>
  200. );
  201. }
  202. const activeItems = useMemo(() => removeHiddenItems(items), [items]);
  203. const defaultDisabledKeys = useMemo(() => getDisabledKeys(activeItems), [activeItems]);
  204. function renderMenu() {
  205. if (!isOpen) {
  206. return null;
  207. }
  208. const menu = (
  209. <DropdownMenuList
  210. {...props}
  211. {...menuProps}
  212. size={size}
  213. disabledKeys={disabledKeys ?? defaultDisabledKeys}
  214. overlayPositionProps={overlayProps}
  215. overlayState={overlayState}
  216. items={activeItems}
  217. >
  218. {(item: MenuItemProps) => {
  219. if (item.children && item.children.length > 0 && !item.isSubmenu) {
  220. return (
  221. <Section key={item.key} title={item.label} items={item.children}>
  222. {sectionItem => (
  223. <Item size={size} {...sectionItem}>
  224. {sectionItem.label}
  225. </Item>
  226. )}
  227. </Section>
  228. );
  229. }
  230. return (
  231. <Item size={size} {...item}>
  232. {item.label}
  233. </Item>
  234. );
  235. }}
  236. </DropdownMenuList>
  237. );
  238. return usePortal
  239. ? createPortal(menu, portalContainerRef?.current ?? document.body)
  240. : menu;
  241. }
  242. return (
  243. <DropdownMenuWrap className={className} as={renderWrapAs} role="presentation">
  244. {renderTrigger()}
  245. {renderMenu()}
  246. </DropdownMenuWrap>
  247. );
  248. }
  249. export {DropdownMenu};
  250. const DropdownMenuWrap = styled('div')`
  251. display: contents;
  252. list-style-type: none;
  253. `;