import {Component} from 'react'; import * as Sentry from '@sentry/react'; import {MENU_CLOSE_DELAY} from 'sentry/constants'; export type GetActorArgs = { className?: string; onBlur?: (e: React.FocusEvent) => void; onChange?: (e: React.ChangeEvent) => void; onClick?: (e: React.MouseEvent) => void; onFocus?: (e: React.FocusEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onMouseEnter?: (e: React.MouseEvent) => void; onMouseLeave?: (e: React.MouseEvent) => void; style?: React.CSSProperties; }; export type GetMenuArgs = { className?: string; onClick?: (e: React.MouseEvent) => void; onKeyDown?: (event: React.KeyboardEvent) => void; onMouseDown?: (e: React.MouseEvent) => void; onMouseEnter?: (e: React.MouseEvent) => void; onMouseLeave?: (e: React.MouseEvent) => void; }; // Props for the "actor" element of `` // This is the element that handles visibility of the dropdown menu type ActorProps = { onClick: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; onMouseEnter: (e: React.MouseEvent) => void; onMouseLeave: (e: React.MouseEvent) => void; }; type MenuProps = { onClick: (e: React.MouseEvent) => void; onMouseEnter: (e: React.MouseEvent) => void; onMouseLeave: (e: React.MouseEvent) => void; role: string; }; export type GetActorPropsFn = ( opts?: GetActorArgs ) => ActorProps; export type GetMenuPropsFn = ( opts?: GetMenuArgs ) => MenuProps; export type MenuActions = { close: (event?: React.MouseEvent) => void; open: (event?: React.MouseEvent) => void; }; type RenderProps = { actions: MenuActions; getActorProps: GetActorPropsFn; getMenuProps: GetMenuPropsFn; getRootProps: Function; isOpen: boolean; }; type DefaultProps = { /** * closes menu on "Esc" keypress */ closeOnEscape: boolean; /** * Keeps dropdown menu open when menu is clicked */ keepMenuOpen: boolean; }; type Props = DefaultProps & { /** * Render function */ children: (renderProps: RenderProps) => React.ReactNode; /** * Compatibility for * This will change where we attach event handlers */ alwaysRenderMenu?: boolean; /** * If this is set to true, the dropdown behaves as a "nested dropdown" and is * triggered on mouse enter and mouse leave */ isNestedDropdown?: boolean; /** * If this is set, then this will become a "controlled" component. * It will no longer set local state and dropdown visibility will * only follow `isOpen`. */ isOpen?: boolean; /** * Callback for when we get a click outside of dropdown menus. * Useful for when menu is controlled. */ onClickOutside?: Function; onClose?: Function; onOpen?: Function; /** * Callback function to check if we should ignore click outside to * hide dropdown menu */ shouldIgnoreClickOutside?: (event: MouseEvent) => boolean; }; type State = { isOpen: boolean; }; /** * Deprecated dropdown menu. Use these alternatives instead: * * - For a select menu: use `CompactSelect` * https://storybook.sentry.dev/?path=/story/components-forms-fields--compact-select-field * * - For an action menu (where there's no selection state, clicking on a menu * item will trigger an action): use `DropdownMenuControl`. * * - For for other menus/overlays: use a combination of `Overlay` and the * `useOverlay` hook. * https://storybook.sentry.dev/?path=/story/components-buttons-dropdowns-overlay--overlay * * @deprecated */ class DropdownMenu extends Component { static defaultProps: DefaultProps = { keepMenuOpen: false, closeOnEscape: true, }; state: State = { isOpen: false, }; componentWillUnmount() { window.clearTimeout(this.mouseLeaveTimeout); window.clearTimeout(this.mouseEnterTimeout); document.removeEventListener('click', this.checkClickOutside, true); } dropdownMenu: Element | null = null; dropdownActor: Element | null = null; mouseLeaveTimeout: number | undefined = undefined; mouseEnterTimeout: number | undefined = undefined; // Gets open state from props or local state when appropriate isOpen = () => { const {isOpen} = this.props; const isControlled = typeof isOpen !== 'undefined'; return (isControlled && isOpen) || this.state.isOpen; }; // Checks if click happens inside of dropdown menu (or its button) // Closes dropdownmenu if it is "outside" checkClickOutside = async (e: MouseEvent) => { const {onClickOutside, shouldIgnoreClickOutside} = this.props; if (!this.dropdownMenu || !this.isOpen()) { return; } if (!(e.target instanceof Element)) { return; } // Dropdown menu itself if (this.dropdownMenu.contains(e.target)) { return; } if (!this.dropdownActor) { // Log an error, should be lower priority Sentry.withScope(scope => { scope.setLevel('warning'); Sentry.captureException(new Error('DropdownMenu does not have "Actor" attached')); }); } // Button that controls visibility of dropdown menu if (this.dropdownActor && this.dropdownActor.contains(e.target)) { return; } if (typeof shouldIgnoreClickOutside === 'function' && shouldIgnoreClickOutside(e)) { return; } if (typeof onClickOutside === 'function') { onClickOutside(e); } // Wait until the current macrotask completes, in the case that the click // happened on a hovercard or some other element rendered outside of the // dropdown, but controlled by the existence of the dropdown, we need to // ensure any click handlers are run. await new Promise(resolve => window.setTimeout(resolve)); this.handleClose(); }; // Opens dropdown menu handleOpen = (e?: React.MouseEvent) => { const {onOpen, isOpen, alwaysRenderMenu, isNestedDropdown} = this.props; const isControlled = typeof isOpen !== 'undefined'; if (!isControlled) { this.setState({ isOpen: true, }); } window.clearTimeout(this.mouseLeaveTimeout); // If we always render menu (e.g. DropdownLink), then add the check click outside handlers when we open the menu // instead of when the menu component mounts. Otherwise we will have many click handlers attached on initial load. if (alwaysRenderMenu || isNestedDropdown) { document.addEventListener('click', this.checkClickOutside, true); } if (typeof onOpen === 'function') { onOpen(e); } }; // Decide whether dropdown should be closed when mouse leaves element // Only for nested dropdowns handleMouseLeave = (e: React.MouseEvent) => { if (!this.props.isNestedDropdown) { return; } const toElement = e.relatedTarget; try { if ( this.dropdownMenu && (!(toElement instanceof Element) || !this.dropdownMenu.contains(toElement)) ) { window.clearTimeout(this.mouseLeaveTimeout); this.mouseLeaveTimeout = window.setTimeout(() => { this.handleClose(e); }, MENU_CLOSE_DELAY); } } catch (err) { Sentry.withScope(scope => { scope.setExtra('event', e); scope.setExtra('relatedTarget', e.relatedTarget); Sentry.captureException(err); }); } }; // Closes dropdown menu handleClose = (e?: React.KeyboardEvent | React.MouseEvent) => { const {onClose, isOpen, alwaysRenderMenu, isNestedDropdown} = this.props; const isControlled = typeof isOpen !== 'undefined'; if (!isControlled) { this.setState({isOpen: false}); } // Clean up click handlers when the menu is closed for menus that are always rendered, // otherwise the click handlers get cleaned up when menu is unmounted if (alwaysRenderMenu || isNestedDropdown) { document.removeEventListener('click', this.checkClickOutside, true); } if (typeof onClose === 'function') { onClose(e); } }; // When dropdown menu is displayed and mounted to DOM, // bind a click handler to `document` to listen for clicks outside of // this component and close menu if so handleMenuMount = (ref: Element | null) => { if (ref && !(ref instanceof Element)) { return; } const {alwaysRenderMenu, isNestedDropdown} = this.props; this.dropdownMenu = ref; // Don't add document event listeners here if we are always rendering menu // Instead add when menu is opened if (alwaysRenderMenu || isNestedDropdown) { return; } if (this.dropdownMenu) { // 3rd arg = useCapture = so event capturing vs event bubbling document.addEventListener('click', this.checkClickOutside, true); } else { document.removeEventListener('click', this.checkClickOutside, true); } }; handleActorMount = (ref: Element | null) => { if (ref && !(ref instanceof Element)) { return; } this.dropdownActor = ref; }; handleToggle = (e: React.MouseEvent) => { if (this.isOpen()) { this.handleClose(e); } else { this.handleOpen(e); } }; // Control whether we should hide dropdown menu when it is clicked handleDropdownMenuClick = (e: React.MouseEvent) => { if (this.props.keepMenuOpen) { return; } this.handleClose(e); }; getRootProps(props: T): T { return props; } // Actor is the component that will open the dropdown menu getActorProps: GetActorPropsFn = ({ onClick, onMouseEnter, onMouseLeave, onKeyDown, style = {}, ...props }: GetActorArgs = {}) => { const {isNestedDropdown, closeOnEscape} = this.props; const refProps = {ref: this.handleActorMount}; // Props that the actor needs to have work return { ...props, ...refProps, style: {...style, outline: 'none'}, 'aria-expanded': this.isOpen(), 'aria-haspopup': 'listbox', onKeyDown: (e: React.KeyboardEvent) => { if (typeof onKeyDown === 'function') { onKeyDown(e); } if (e.key === 'Escape' && closeOnEscape) { this.handleClose(e); } }, onMouseEnter: (e: React.MouseEvent) => { if (typeof onMouseEnter === 'function') { onMouseEnter(e); } // Only handle mouse enter for nested dropdowns if (!isNestedDropdown) { return; } window.clearTimeout(this.mouseEnterTimeout); window.clearTimeout(this.mouseLeaveTimeout); this.mouseEnterTimeout = window.setTimeout(() => { this.handleOpen(e); }, MENU_CLOSE_DELAY); }, onMouseLeave: (e: React.MouseEvent) => { if (typeof onMouseLeave === 'function') { onMouseLeave(e); } window.clearTimeout(this.mouseEnterTimeout); window.clearTimeout(this.mouseLeaveTimeout); this.handleMouseLeave(e); }, onClick: (e: React.MouseEvent) => { // If we are a nested dropdown, clicking the actor // should be a no-op so that the menu doesn't close. if (isNestedDropdown) { e.preventDefault(); e.stopPropagation(); return; } this.handleToggle(e); if (typeof onClick === 'function') { onClick(e); } }, }; }; // Menu is the menu component that will control getMenuProps: GetMenuPropsFn = ({ onClick, onMouseLeave, onMouseEnter, ...props }: GetMenuArgs = {}): MenuProps => { const refProps = {ref: this.handleMenuMount}; // Props that the menu needs to have work return { ...props, ...refProps, role: 'listbox', onMouseEnter: (e: React.MouseEvent) => { onMouseEnter?.(e); // There is a delay before closing a menu on mouse leave, cancel this // action if mouse enters menu again window.clearTimeout(this.mouseLeaveTimeout); }, onMouseLeave: (e: React.MouseEvent) => { onMouseLeave?.(e); this.handleMouseLeave(e); }, onClick: (e: React.MouseEvent) => { this.handleDropdownMenuClick(e); onClick?.(e); }, }; }; render() { const {children} = this.props; // Default anchor = left const shouldShowDropdown = this.isOpen(); return children({ isOpen: shouldShowDropdown, getRootProps: this.getRootProps, getActorProps: this.getActorProps, getMenuProps: this.getMenuProps, actions: { open: this.handleOpen, close: this.handleClose, }, }); } } export default DropdownMenu;