dropdownMenuControl.tsx 6.9 KB

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