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;
- };
- 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 = {
-
- closeOnEscape: boolean;
-
- keepMenuOpen: boolean;
- };
- type Props = DefaultProps & {
-
- children: (renderProps: RenderProps) => React.ReactNode;
-
- alwaysRenderMenu?: boolean;
-
- isNestedDropdown?: boolean;
-
- isOpen?: boolean;
-
- onClickOutside?: Function;
- onClose?: Function;
- onOpen?: Function;
-
- shouldIgnoreClickOutside?: (event: MouseEvent) => boolean;
- };
- type State = {
- isOpen: boolean;
- };
- 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;
-
- isOpen = () => {
- const {isOpen} = this.props;
- const isControlled = typeof isOpen !== 'undefined';
- return (isControlled && isOpen) || this.state.isOpen;
- };
-
-
- checkClickOutside = async (e: MouseEvent) => {
- const {onClickOutside, shouldIgnoreClickOutside} = this.props;
- if (!this.dropdownMenu || !this.isOpen()) {
- return;
- }
- if (!(e.target instanceof Element)) {
- return;
- }
-
- if (this.dropdownMenu.contains(e.target)) {
- return;
- }
- if (!this.dropdownActor) {
-
- Sentry.withScope(scope => {
- scope.setLevel('warning');
- Sentry.captureException(new Error('DropdownMenu does not have "Actor" attached'));
- });
- }
-
- if (this.dropdownActor && this.dropdownActor.contains(e.target)) {
- return;
- }
- if (typeof shouldIgnoreClickOutside === 'function' && shouldIgnoreClickOutside(e)) {
- return;
- }
- if (typeof onClickOutside === 'function') {
- onClickOutside(e);
- }
-
-
-
-
- await new Promise(resolve => window.setTimeout(resolve));
- this.handleClose();
- };
-
- 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 (alwaysRenderMenu || isNestedDropdown) {
- document.addEventListener('click', this.checkClickOutside, true);
- }
- if (typeof onOpen === 'function') {
- onOpen(e);
- }
- };
-
-
- 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);
- });
- }
- };
-
- 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});
- }
-
-
- if (alwaysRenderMenu || isNestedDropdown) {
- document.removeEventListener('click', this.checkClickOutside, true);
- }
- if (typeof onClose === 'function') {
- onClose(e);
- }
- };
-
-
-
- handleMenuMount = (ref: Element | null) => {
- if (ref && !(ref instanceof Element)) {
- return;
- }
- const {alwaysRenderMenu, isNestedDropdown} = this.props;
- this.dropdownMenu = ref;
-
-
- if (alwaysRenderMenu || isNestedDropdown) {
- return;
- }
- if (this.dropdownMenu) {
-
- 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);
- }
- };
-
- handleDropdownMenuClick = (e: React.MouseEvent<Element>) => {
- if (this.props.keepMenuOpen) {
- return;
- }
- this.handleClose(e);
- };
- getRootProps<T>(props: T): T {
- return props;
- }
-
- getActorProps: GetActorPropsFn = <E extends Element = Element>({
- onClick,
- onMouseEnter,
- onMouseLeave,
- onKeyDown,
- style = {},
- ...props
- }: GetActorArgs<E> = {}) => {
- const {isNestedDropdown, closeOnEscape} = this.props;
- const refProps = {ref: this.handleActorMount};
-
- 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);
- }
-
- 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 (isNestedDropdown) {
- e.preventDefault();
- e.stopPropagation();
- return;
- }
- this.handleToggle(e);
- if (typeof onClick === 'function') {
- onClick(e);
- }
- },
- };
- };
-
- getMenuProps: GetMenuPropsFn = <E extends Element = Element>({
- onClick,
- onMouseLeave,
- onMouseEnter,
- ...props
- }: GetMenuArgs<E> = {}): MenuProps<E> => {
- const refProps = {ref: this.handleMenuMount};
-
- return {
- ...props,
- ...refProps,
- role: 'listbox',
- onMouseEnter: (e: React.MouseEvent<E>) => {
- onMouseEnter?.(e);
-
-
- 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;
-
- 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;
|