index.tsx 8.3 KB

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