index.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  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. > {
  56. /**
  57. * Items to display inside the dropdown menu. If the item has a `children`
  58. * prop, it will be rendered as a menu section. If it has a `children` prop
  59. * and its `isSubmenu` prop is true, it will be rendered as a submenu.
  60. */
  61. items: MenuItemProps[];
  62. /**
  63. * Pass class name to the outer wrap
  64. */
  65. className?: string;
  66. /**
  67. * Whether the trigger is disabled.
  68. */
  69. isDisabled?: boolean;
  70. /**
  71. * Title for the current menu.
  72. */
  73. menuTitle?: string;
  74. /**
  75. * Whether the menu should always be wider than the trigger. If true (default), then
  76. * the menu will have a min width equal to the trigger's width.
  77. */
  78. menuWiderThanTrigger?: boolean;
  79. /**
  80. * Minimum menu width, in pixels
  81. */
  82. minMenuWidth?: number;
  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. /**
  114. * A menu component that renders both the trigger button and the dropdown
  115. * menu. See: https://react-spectrum.adobe.com/react-aria/useMenuTrigger.html
  116. */
  117. function DropdownMenu({
  118. items,
  119. disabledKeys,
  120. trigger,
  121. triggerLabel,
  122. triggerProps = {},
  123. isDisabled: disabledProp,
  124. isOpen: isOpenProp,
  125. minMenuWidth,
  126. menuWiderThanTrigger = true,
  127. renderWrapAs = 'div',
  128. size = 'md',
  129. className,
  130. // Overlay props
  131. offset = 8,
  132. position = 'bottom-start',
  133. isDismissable = true,
  134. shouldCloseOnBlur = true,
  135. shouldCloseOnInteractOutside,
  136. onInteractOutside,
  137. preventOverflowOptions,
  138. ...props
  139. }: DropdownMenuProps) {
  140. const isDisabled = disabledProp ?? (!items || items.length === 0);
  141. const {rootOverlayState} = useContext(DropdownMenuContext);
  142. const {
  143. isOpen,
  144. state: overlayState,
  145. triggerRef,
  146. triggerProps: overlayTriggerProps,
  147. overlayProps,
  148. } = useOverlay({
  149. isOpen: isOpenProp,
  150. onClose: rootOverlayState?.close,
  151. offset,
  152. position,
  153. isDismissable,
  154. shouldCloseOnBlur,
  155. shouldCloseOnInteractOutside,
  156. onInteractOutside,
  157. preventOverflowOptions,
  158. });
  159. const {menuTriggerProps, menuProps} = useMenuTrigger(
  160. {type: 'menu', isDisabled},
  161. {...overlayState, focusStrategy: 'first'},
  162. triggerRef
  163. );
  164. const {buttonProps} = useButton(
  165. {
  166. isDisabled,
  167. ...menuTriggerProps,
  168. },
  169. triggerRef
  170. );
  171. // Calculate the current trigger element's width. This will be used as
  172. // the min width for the menu.
  173. const [triggerWidth, setTriggerWidth] = useState<number>();
  174. // Update triggerWidth when its size changes using useResizeObserver
  175. const updateTriggerWidth = useCallback(async () => {
  176. if (!menuWiderThanTrigger) {
  177. return;
  178. }
  179. // Wait until the trigger element finishes rendering, otherwise
  180. // ResizeObserver might throw an infinite loop error.
  181. await new Promise(resolve => window.setTimeout(resolve));
  182. setTriggerWidth(triggerRef.current?.offsetWidth ?? 0);
  183. }, [menuWiderThanTrigger, triggerRef]);
  184. useResizeObserver({ref: triggerRef, onResize: updateTriggerWidth});
  185. // If ResizeObserver is not available, manually update the width
  186. // when any of [trigger, triggerLabel, triggerProps] changes.
  187. useEffect(() => {
  188. if (typeof window.ResizeObserver !== 'undefined') {
  189. return;
  190. }
  191. updateTriggerWidth();
  192. }, [updateTriggerWidth]);
  193. function renderTrigger() {
  194. if (trigger) {
  195. return trigger({...overlayTriggerProps, ...buttonProps}, isOpen);
  196. }
  197. return (
  198. <DropdownButton
  199. size={size}
  200. isOpen={isOpen}
  201. {...triggerProps}
  202. {...overlayTriggerProps}
  203. {...buttonProps}
  204. >
  205. {triggerLabel}
  206. </DropdownButton>
  207. );
  208. }
  209. const activeItems = useMemo(() => removeHiddenItems(items), [items]);
  210. const defaultDisabledKeys = useMemo(() => getDisabledKeys(activeItems), [activeItems]);
  211. function renderMenu() {
  212. if (!isOpen) {
  213. return null;
  214. }
  215. return (
  216. <DropdownMenuList
  217. {...props}
  218. {...menuProps}
  219. size={size}
  220. minWidth={Math.max(minMenuWidth ?? 0, triggerWidth ?? 0)}
  221. disabledKeys={disabledKeys ?? defaultDisabledKeys}
  222. overlayPositionProps={overlayProps}
  223. overlayState={overlayState}
  224. items={activeItems}
  225. >
  226. {(item: MenuItemProps) => {
  227. if (item.children && item.children.length > 0 && !item.isSubmenu) {
  228. return (
  229. <Section key={item.key} title={item.label} items={item.children}>
  230. {sectionItem => (
  231. <Item size={size} {...sectionItem}>
  232. {sectionItem.label}
  233. </Item>
  234. )}
  235. </Section>
  236. );
  237. }
  238. return (
  239. <Item size={size} {...item}>
  240. {item.label}
  241. </Item>
  242. );
  243. }}
  244. </DropdownMenuList>
  245. );
  246. }
  247. return (
  248. <DropdownMenuWrap className={className} as={renderWrapAs} role="presentation">
  249. {renderTrigger()}
  250. {renderMenu()}
  251. </DropdownMenuWrap>
  252. );
  253. }
  254. export {DropdownMenu};
  255. const DropdownMenuWrap = styled('div')`
  256. list-style-type: none;
  257. `;