dropdownMenu.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. import {Component} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import {MENU_CLOSE_DELAY} from 'sentry/constants';
  4. export type GetActorArgs<E extends Element> = {
  5. className?: string;
  6. onBlur?: (e: React.FocusEvent<E>) => void;
  7. onChange?: (e: React.ChangeEvent<E>) => void;
  8. onClick?: (e: React.MouseEvent<E>) => void;
  9. onFocus?: (e: React.FocusEvent<E>) => void;
  10. onKeyDown?: (e: React.KeyboardEvent<E>) => void;
  11. onMouseEnter?: (e: React.MouseEvent<E>) => void;
  12. onMouseLeave?: (e: React.MouseEvent<E>) => void;
  13. style?: React.CSSProperties;
  14. };
  15. export type GetMenuArgs<E extends Element> = {
  16. className?: string;
  17. onClick?: (e: React.MouseEvent<E>) => void;
  18. onKeyDown?: (event: React.KeyboardEvent<E>) => void;
  19. onMouseDown?: (e: React.MouseEvent<E>) => void;
  20. onMouseEnter?: (e: React.MouseEvent<E>) => void;
  21. onMouseLeave?: (e: React.MouseEvent<E>) => void;
  22. };
  23. // Props for the "actor" element of `<DropdownMenu>`
  24. // This is the element that handles visibility of the dropdown menu
  25. type ActorProps<E extends Element> = {
  26. onClick: (e: React.MouseEvent<E>) => void;
  27. onKeyDown: (e: React.KeyboardEvent<E>) => void;
  28. onMouseEnter: (e: React.MouseEvent<E>) => void;
  29. onMouseLeave: (e: React.MouseEvent<E>) => void;
  30. };
  31. type MenuProps<E extends Element> = {
  32. onClick: (e: React.MouseEvent<E>) => void;
  33. onMouseEnter: (e: React.MouseEvent<E>) => void;
  34. onMouseLeave: (e: React.MouseEvent<E>) => void;
  35. };
  36. export type GetActorPropsFn = <E extends Element = Element>(
  37. opts?: GetActorArgs<E>
  38. ) => ActorProps<E>;
  39. export type GetMenuPropsFn = <E extends Element = Element>(
  40. opts?: GetMenuArgs<E>
  41. ) => MenuProps<E>;
  42. export type MenuActions = {
  43. close: (event?: React.MouseEvent<Element>) => void;
  44. open: (event?: React.MouseEvent<Element>) => void;
  45. };
  46. type RenderProps = {
  47. actions: MenuActions;
  48. getActorProps: GetActorPropsFn;
  49. getMenuProps: GetMenuPropsFn;
  50. getRootProps: Function;
  51. isOpen: boolean;
  52. };
  53. type DefaultProps = {
  54. /**
  55. * closes menu on "Esc" keypress
  56. */
  57. closeOnEscape: boolean;
  58. /**
  59. * Keeps dropdown menu open when menu is clicked
  60. */
  61. keepMenuOpen: boolean;
  62. };
  63. type Props = DefaultProps & {
  64. /**
  65. * Render function
  66. */
  67. children: (renderProps: RenderProps) => React.ReactNode;
  68. /**
  69. * Compatibility for <DropdownLink>
  70. * This will change where we attach event handlers
  71. */
  72. alwaysRenderMenu?: boolean;
  73. /**
  74. * If this is set to true, the dropdown behaves as a "nested dropdown" and is
  75. * triggered on mouse enter and mouse leave
  76. */
  77. isNestedDropdown?: boolean;
  78. /**
  79. * If this is set, then this will become a "controlled" component.
  80. * It will no longer set local state and dropdown visibility will
  81. * only follow `isOpen`.
  82. */
  83. isOpen?: boolean;
  84. /**
  85. * Callback for when we get a click outside of dropdown menus.
  86. * Useful for when menu is controlled.
  87. */
  88. onClickOutside?: Function;
  89. onClose?: Function;
  90. onOpen?: Function;
  91. /**
  92. * Callback function to check if we should ignore click outside to
  93. * hide dropdown menu
  94. */
  95. shouldIgnoreClickOutside?: (event: MouseEvent) => boolean;
  96. };
  97. type State = {
  98. isOpen: boolean;
  99. };
  100. class DropdownMenu extends Component<Props, State> {
  101. static defaultProps: DefaultProps = {
  102. keepMenuOpen: false,
  103. closeOnEscape: true,
  104. };
  105. state: State = {
  106. isOpen: false,
  107. };
  108. componentWillUnmount() {
  109. window.clearTimeout(this.mouseLeaveTimeout);
  110. window.clearTimeout(this.mouseEnterTimeout);
  111. document.removeEventListener('click', this.checkClickOutside, true);
  112. }
  113. dropdownMenu: Element | null = null;
  114. dropdownActor: Element | null = null;
  115. mouseLeaveTimeout: number | undefined = undefined;
  116. mouseEnterTimeout: number | undefined = undefined;
  117. // Gets open state from props or local state when appropriate
  118. isOpen = () => {
  119. const {isOpen} = this.props;
  120. const isControlled = typeof isOpen !== 'undefined';
  121. return (isControlled && isOpen) || this.state.isOpen;
  122. };
  123. // Checks if click happens inside of dropdown menu (or its button)
  124. // Closes dropdownmenu if it is "outside"
  125. checkClickOutside = async (e: MouseEvent) => {
  126. const {onClickOutside, shouldIgnoreClickOutside} = this.props;
  127. if (!this.dropdownMenu || !this.isOpen()) {
  128. return;
  129. }
  130. if (!(e.target instanceof Element)) {
  131. return;
  132. }
  133. // Dropdown menu itself
  134. if (this.dropdownMenu.contains(e.target)) {
  135. return;
  136. }
  137. if (!this.dropdownActor) {
  138. // Log an error, should be lower priority
  139. Sentry.withScope(scope => {
  140. scope.setLevel('warning');
  141. Sentry.captureException(new Error('DropdownMenu does not have "Actor" attached'));
  142. });
  143. }
  144. // Button that controls visibility of dropdown menu
  145. if (this.dropdownActor && this.dropdownActor.contains(e.target)) {
  146. return;
  147. }
  148. if (typeof shouldIgnoreClickOutside === 'function' && shouldIgnoreClickOutside(e)) {
  149. return;
  150. }
  151. if (typeof onClickOutside === 'function') {
  152. onClickOutside(e);
  153. }
  154. // Wait until the current macrotask completes, in the case that the click
  155. // happened on a hovercard or some other element rendered outside of the
  156. // dropdown, but controlled by the existence of the dropdown, we need to
  157. // ensure any click handlers are run.
  158. await new Promise(resolve => window.setTimeout(resolve));
  159. this.handleClose();
  160. };
  161. // Opens dropdown menu
  162. handleOpen = (e?: React.MouseEvent<Element>) => {
  163. const {onOpen, isOpen, alwaysRenderMenu, isNestedDropdown} = this.props;
  164. const isControlled = typeof isOpen !== 'undefined';
  165. if (!isControlled) {
  166. this.setState({
  167. isOpen: true,
  168. });
  169. }
  170. window.clearTimeout(this.mouseLeaveTimeout);
  171. // If we always render menu (e.g. DropdownLink), then add the check click outside handlers when we open the menu
  172. // instead of when the menu component mounts. Otherwise we will have many click handlers attached on initial load.
  173. if (alwaysRenderMenu || isNestedDropdown) {
  174. document.addEventListener('click', this.checkClickOutside, true);
  175. }
  176. if (typeof onOpen === 'function') {
  177. onOpen(e);
  178. }
  179. };
  180. // Decide whether dropdown should be closed when mouse leaves element
  181. // Only for nested dropdowns
  182. handleMouseLeave = (e: React.MouseEvent<Element>) => {
  183. if (!this.props.isNestedDropdown) {
  184. return;
  185. }
  186. const toElement = e.relatedTarget;
  187. try {
  188. if (
  189. this.dropdownMenu &&
  190. (!(toElement instanceof Element) || !this.dropdownMenu.contains(toElement))
  191. ) {
  192. window.clearTimeout(this.mouseLeaveTimeout);
  193. this.mouseLeaveTimeout = window.setTimeout(() => {
  194. this.handleClose(e);
  195. }, MENU_CLOSE_DELAY);
  196. }
  197. } catch (err) {
  198. Sentry.withScope(scope => {
  199. scope.setExtra('event', e);
  200. scope.setExtra('relatedTarget', e.relatedTarget);
  201. Sentry.captureException(err);
  202. });
  203. }
  204. };
  205. // Closes dropdown menu
  206. handleClose = (e?: React.KeyboardEvent<Element> | React.MouseEvent<Element>) => {
  207. const {onClose, isOpen, alwaysRenderMenu, isNestedDropdown} = this.props;
  208. const isControlled = typeof isOpen !== 'undefined';
  209. if (!isControlled) {
  210. this.setState({isOpen: false});
  211. }
  212. // Clean up click handlers when the menu is closed for menus that are always rendered,
  213. // otherwise the click handlers get cleaned up when menu is unmounted
  214. if (alwaysRenderMenu || isNestedDropdown) {
  215. document.removeEventListener('click', this.checkClickOutside, true);
  216. }
  217. if (typeof onClose === 'function') {
  218. onClose(e);
  219. }
  220. };
  221. // When dropdown menu is displayed and mounted to DOM,
  222. // bind a click handler to `document` to listen for clicks outside of
  223. // this component and close menu if so
  224. handleMenuMount = (ref: Element | null) => {
  225. if (ref && !(ref instanceof Element)) {
  226. return;
  227. }
  228. const {alwaysRenderMenu, isNestedDropdown} = this.props;
  229. this.dropdownMenu = ref;
  230. // Don't add document event listeners here if we are always rendering menu
  231. // Instead add when menu is opened
  232. if (alwaysRenderMenu || isNestedDropdown) {
  233. return;
  234. }
  235. if (this.dropdownMenu) {
  236. // 3rd arg = useCapture = so event capturing vs event bubbling
  237. document.addEventListener('click', this.checkClickOutside, true);
  238. } else {
  239. document.removeEventListener('click', this.checkClickOutside, true);
  240. }
  241. };
  242. handleActorMount = (ref: Element | null) => {
  243. if (ref && !(ref instanceof Element)) {
  244. return;
  245. }
  246. this.dropdownActor = ref;
  247. };
  248. handleToggle = (e: React.MouseEvent<Element>) => {
  249. if (this.isOpen()) {
  250. this.handleClose(e);
  251. } else {
  252. this.handleOpen(e);
  253. }
  254. };
  255. // Control whether we should hide dropdown menu when it is clicked
  256. handleDropdownMenuClick = (e: React.MouseEvent<Element>) => {
  257. if (this.props.keepMenuOpen) {
  258. return;
  259. }
  260. this.handleClose(e);
  261. };
  262. getRootProps<T>(props: T): T {
  263. return props;
  264. }
  265. // Actor is the component that will open the dropdown menu
  266. getActorProps: GetActorPropsFn = <E extends Element = Element>({
  267. onClick,
  268. onMouseEnter,
  269. onMouseLeave,
  270. onKeyDown,
  271. style = {},
  272. ...props
  273. }: GetActorArgs<E> = {}) => {
  274. const {isNestedDropdown, closeOnEscape} = this.props;
  275. const refProps = {ref: this.handleActorMount};
  276. // Props that the actor needs to have <DropdownMenu> work
  277. return {
  278. ...props,
  279. ...refProps,
  280. style: {...style, outline: 'none'},
  281. onKeyDown: (e: React.KeyboardEvent<E>) => {
  282. if (typeof onKeyDown === 'function') {
  283. onKeyDown(e);
  284. }
  285. if (e.key === 'Escape' && closeOnEscape) {
  286. this.handleClose(e);
  287. }
  288. },
  289. onMouseEnter: (e: React.MouseEvent<E>) => {
  290. if (typeof onMouseEnter === 'function') {
  291. onMouseEnter(e);
  292. }
  293. // Only handle mouse enter for nested dropdowns
  294. if (!isNestedDropdown) {
  295. return;
  296. }
  297. window.clearTimeout(this.mouseEnterTimeout);
  298. window.clearTimeout(this.mouseLeaveTimeout);
  299. this.mouseEnterTimeout = window.setTimeout(() => {
  300. this.handleOpen(e);
  301. }, MENU_CLOSE_DELAY);
  302. },
  303. onMouseLeave: (e: React.MouseEvent<E>) => {
  304. if (typeof onMouseLeave === 'function') {
  305. onMouseLeave(e);
  306. }
  307. window.clearTimeout(this.mouseEnterTimeout);
  308. window.clearTimeout(this.mouseLeaveTimeout);
  309. this.handleMouseLeave(e);
  310. },
  311. onClick: (e: React.MouseEvent<E>) => {
  312. // If we are a nested dropdown, clicking the actor
  313. // should be a no-op so that the menu doesn't close.
  314. if (isNestedDropdown) {
  315. e.preventDefault();
  316. e.stopPropagation();
  317. return;
  318. }
  319. this.handleToggle(e);
  320. if (typeof onClick === 'function') {
  321. onClick(e);
  322. }
  323. },
  324. };
  325. };
  326. // Menu is the menu component that <DropdownMenu> will control
  327. getMenuProps: GetMenuPropsFn = <E extends Element = Element>({
  328. onClick,
  329. onMouseLeave,
  330. onMouseEnter,
  331. ...props
  332. }: GetMenuArgs<E> = {}): MenuProps<E> => {
  333. const refProps = {ref: this.handleMenuMount};
  334. // Props that the menu needs to have <DropdownMenu> work
  335. return {
  336. ...props,
  337. ...refProps,
  338. onMouseEnter: (e: React.MouseEvent<E>) => {
  339. onMouseEnter?.(e);
  340. // There is a delay before closing a menu on mouse leave, cancel this
  341. // action if mouse enters menu again
  342. window.clearTimeout(this.mouseLeaveTimeout);
  343. },
  344. onMouseLeave: (e: React.MouseEvent<E>) => {
  345. onMouseLeave?.(e);
  346. this.handleMouseLeave(e);
  347. },
  348. onClick: (e: React.MouseEvent<E>) => {
  349. this.handleDropdownMenuClick(e);
  350. onClick?.(e);
  351. },
  352. };
  353. };
  354. render() {
  355. const {children} = this.props;
  356. // Default anchor = left
  357. const shouldShowDropdown = this.isOpen();
  358. return children({
  359. isOpen: shouldShowDropdown,
  360. getRootProps: this.getRootProps,
  361. getActorProps: this.getActorProps,
  362. getMenuProps: this.getMenuProps,
  363. actions: {
  364. open: this.handleOpen,
  365. close: this.handleClose,
  366. },
  367. });
  368. }
  369. }
  370. export default DropdownMenu;