123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459 |
- 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 && 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;
|