index.tsx 7.7 KB

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