dropdownMenu.tsx 12 KB

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