dropdownMenu.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import React from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import {MENU_CLOSE_DELAY} from 'app/constants';
  4. export type GetActorArgs<E extends Element> = {
  5. onClick?: (e: React.MouseEvent<E>) => void;
  6. onMouseEnter?: (e: React.MouseEvent<E>) => void;
  7. onMouseLeave?: (e: React.MouseEvent<E>) => void;
  8. onKeyDown?: (e: React.KeyboardEvent<E>) => void;
  9. onFocus?: (e: React.FocusEvent<E>) => void;
  10. onBlur?: (e: React.FocusEvent<E>) => void;
  11. onChange?: (e: React.ChangeEvent<E>) => void;
  12. style?: React.CSSProperties;
  13. className?: string;
  14. };
  15. export type GetMenuArgs<E extends Element> = {
  16. onClick?: (e: React.MouseEvent<E>) => void;
  17. onMouseEnter?: (e: React.MouseEvent<E>) => void;
  18. onMouseLeave?: (e: React.MouseEvent<E>) => void;
  19. onMouseDown?: (e: React.MouseEvent<E>) => void;
  20. onKeyDown?: (event: React.KeyboardEvent<E>) => void;
  21. className?: string;
  22. itemCount?: number;
  23. };
  24. // Props for the "actor" element of `<DropdownMenu>`
  25. // This is the element that handles visibility of the dropdown menu
  26. type ActorProps<E extends Element> = {
  27. onClick: (e: React.MouseEvent<E>) => void;
  28. onMouseEnter: (e: React.MouseEvent<E>) => void;
  29. onMouseLeave: (e: React.MouseEvent<E>) => void;
  30. onKeyDown: (e: React.KeyboardEvent<E>) => void;
  31. };
  32. type MenuProps<E extends Element> = {
  33. onClick: (e: React.MouseEvent<E>) => void;
  34. onMouseEnter: (e: React.MouseEvent<E>) => void;
  35. onMouseLeave: (e: React.MouseEvent<E>) => void;
  36. };
  37. export type GetActorPropsFn = <E extends Element = Element>(
  38. opts?: GetActorArgs<E>
  39. ) => ActorProps<E>;
  40. export type GetMenuPropsFn = <E extends Element = Element>(
  41. opts?: GetMenuArgs<E>
  42. ) => MenuProps<E>;
  43. type RenderProps = {
  44. isOpen: boolean;
  45. getRootProps: Function;
  46. getActorProps: GetActorPropsFn;
  47. getMenuProps: GetMenuPropsFn;
  48. actions: {
  49. open: (event?: React.MouseEvent<Element>) => void;
  50. close: (event?: React.MouseEvent<Element>) => void;
  51. };
  52. };
  53. type DefaultProps = {
  54. /**
  55. * Keeps dropdown menu open when menu is clicked
  56. */
  57. keepMenuOpen: boolean;
  58. /**
  59. * closes menu on "Esc" keypress
  60. */
  61. closeOnEscape: boolean;
  62. };
  63. type Props = DefaultProps & {
  64. onOpen?: Function;
  65. onClose?: Function;
  66. /**
  67. * Callback for when we get a click outside of dropdown menus.
  68. * Useful for when menu is controlled.
  69. */
  70. onClickOutside?: Function;
  71. /**
  72. * Callback function to check if we should ignore click outside to
  73. * hide dropdown menu
  74. */
  75. shouldIgnoreClickOutside?: (event: MouseEvent) => boolean;
  76. /**
  77. * If this is set, then this will become a "controlled" component.
  78. * It will no longer set local state and dropdown visibility will
  79. * only follow `isOpen`.
  80. */
  81. isOpen?: boolean;
  82. /**
  83. * Compatibility for <DropdownLink>
  84. * This will change where we attach event handlers
  85. */
  86. alwaysRenderMenu?: boolean;
  87. /**
  88. * If this is set to true, the dropdown behaves as a "nested dropdown" and is
  89. * triggered on mouse enter and mouse leave
  90. */
  91. isNestedDropdown?: boolean;
  92. /**
  93. * Render function
  94. */
  95. children: (renderProps: RenderProps) => React.ReactNode;
  96. };
  97. type State = {
  98. isOpen: boolean;
  99. };
  100. class DropdownMenu extends React.Component<Props, State> {
  101. static defaultProps: DefaultProps = {
  102. keepMenuOpen: false,
  103. closeOnEscape: true,
  104. };
  105. state: State = {
  106. isOpen: false,
  107. };
  108. componentWillUnmount() {
  109. document.removeEventListener('click', this.checkClickOutside, true);
  110. }
  111. dropdownMenu: Element | null = null;
  112. dropdownActor: Element | null = null;
  113. mouseLeaveId: number | null = null;
  114. mouseEnterId: number | null = null;
  115. // Gets open state from props or local state when appropriate
  116. isOpen = () => {
  117. const {isOpen} = this.props;
  118. const isControlled = typeof isOpen !== 'undefined';
  119. return (isControlled && isOpen) || this.state.isOpen;
  120. };
  121. // Checks if click happens inside of dropdown menu (or its button)
  122. // Closes dropdownmenu if it is "outside"
  123. checkClickOutside = async (e: MouseEvent) => {
  124. const {onClickOutside, shouldIgnoreClickOutside} = this.props;
  125. if (!this.dropdownMenu || !this.isOpen()) {
  126. return;
  127. }
  128. if (!(e.target instanceof Element)) {
  129. return;
  130. }
  131. // Dropdown menu itself
  132. if (this.dropdownMenu.contains(e.target)) {
  133. return;
  134. }
  135. if (!this.dropdownActor) {
  136. // Log an error, should be lower priority
  137. Sentry.withScope(scope => {
  138. scope.setLevel(Sentry.Severity.Warning);
  139. Sentry.captureException(new Error('DropdownMenu does not have "Actor" attached'));
  140. });
  141. }
  142. // Button that controls visibility of dropdown menu
  143. if (this.dropdownActor && this.dropdownActor.contains(e.target)) {
  144. return;
  145. }
  146. if (typeof shouldIgnoreClickOutside === 'function' && shouldIgnoreClickOutside(e)) {
  147. return;
  148. }
  149. if (typeof onClickOutside === 'function') {
  150. onClickOutside(e);
  151. }
  152. // Wait until the current macrotask completes, in the case that the click
  153. // happened on a hovercard or some other element rendered outside of the
  154. // dropdown, but controlled by the existence of the dropdown, we need to
  155. // ensure any click handlers are run.
  156. await new Promise(resolve => setTimeout(resolve));
  157. this.handleClose();
  158. };
  159. // Opens dropdown menu
  160. handleOpen = (e?: React.MouseEvent<Element>) => {
  161. const {onOpen, isOpen, alwaysRenderMenu, isNestedDropdown} = this.props;
  162. const isControlled = typeof isOpen !== 'undefined';
  163. if (!isControlled) {
  164. this.setState({
  165. isOpen: true,
  166. });
  167. }
  168. if (this.mouseLeaveId) {
  169. window.clearTimeout(this.mouseLeaveId);
  170. }
  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. const {isNestedDropdown} = this.props;
  184. if (!isNestedDropdown) {
  185. return;
  186. }
  187. const toElement = e.relatedTarget;
  188. try {
  189. if (
  190. this.dropdownMenu &&
  191. (!(toElement instanceof Element) || !this.dropdownMenu.contains(toElement))
  192. ) {
  193. this.mouseLeaveId = 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. if (this.mouseLeaveId) {
  298. window.clearTimeout(this.mouseLeaveId);
  299. }
  300. this.mouseEnterId = window.setTimeout(() => {
  301. this.handleOpen(e);
  302. }, MENU_CLOSE_DELAY);
  303. },
  304. onMouseLeave: (e: React.MouseEvent<E>) => {
  305. if (typeof onMouseLeave === 'function') {
  306. onMouseLeave(e);
  307. }
  308. if (this.mouseEnterId) {
  309. window.clearTimeout(this.mouseEnterId);
  310. }
  311. this.handleMouseLeave(e);
  312. },
  313. onClick: (e: React.MouseEvent<E>) => {
  314. // If we are a nested dropdown, clicking the actor
  315. // should be a no-op so that the menu doesn't close.
  316. if (isNestedDropdown) {
  317. e.preventDefault();
  318. e.stopPropagation();
  319. return;
  320. }
  321. this.handleToggle(e);
  322. if (typeof onClick === 'function') {
  323. onClick(e);
  324. }
  325. },
  326. };
  327. };
  328. // Menu is the menu component that <DropdownMenu> will control
  329. getMenuProps: GetMenuPropsFn = <E extends Element = Element>({
  330. onClick,
  331. onMouseLeave,
  332. onMouseEnter,
  333. ...props
  334. }: GetMenuArgs<E> = {}): MenuProps<E> => {
  335. const refProps = {ref: this.handleMenuMount};
  336. // Props that the menu needs to have <DropdownMenu> work
  337. return {
  338. ...props,
  339. ...refProps,
  340. onMouseEnter: (e: React.MouseEvent<E>) => {
  341. if (typeof onMouseEnter === 'function') {
  342. onMouseEnter(e);
  343. }
  344. // There is a delay before closing a menu on mouse leave, cancel this action if mouse enters menu again
  345. if (this.mouseLeaveId) {
  346. window.clearTimeout(this.mouseLeaveId);
  347. }
  348. },
  349. onMouseLeave: (e: React.MouseEvent<E>) => {
  350. if (typeof onMouseLeave === 'function') {
  351. onMouseLeave(e);
  352. }
  353. this.handleMouseLeave(e);
  354. },
  355. onClick: (e: React.MouseEvent<E>) => {
  356. this.handleDropdownMenuClick(e);
  357. if (typeof onClick === 'function') {
  358. onClick(e);
  359. }
  360. },
  361. };
  362. };
  363. render() {
  364. const {children} = this.props;
  365. // Default anchor = left
  366. const shouldShowDropdown = this.isOpen();
  367. return children({
  368. isOpen: shouldShowDropdown,
  369. getRootProps: this.getRootProps,
  370. getActorProps: this.getActorProps,
  371. getMenuProps: this.getMenuProps,
  372. actions: {
  373. open: this.handleOpen,
  374. close: this.handleClose,
  375. },
  376. });
  377. }
  378. }
  379. export default DropdownMenu;