dropdownMenuControl.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import {useCallback, useEffect, useMemo, 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. /**
  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. type TriggerProps = {
  42. props: Omit<React.HTMLAttributes<Element>, 'children'> & {
  43. onClick?: (e: MouseEvent) => void;
  44. };
  45. ref: React.RefObject<HTMLButtonElement>;
  46. };
  47. type Props = {
  48. /**
  49. * Items to display inside the dropdown menu. If the item has a `children`
  50. * prop, it will be rendered as a menu section. If it has a `children` prop
  51. * and its `isSubmenu` prop is true, it will be rendered as a submenu.
  52. */
  53. items: MenuItemProps[];
  54. /**
  55. * Pass class name to the outer wrap
  56. */
  57. className?: string;
  58. /**
  59. * If this is a submenu, it will in some cases need to close itself (e.g.
  60. * when the user presses the arrow left key)
  61. */
  62. closeCurrentSubmenu?: () => void;
  63. /**
  64. * If this is a submenu, it will in some cases need to close the root menu
  65. * (e.g. when a submenu item is clicked).
  66. */
  67. closeRootMenu?: () => void;
  68. /**
  69. * Whether the trigger is disabled.
  70. */
  71. isDisabled?: boolean;
  72. /**
  73. * Whether this is a submenu.
  74. */
  75. isSubmenu?: boolean;
  76. /**
  77. * Title for the current menu.
  78. */
  79. menuTitle?: string;
  80. /**
  81. * Tag name for the outer wrap, defaults to `div`
  82. */
  83. renderWrapAs?: React.ElementType;
  84. /**
  85. * Affects the size of the trigger button and menu items.
  86. */
  87. size?: FormSize;
  88. /**
  89. * Optionally replace the trigger button with a different component. Note
  90. * that the replacement must have the `props` and `ref` (supplied in
  91. * TriggerProps) forwarded its outer wrap, otherwise the accessibility
  92. * features won't work correctly.
  93. */
  94. trigger?: (props: TriggerProps) => 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. } & Partial<MenuTriggerProps> &
  107. Partial<AriaMenuOptions<MenuItemProps>> &
  108. Partial<OverlayProps> &
  109. Partial<AriaPositionProps>;
  110. /**
  111. * A menu component that renders both the trigger button and the dropdown
  112. * menu. See: https://react-spectrum.adobe.com/react-aria/useMenuTrigger.html
  113. */
  114. function MenuControl({
  115. items,
  116. disabledKeys,
  117. trigger,
  118. triggerLabel,
  119. triggerProps = {},
  120. isDisabled: disabledProp,
  121. isSubmenu = false,
  122. closeRootMenu,
  123. closeCurrentSubmenu,
  124. renderWrapAs = 'div',
  125. size = 'md',
  126. className,
  127. ...props
  128. }: Props) {
  129. const ref = useRef<HTMLButtonElement>(null);
  130. const isDisabled = disabledProp ?? (!items || items.length === 0);
  131. // Control the menu open state. See:
  132. // https://react-spectrum.adobe.com/react-aria/useMenuTrigger.html
  133. const state = useMenuTriggerState(props);
  134. const {menuTriggerProps, menuProps} = useMenuTrigger(
  135. {type: 'menu', isDisabled},
  136. state,
  137. ref
  138. );
  139. const {buttonProps} = useButton(
  140. {
  141. isDisabled,
  142. ...menuTriggerProps,
  143. ...(isSubmenu && {
  144. onKeyUp: e => e.continuePropagation(),
  145. onKeyDown: e => e.continuePropagation(),
  146. onPress: () => null,
  147. onPressStart: () => null,
  148. onPressEnd: () => null,
  149. }),
  150. },
  151. ref
  152. );
  153. // Calculate the current trigger element's width. This will be used as
  154. // the min width for the menu.
  155. const [triggerWidth, setTriggerWidth] = useState<number>();
  156. // Update triggerWidth when its size changes using useResizeObserver
  157. const updateTriggerWidth = useCallback(async () => {
  158. // Wait until the trigger element finishes rendering, otherwise
  159. // ResizeObserver might throw an infinite loop error.
  160. await new Promise(resolve => window.setTimeout(resolve));
  161. const newTriggerWidth = ref.current?.offsetWidth;
  162. !isSubmenu && newTriggerWidth && setTriggerWidth(newTriggerWidth);
  163. }, [isSubmenu]);
  164. useResizeObserver({ref, onResize: updateTriggerWidth});
  165. // If ResizeObserver is not available, manually update the width
  166. // when any of [trigger, triggerLabel, triggerProps] changes.
  167. useEffect(() => {
  168. if (typeof window.ResizeObserver !== 'undefined') {
  169. return;
  170. }
  171. updateTriggerWidth();
  172. }, [updateTriggerWidth]);
  173. function renderTrigger() {
  174. if (trigger) {
  175. return trigger({
  176. props: {
  177. size,
  178. isOpen: state.isOpen,
  179. ...triggerProps,
  180. ...buttonProps,
  181. },
  182. ref,
  183. });
  184. }
  185. return (
  186. <DropdownButton
  187. ref={ref}
  188. size={size}
  189. isOpen={state.isOpen}
  190. {...triggerProps}
  191. {...buttonProps}
  192. >
  193. {triggerLabel}
  194. </DropdownButton>
  195. );
  196. }
  197. const activeItems = useMemo(() => removeHiddenItems(items), [items]);
  198. const defaultDisabledKeys = useMemo(() => getDisabledKeys(activeItems), [activeItems]);
  199. function renderMenu() {
  200. if (!state.isOpen) {
  201. return null;
  202. }
  203. return (
  204. <Menu
  205. {...props}
  206. {...menuProps}
  207. triggerRef={ref}
  208. triggerWidth={triggerWidth}
  209. size={size}
  210. isSubmenu={isSubmenu}
  211. isDismissable={!isSubmenu && props.isDismissable}
  212. shouldCloseOnBlur={!isSubmenu && props.shouldCloseOnBlur}
  213. closeRootMenu={closeRootMenu ?? state.close}
  214. closeCurrentSubmenu={closeCurrentSubmenu}
  215. disabledKeys={disabledKeys ?? defaultDisabledKeys}
  216. items={activeItems}
  217. >
  218. {(item: MenuItemProps) => {
  219. if (item.children && item.children.length > 0 && !item.isSubmenu) {
  220. return (
  221. <Section key={item.key} title={item.label} items={item.children}>
  222. {sectionItem => (
  223. <Item size={size} {...sectionItem}>
  224. {sectionItem.label}
  225. </Item>
  226. )}
  227. </Section>
  228. );
  229. }
  230. return (
  231. <Item size={size} {...item}>
  232. {item.label}
  233. </Item>
  234. );
  235. }}
  236. </Menu>
  237. );
  238. }
  239. return (
  240. <MenuControlWrap className={className} as={renderWrapAs} role="presentation">
  241. {renderTrigger()}
  242. {renderMenu()}
  243. </MenuControlWrap>
  244. );
  245. }
  246. export default MenuControl;
  247. const MenuControlWrap = styled('div')`
  248. list-style-type: none;
  249. `;