Files
sentinel/static/app/components/deprecatedDropdownMenu.tsx
2024-02-15 17:01:50 +00:00

460 lines
13 KiB
TypeScript

import {Component} from 'react';
import * as Sentry from '@sentry/react';
import {MENU_CLOSE_DELAY} from 'sentry/constants';
export type GetActorArgs<E extends Element> = {
className?: string;
onBlur?: (e: React.FocusEvent<E>) => void;
onChange?: (e: React.ChangeEvent<E>) => void;
onClick?: (e: React.MouseEvent<E>) => void;
onFocus?: (e: React.FocusEvent<E>) => void;
onKeyDown?: (e: React.KeyboardEvent<E>) => void;
onMouseEnter?: (e: React.MouseEvent<E>) => void;
onMouseLeave?: (e: React.MouseEvent<E>) => void;
style?: React.CSSProperties;
};
export type GetMenuArgs<E extends Element> = {
className?: string;
onClick?: (e: React.MouseEvent<E>) => void;
onKeyDown?: (event: React.KeyboardEvent<E>) => void;
onMouseDown?: (e: React.MouseEvent<E>) => void;
onMouseEnter?: (e: React.MouseEvent<E>) => void;
onMouseLeave?: (e: React.MouseEvent<E>) => void;
};
// Props for the "actor" element of `<DropdownMenu>`
// This is the element that handles visibility of the dropdown menu
type ActorProps<E extends Element> = {
onClick: (e: React.MouseEvent<E>) => void;
onKeyDown: (e: React.KeyboardEvent<E>) => void;
onMouseEnter: (e: React.MouseEvent<E>) => void;
onMouseLeave: (e: React.MouseEvent<E>) => void;
};
type MenuProps<E extends Element> = {
onClick: (e: React.MouseEvent<E>) => void;
onMouseEnter: (e: React.MouseEvent<E>) => void;
onMouseLeave: (e: React.MouseEvent<E>) => void;
role: string;
};
export type GetActorPropsFn = <E extends Element = Element>(
opts?: GetActorArgs<E>
) => ActorProps<E>;
export type GetMenuPropsFn = <E extends Element = Element>(
opts?: GetMenuArgs<E>
) => MenuProps<E>;
export type MenuActions = {
close: (event?: React.MouseEvent<Element>) => void;
open: (event?: React.MouseEvent<Element>) => 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 <DropdownLink>
* 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<Props, State> {
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?.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<Element>) => {
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<Element>) => {
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<Element> | React.MouseEvent<Element>) => {
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<Element>) => {
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<Element>) => {
if (this.props.keepMenuOpen) {
return;
}
this.handleClose(e);
};
getRootProps<T>(props: T): T {
return props;
}
// Actor is the component that will open the dropdown menu
getActorProps: GetActorPropsFn = <E extends Element = Element>({
onClick,
onMouseEnter,
onMouseLeave,
onKeyDown,
style = {},
...props
}: GetActorArgs<E> = {}) => {
const {isNestedDropdown, closeOnEscape} = this.props;
const refProps = {ref: this.handleActorMount};
// Props that the actor needs to have <DropdownMenu> work
return {
...props,
...refProps,
style: {...style, outline: 'none'},
'aria-expanded': this.isOpen(),
'aria-haspopup': 'listbox',
onKeyDown: (e: React.KeyboardEvent<E>) => {
if (typeof onKeyDown === 'function') {
onKeyDown(e);
}
if (e.key === 'Escape' && closeOnEscape) {
this.handleClose(e);
}
},
onMouseEnter: (e: React.MouseEvent<E>) => {
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<E>) => {
if (typeof onMouseLeave === 'function') {
onMouseLeave(e);
}
window.clearTimeout(this.mouseEnterTimeout);
window.clearTimeout(this.mouseLeaveTimeout);
this.handleMouseLeave(e);
},
onClick: (e: React.MouseEvent<E>) => {
// 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 <DropdownMenu> will control
getMenuProps: GetMenuPropsFn = <E extends Element = Element>({
onClick,
onMouseLeave,
onMouseEnter,
...props
}: GetMenuArgs<E> = {}): MenuProps<E> => {
const refProps = {ref: this.handleMenuMount};
// Props that the menu needs to have <DropdownMenu> work
return {
...props,
...refProps,
role: 'listbox',
onMouseEnter: (e: React.MouseEvent<E>) => {
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<E>) => {
onMouseLeave?.(e);
this.handleMouseLeave(e);
},
onClick: (e: React.MouseEvent<E>) => {
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;