deprecatedDropdownMenu.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  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. /**
  102. * Deprecated dropdown menu. Use these alternatives instead:
  103. *
  104. * - For a select menu: use `CompactSelect`
  105. * https://storybook.sentry.dev/?path=/story/components-forms-fields--compact-select-field
  106. *
  107. * - For an action menu (where there's no selection state, clicking on a menu
  108. * item will trigger an action): use `DropdownMenuControl`.
  109. *
  110. * - For for other menus/overlays: use a combination of `Overlay` and the
  111. * `useOverlay` hook.
  112. * https://storybook.sentry.dev/?path=/story/components-buttons-dropdowns-overlay--overlay
  113. *
  114. * @deprecated
  115. */
  116. class DropdownMenu extends Component<Props, State> {
  117. static defaultProps: DefaultProps = {
  118. keepMenuOpen: false,
  119. closeOnEscape: true,
  120. };
  121. state: State = {
  122. isOpen: false,
  123. };
  124. componentWillUnmount() {
  125. window.clearTimeout(this.mouseLeaveTimeout);
  126. window.clearTimeout(this.mouseEnterTimeout);
  127. document.removeEventListener('click', this.checkClickOutside, true);
  128. }
  129. dropdownMenu: Element | null = null;
  130. dropdownActor: Element | null = null;
  131. mouseLeaveTimeout: number | undefined = undefined;
  132. mouseEnterTimeout: number | undefined = undefined;
  133. // Gets open state from props or local state when appropriate
  134. isOpen = () => {
  135. const {isOpen} = this.props;
  136. const isControlled = typeof isOpen !== 'undefined';
  137. return (isControlled && isOpen) || this.state.isOpen;
  138. };
  139. // Checks if click happens inside of dropdown menu (or its button)
  140. // Closes dropdownmenu if it is "outside"
  141. checkClickOutside = async (e: MouseEvent) => {
  142. const {onClickOutside, shouldIgnoreClickOutside} = this.props;
  143. if (!this.dropdownMenu || !this.isOpen()) {
  144. return;
  145. }
  146. if (!(e.target instanceof Element)) {
  147. return;
  148. }
  149. // Dropdown menu itself
  150. if (this.dropdownMenu.contains(e.target)) {
  151. return;
  152. }
  153. if (!this.dropdownActor) {
  154. // Log an error, should be lower priority
  155. Sentry.withScope(scope => {
  156. scope.setLevel('warning');
  157. Sentry.captureException(new Error('DropdownMenu does not have "Actor" attached'));
  158. });
  159. }
  160. // Button that controls visibility of dropdown menu
  161. if (this.dropdownActor && this.dropdownActor.contains(e.target)) {
  162. return;
  163. }
  164. if (typeof shouldIgnoreClickOutside === 'function' && shouldIgnoreClickOutside(e)) {
  165. return;
  166. }
  167. if (typeof onClickOutside === 'function') {
  168. onClickOutside(e);
  169. }
  170. // Wait until the current macrotask completes, in the case that the click
  171. // happened on a hovercard or some other element rendered outside of the
  172. // dropdown, but controlled by the existence of the dropdown, we need to
  173. // ensure any click handlers are run.
  174. await new Promise(resolve => window.setTimeout(resolve));
  175. this.handleClose();
  176. };
  177. // Opens dropdown menu
  178. handleOpen = (e?: React.MouseEvent<Element>) => {
  179. const {onOpen, isOpen, alwaysRenderMenu, isNestedDropdown} = this.props;
  180. const isControlled = typeof isOpen !== 'undefined';
  181. if (!isControlled) {
  182. this.setState({
  183. isOpen: true,
  184. });
  185. }
  186. window.clearTimeout(this.mouseLeaveTimeout);
  187. // If we always render menu (e.g. DropdownLink), then add the check click outside handlers when we open the menu
  188. // instead of when the menu component mounts. Otherwise we will have many click handlers attached on initial load.
  189. if (alwaysRenderMenu || isNestedDropdown) {
  190. document.addEventListener('click', this.checkClickOutside, true);
  191. }
  192. if (typeof onOpen === 'function') {
  193. onOpen(e);
  194. }
  195. };
  196. // Decide whether dropdown should be closed when mouse leaves element
  197. // Only for nested dropdowns
  198. handleMouseLeave = (e: React.MouseEvent<Element>) => {
  199. if (!this.props.isNestedDropdown) {
  200. return;
  201. }
  202. const toElement = e.relatedTarget;
  203. try {
  204. if (
  205. this.dropdownMenu &&
  206. (!(toElement instanceof Element) || !this.dropdownMenu.contains(toElement))
  207. ) {
  208. window.clearTimeout(this.mouseLeaveTimeout);
  209. this.mouseLeaveTimeout = window.setTimeout(() => {
  210. this.handleClose(e);
  211. }, MENU_CLOSE_DELAY);
  212. }
  213. } catch (err) {
  214. Sentry.withScope(scope => {
  215. scope.setExtra('event', e);
  216. scope.setExtra('relatedTarget', e.relatedTarget);
  217. Sentry.captureException(err);
  218. });
  219. }
  220. };
  221. // Closes dropdown menu
  222. handleClose = (e?: React.KeyboardEvent<Element> | React.MouseEvent<Element>) => {
  223. const {onClose, isOpen, alwaysRenderMenu, isNestedDropdown} = this.props;
  224. const isControlled = typeof isOpen !== 'undefined';
  225. if (!isControlled) {
  226. this.setState({isOpen: false});
  227. }
  228. // Clean up click handlers when the menu is closed for menus that are always rendered,
  229. // otherwise the click handlers get cleaned up when menu is unmounted
  230. if (alwaysRenderMenu || isNestedDropdown) {
  231. document.removeEventListener('click', this.checkClickOutside, true);
  232. }
  233. if (typeof onClose === 'function') {
  234. onClose(e);
  235. }
  236. };
  237. // When dropdown menu is displayed and mounted to DOM,
  238. // bind a click handler to `document` to listen for clicks outside of
  239. // this component and close menu if so
  240. handleMenuMount = (ref: Element | null) => {
  241. if (ref && !(ref instanceof Element)) {
  242. return;
  243. }
  244. const {alwaysRenderMenu, isNestedDropdown} = this.props;
  245. this.dropdownMenu = ref;
  246. // Don't add document event listeners here if we are always rendering menu
  247. // Instead add when menu is opened
  248. if (alwaysRenderMenu || isNestedDropdown) {
  249. return;
  250. }
  251. if (this.dropdownMenu) {
  252. // 3rd arg = useCapture = so event capturing vs event bubbling
  253. document.addEventListener('click', this.checkClickOutside, true);
  254. } else {
  255. document.removeEventListener('click', this.checkClickOutside, true);
  256. }
  257. };
  258. handleActorMount = (ref: Element | null) => {
  259. if (ref && !(ref instanceof Element)) {
  260. return;
  261. }
  262. this.dropdownActor = ref;
  263. };
  264. handleToggle = (e: React.MouseEvent<Element>) => {
  265. if (this.isOpen()) {
  266. this.handleClose(e);
  267. } else {
  268. this.handleOpen(e);
  269. }
  270. };
  271. // Control whether we should hide dropdown menu when it is clicked
  272. handleDropdownMenuClick = (e: React.MouseEvent<Element>) => {
  273. if (this.props.keepMenuOpen) {
  274. return;
  275. }
  276. this.handleClose(e);
  277. };
  278. getRootProps<T>(props: T): T {
  279. return props;
  280. }
  281. // Actor is the component that will open the dropdown menu
  282. getActorProps: GetActorPropsFn = <E extends Element = Element>({
  283. onClick,
  284. onMouseEnter,
  285. onMouseLeave,
  286. onKeyDown,
  287. style = {},
  288. ...props
  289. }: GetActorArgs<E> = {}) => {
  290. const {isNestedDropdown, closeOnEscape} = this.props;
  291. const refProps = {ref: this.handleActorMount};
  292. // Props that the actor needs to have <DropdownMenu> work
  293. return {
  294. ...props,
  295. ...refProps,
  296. style: {...style, outline: 'none'},
  297. 'aria-expanded': this.isOpen(),
  298. 'aria-haspopup': 'listbox',
  299. onKeyDown: (e: React.KeyboardEvent<E>) => {
  300. if (typeof onKeyDown === 'function') {
  301. onKeyDown(e);
  302. }
  303. if (e.key === 'Escape' && closeOnEscape) {
  304. this.handleClose(e);
  305. }
  306. },
  307. onMouseEnter: (e: React.MouseEvent<E>) => {
  308. if (typeof onMouseEnter === 'function') {
  309. onMouseEnter(e);
  310. }
  311. // Only handle mouse enter for nested dropdowns
  312. if (!isNestedDropdown) {
  313. return;
  314. }
  315. window.clearTimeout(this.mouseEnterTimeout);
  316. window.clearTimeout(this.mouseLeaveTimeout);
  317. this.mouseEnterTimeout = window.setTimeout(() => {
  318. this.handleOpen(e);
  319. }, MENU_CLOSE_DELAY);
  320. },
  321. onMouseLeave: (e: React.MouseEvent<E>) => {
  322. if (typeof onMouseLeave === 'function') {
  323. onMouseLeave(e);
  324. }
  325. window.clearTimeout(this.mouseEnterTimeout);
  326. window.clearTimeout(this.mouseLeaveTimeout);
  327. this.handleMouseLeave(e);
  328. },
  329. onClick: (e: React.MouseEvent<E>) => {
  330. // If we are a nested dropdown, clicking the actor
  331. // should be a no-op so that the menu doesn't close.
  332. if (isNestedDropdown) {
  333. e.preventDefault();
  334. e.stopPropagation();
  335. return;
  336. }
  337. this.handleToggle(e);
  338. if (typeof onClick === 'function') {
  339. onClick(e);
  340. }
  341. },
  342. };
  343. };
  344. // Menu is the menu component that <DropdownMenu> will control
  345. getMenuProps: GetMenuPropsFn = <E extends Element = Element>({
  346. onClick,
  347. onMouseLeave,
  348. onMouseEnter,
  349. ...props
  350. }: GetMenuArgs<E> = {}): MenuProps<E> => {
  351. const refProps = {ref: this.handleMenuMount};
  352. // Props that the menu needs to have <DropdownMenu> work
  353. return {
  354. ...props,
  355. ...refProps,
  356. role: 'listbox',
  357. onMouseEnter: (e: React.MouseEvent<E>) => {
  358. onMouseEnter?.(e);
  359. // There is a delay before closing a menu on mouse leave, cancel this
  360. // action if mouse enters menu again
  361. window.clearTimeout(this.mouseLeaveTimeout);
  362. },
  363. onMouseLeave: (e: React.MouseEvent<E>) => {
  364. onMouseLeave?.(e);
  365. this.handleMouseLeave(e);
  366. },
  367. onClick: (e: React.MouseEvent<E>) => {
  368. this.handleDropdownMenuClick(e);
  369. onClick?.(e);
  370. },
  371. };
  372. };
  373. render() {
  374. const {children} = this.props;
  375. // Default anchor = left
  376. const shouldShowDropdown = this.isOpen();
  377. return children({
  378. isOpen: shouldShowDropdown,
  379. getRootProps: this.getRootProps,
  380. getActorProps: this.getActorProps,
  381. getMenuProps: this.getMenuProps,
  382. actions: {
  383. open: this.handleOpen,
  384. close: this.handleClose,
  385. },
  386. });
  387. }
  388. }
  389. export default DropdownMenu;