dropdownMenuControl.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {useButton} from '@react-aria/button';
  4. import {AriaMenuOptions, useMenuTrigger} from '@react-aria/menu';
  5. import {useResizeObserver} from '@react-aria/utils';
  6. import {Item, Section} from '@react-stately/collections';
  7. import {MenuTriggerProps} from '@react-types/menu';
  8. import DropdownButton, {DropdownButtonProps} from 'sentry/components/dropdownButton';
  9. import DropdownMenu from 'sentry/components/dropdownMenu';
  10. import {MenuItemProps} from 'sentry/components/dropdownMenuItem';
  11. import {FormSize} from 'sentry/utils/theme';
  12. import useOverlay, {UseOverlayProps} from 'sentry/utils/useOverlay';
  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 Props
  41. extends Partial<MenuTriggerProps>,
  42. Partial<AriaMenuOptions<MenuItemProps>>,
  43. UseOverlayProps {
  44. /**
  45. * Items to display inside the dropdown menu. If the item has a `children`
  46. * prop, it will be rendered as a menu section. If it has a `children` prop
  47. * and its `isSubmenu` prop is true, it will be rendered as a submenu.
  48. */
  49. items: MenuItemProps[];
  50. /**
  51. * Pass class name to the outer wrap
  52. */
  53. className?: string;
  54. /**
  55. * If this is a submenu, it will in some cases need to close itself (e.g.
  56. * when the user presses the arrow left key)
  57. */
  58. closeCurrentSubmenu?: () => void;
  59. /**
  60. * If this is a submenu, it will in some cases need to close the root menu
  61. * (e.g. when a submenu item is clicked).
  62. */
  63. closeRootMenu?: () => void;
  64. /**
  65. * Whether the trigger is disabled.
  66. */
  67. isDisabled?: boolean;
  68. /**
  69. * Whether this is a submenu.
  70. */
  71. isSubmenu?: boolean;
  72. /**
  73. * Title for the current menu.
  74. */
  75. menuTitle?: string;
  76. /**
  77. * Tag name for the outer wrap, defaults to `div`
  78. */
  79. renderWrapAs?: React.ElementType;
  80. /**
  81. * Affects the size of the trigger button and menu items.
  82. */
  83. size?: FormSize;
  84. /**
  85. * Optionally replace the trigger button with a different component. Note
  86. * that the replacement must have the `props` and `ref` (supplied in
  87. * TriggerProps) forwarded its outer wrap, otherwise the accessibility
  88. * features won't work correctly.
  89. */
  90. trigger?: (
  91. props: Omit<React.HTMLAttributes<Element>, 'children'> & {
  92. onClick?: (e: MouseEvent) => void;
  93. }
  94. ) => React.ReactNode;
  95. /**
  96. * By default, the menu trigger will be rendered as a button, with
  97. * triggerLabel as the button label.
  98. */
  99. triggerLabel?: React.ReactNode;
  100. /**
  101. * If using the default button trigger (i.e. the custom `trigger` prop has
  102. * not been provided), then `triggerProps` will be passed on to the button
  103. * component.
  104. */
  105. triggerProps?: DropdownButtonProps;
  106. }
  107. /**
  108. * A menu component that renders both the trigger button and the dropdown
  109. * menu. See: https://react-spectrum.adobe.com/react-aria/useMenuTrigger.html
  110. */
  111. function DropdownMenuControl({
  112. items,
  113. disabledKeys,
  114. trigger,
  115. triggerLabel,
  116. triggerProps = {},
  117. isDisabled: disabledProp,
  118. isOpen: isOpenProp,
  119. isSubmenu = false,
  120. closeRootMenu,
  121. closeCurrentSubmenu,
  122. renderWrapAs = 'div',
  123. size = 'md',
  124. className,
  125. // Overlay props
  126. offset = 8,
  127. position = 'bottom-start',
  128. isDismissable = true,
  129. shouldCloseOnBlur = true,
  130. ...props
  131. }: Props) {
  132. const isDisabled = disabledProp ?? (!items || items.length === 0);
  133. const {
  134. isOpen,
  135. state,
  136. triggerRef,
  137. triggerProps: overlayTriggerProps,
  138. overlayProps,
  139. } = useOverlay({
  140. onClose: closeRootMenu,
  141. isOpen: isOpenProp,
  142. offset,
  143. position,
  144. isDismissable: !isSubmenu && isDismissable,
  145. shouldCloseOnBlur: !isSubmenu && shouldCloseOnBlur,
  146. shouldCloseOnInteractOutside: target =>
  147. target && triggerRef.current !== target && !triggerRef.current?.contains(target),
  148. });
  149. const {menuTriggerProps, menuProps} = useMenuTrigger(
  150. {type: 'menu', isDisabled},
  151. {...state, focusStrategy: 'first'},
  152. triggerRef
  153. );
  154. const {buttonProps} = useButton(
  155. {
  156. isDisabled,
  157. ...menuTriggerProps,
  158. ...(isSubmenu && {
  159. onKeyUp: e => e.continuePropagation(),
  160. onKeyDown: e => e.continuePropagation(),
  161. onPress: () => null,
  162. onPressStart: () => null,
  163. onPressEnd: () => null,
  164. }),
  165. },
  166. triggerRef
  167. );
  168. // Calculate the current trigger element's width. This will be used as
  169. // the min width for the menu.
  170. const [triggerWidth, setTriggerWidth] = useState<number>();
  171. // Update triggerWidth when its size changes using useResizeObserver
  172. const updateTriggerWidth = useCallback(async () => {
  173. // Wait until the trigger element finishes rendering, otherwise
  174. // ResizeObserver might throw an infinite loop error.
  175. await new Promise(resolve => window.setTimeout(resolve));
  176. const newTriggerWidth = triggerRef.current?.offsetWidth;
  177. !isSubmenu && newTriggerWidth && setTriggerWidth(newTriggerWidth);
  178. }, [isSubmenu, triggerRef]);
  179. useResizeObserver({ref: triggerRef, onResize: updateTriggerWidth});
  180. // If ResizeObserver is not available, manually update the width
  181. // when any of [trigger, triggerLabel, triggerProps] changes.
  182. useEffect(() => {
  183. if (typeof window.ResizeObserver !== 'undefined') {
  184. return;
  185. }
  186. updateTriggerWidth();
  187. }, [updateTriggerWidth]);
  188. function renderTrigger() {
  189. if (trigger) {
  190. return trigger({
  191. size,
  192. isOpen,
  193. ...triggerProps,
  194. ...overlayTriggerProps,
  195. ...buttonProps,
  196. });
  197. }
  198. return (
  199. <DropdownButton
  200. size={size}
  201. isOpen={isOpen}
  202. {...triggerProps}
  203. {...overlayTriggerProps}
  204. {...buttonProps}
  205. >
  206. {triggerLabel}
  207. </DropdownButton>
  208. );
  209. }
  210. const activeItems = useMemo(() => removeHiddenItems(items), [items]);
  211. const defaultDisabledKeys = useMemo(() => getDisabledKeys(activeItems), [activeItems]);
  212. function renderMenu() {
  213. if (!isOpen) {
  214. return null;
  215. }
  216. return (
  217. <DropdownMenu
  218. {...props}
  219. {...menuProps}
  220. size={size}
  221. triggerWidth={triggerWidth}
  222. isSubmenu={isSubmenu}
  223. closeRootMenu={closeRootMenu ?? state.close}
  224. closeCurrentSubmenu={closeCurrentSubmenu}
  225. disabledKeys={disabledKeys ?? defaultDisabledKeys}
  226. overlayPositionProps={overlayProps}
  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. </DropdownMenu>
  248. );
  249. }
  250. return (
  251. <MenuControlWrap className={className} as={renderWrapAs} role="presentation">
  252. {renderTrigger()}
  253. {renderMenu()}
  254. </MenuControlWrap>
  255. );
  256. }
  257. export default DropdownMenuControl;
  258. const MenuControlWrap = styled('div')`
  259. list-style-type: none;
  260. `;