dropdownMenu.tsx 12 KB

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