diff --git a/src/App.js b/src/App.js index 4da2222c40ab..d8faa911f86b 100644 --- a/src/App.js +++ b/src/App.js @@ -12,6 +12,7 @@ import Expensify from './Expensify'; import {LocaleContextProvider} from './components/withLocalize'; import OnyxProvider from './components/OnyxProvider'; import HTMLEngineProvider from './components/HTMLEngineProvider'; +import PopoverContextProvider from './components/PopoverProvider'; import ComposeProviders from './components/ComposeProviders'; import SafeArea from './components/SafeArea'; import * as Environment from './libs/Environment/Environment'; @@ -51,6 +52,7 @@ function App() { HTMLEngineProvider, WindowDimensionsProvider, KeyboardStateProvider, + PopoverContextProvider, CurrentReportIDContextProvider, PickerStateProvider, EnvironmentProvider, diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.js index e5f6bf946f16..7f1544a758f4 100644 --- a/src/components/AddPaymentMethodMenu.js +++ b/src/components/AddPaymentMethodMenu.js @@ -9,6 +9,7 @@ import CONST from '../CONST'; import withWindowDimensions from './withWindowDimensions'; import Permissions from '../libs/Permissions'; import PopoverMenu from './PopoverMenu'; +import refPropTypes from './refPropTypes'; import paypalMeDataPropTypes from './paypalMeDataPropTypes'; const propTypes = { @@ -33,6 +34,9 @@ const propTypes = { /** List of betas available to current user */ betas: PropTypes.arrayOf(PropTypes.string), + /** Popover anchor ref */ + anchorRef: refPropTypes, + ...withLocalizePropTypes, }; @@ -41,6 +45,7 @@ const defaultProps = { payPalMeData: {}, shouldShowPaypal: true, betas: [], + anchorRef: () => {}, }; function AddPaymentMethodMenu(props) { @@ -49,6 +54,7 @@ function AddPaymentMethodMenu(props) { isVisible={props.isVisible} onClose={props.onClose} anchorPosition={props.anchorPosition} + anchorRef={props.anchorRef} onItemSelected={props.onClose} menuItems={[ { @@ -77,6 +83,7 @@ function AddPaymentMethodMenu(props) { ] : []), ]} + withoutOverlay /> ); } diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index fcbfe4f4c4c4..808d4cb39076 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -116,6 +116,7 @@ class AvatarWithImagePicker extends React.Component { imageUri: '', imageType: '', }; + this.anchorRef = React.createRef(); } componentDidMount() { @@ -225,22 +226,14 @@ class AvatarWithImagePicker extends React.Component { this.setState({isAvatarCropModalOpen: false}); } - /** - * Create menu items list for avatar menu - * - * @param {Function} openPicker - * @returns {Array} - */ - createMenuItems(openPicker) { + render() { + const DefaultAvatar = this.props.DefaultAvatar; + const additionalStyles = _.isArray(this.props.style) ? this.props.style : [this.props.style]; const menuItems = [ { icon: Expensicons.Upload, text: this.props.translate('avatarWithImagePicker.uploadPhoto'), - onSelected: () => { - openPicker({ - onPicked: this.showAvatarCropModal, - }); - }, + onSelected: () => {}, }, ]; @@ -255,17 +248,11 @@ class AvatarWithImagePicker extends React.Component { }, }); } - return menuItems; - } - - render() { - const DefaultAvatar = this.props.DefaultAvatar; - const additionalStyles = _.isArray(this.props.style) ? this.props.style : [this.props.style]; return ( this.setState({isMenuVisible: true})} + onPress={() => this.setState((prev) => ({isMenuVisible: !prev.isMenuVisible}))} accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')} disabled={this.state.isAvatarCropModalOpen} @@ -311,9 +298,21 @@ class AvatarWithImagePicker extends React.Component { this.setState({isMenuVisible: false})} - onItemSelected={() => this.setState({isMenuVisible: false})} - menuItems={this.createMenuItems(openPicker)} + onItemSelected={(item, index) => { + this.setState({isMenuVisible: false}); + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user. + if (index === 0) { + openPicker({ + onPicked: this.showAvatarCropModal, + }); + } + }} + menuItems={menuItems} anchorPosition={this.props.anchorPosition} + withoutOverlay + anchorRef={this.anchorRef} anchorAlignment={this.props.anchorAlignment} /> diff --git a/src/components/ButtonWithDropdownMenu.js b/src/components/ButtonWithDropdownMenu.js index 1396ab601330..2bb21dd78f28 100644 --- a/src/components/ButtonWithDropdownMenu.js +++ b/src/components/ButtonWithDropdownMenu.js @@ -117,6 +117,8 @@ function ButtonWithDropdownMenu(props) { onClose={() => setIsMenuVisible(false)} onItemSelected={() => setIsMenuVisible(false)} anchorPosition={popoverAnchorPosition} + anchorRef={caretButton} + withoutOverlay anchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index 285e0a6f4b8c..803c3521af1d 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -155,6 +155,8 @@ const EmojiPicker = forwardRef((props, ref) => { vertical: emojiPopoverAnchorPosition.vertical, horizontal: emojiPopoverAnchorPosition.horizontal, }} + anchorRef={emojiPopoverAnchor} + withoutOverlay popoverDimensions={{ width: CONST.EMOJI_PICKER_SIZE.WIDTH, height: CONST.EMOJI_PICKER_SIZE.HEIGHT, diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index 32a794911fd9..c78e9fdd285a 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; @@ -34,15 +34,23 @@ const defaultProps = { }; function EmojiPickerButton(props) { - let emojiPopoverAnchor = null; + const emojiPopoverAnchor = useRef(null); + useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); + return ( (emojiPopoverAnchor = el)} + ref={emojiPopoverAnchor} style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} disabled={props.isDisabled} - onPress={() => EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor, undefined, () => {}, props.reportAction)} + onPress={() => { + if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { + EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.reportAction); + } else { + EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); + } + }} nativeID={props.nativeID} accessibilityLabel={props.translate('reportActionCompose.emoji')} > diff --git a/src/components/FloatingActionButton.js b/src/components/FloatingActionButton.js index 706bad59f7b7..f1174988e955 100644 --- a/src/components/FloatingActionButton.js +++ b/src/components/FloatingActionButton.js @@ -23,9 +23,16 @@ const propTypes = { // Current state (active or not active) of the component isActive: PropTypes.bool.isRequired, + // Ref for the button + buttonRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + ...withLocalizePropTypes, }; +const defaultProps = { + buttonRef: () => {}, +}; + class FloatingActionButton extends PureComponent { constructor(props) { super(props); @@ -75,7 +82,12 @@ class FloatingActionButton extends PureComponent { (this.fabPressable = el)} + ref={(el) => { + this.fabPressable = el; + if (this.props.buttonRef) { + this.props.buttonRef.current = el; + } + }} accessibilityLabel={this.props.accessibilityLabel} accessibilityRole={this.props.accessibilityRole} pressDimmingValue={1} @@ -99,5 +111,14 @@ class FloatingActionButton extends PureComponent { } FloatingActionButton.propTypes = propTypes; +FloatingActionButton.defaultProps = defaultProps; + +const FloatingActionButtonWithLocalize = withLocalize(FloatingActionButton); -export default withLocalize(FloatingActionButton); +export default React.forwardRef((props, ref) => ( + +)); diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index e17cf71e5d06..a3fbb5e41378 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {useState} from 'react'; +import React, {useState, useRef} from 'react'; import PropTypes from 'prop-types'; import {View, StyleSheet} from 'react-native'; import * as optionRowStyles from '../../styles/optionRowStyles'; @@ -57,6 +57,8 @@ const defaultProps = { }; function OptionRowLHN(props) { + const popoverAnchor = useRef(null); + const {translate} = useLocalize(); const optionItem = props.optionItem; @@ -71,7 +73,6 @@ function OptionRowLHN(props) { return null; } - let popoverAnchor = null; const textStyle = props.isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; const textUnreadStyle = optionItem.isUnread ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; const displayNameStyle = StyleUtils.combineStyles([styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, ...textUnreadStyle], props.style); @@ -133,7 +134,7 @@ function OptionRowLHN(props) { {(hovered) => ( (popoverAnchor = el)} + ref={popoverAnchor} onPress={(e) => { if (e) { e.preventDefault(); diff --git a/src/components/Popover/index.js b/src/components/Popover/index.js index af0ca708f58f..bfd02ba1d3e3 100644 --- a/src/components/Popover/index.js +++ b/src/components/Popover/index.js @@ -4,6 +4,7 @@ import {propTypes, defaultProps} from './popoverPropTypes'; import CONST from '../../CONST'; import Modal from '../Modal'; import withWindowDimensions from '../withWindowDimensions'; +import PopoverWithoutOverlay from '../PopoverWithoutOverlay'; /* * This is a convenience wrapper around the Modal component for a responsive Popover. @@ -25,6 +26,12 @@ function Popover(props) { document.body, ); } + + if (props.withoutOverlay && !props.isSmallScreenWidth) { + // eslint-disable-next-line react/jsx-props-no-spreading + return createPortal(, document.body); + } + return ( {}, disableAnimation: true, }; diff --git a/src/components/PopoverMenu/index.js b/src/components/PopoverMenu/index.js index 1cafa9e12664..67b9a0406aef 100644 --- a/src/components/PopoverMenu/index.js +++ b/src/components/PopoverMenu/index.js @@ -7,6 +7,7 @@ import styles from '../../styles/styles'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import MenuItem from '../MenuItem'; import {propTypes as createMenuPropTypes, defaultProps as createMenuDefaultProps} from './popoverMenuPropTypes'; +import refPropTypes from '../refPropTypes'; import Text from '../Text'; import CONST from '../../CONST'; import useArrowKeyFocusManager from '../../hooks/useArrowKeyFocusManager'; @@ -23,11 +24,16 @@ const propTypes = { vertical: PropTypes.number.isRequired, }).isRequired, + /** Ref of the anchor */ + anchorRef: refPropTypes, + /** Where the popover should be positioned relative to the anchor points. */ anchorAlignment: PropTypes.shape({ horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), }), + + withoutOverlay: PropTypes.bool, }; const defaultProps = { @@ -36,6 +42,8 @@ const defaultProps = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }, + anchorRef: () => {}, + withoutOverlay: false, }; function PopoverMenu(props) { @@ -45,7 +53,7 @@ function PopoverMenu(props) { const selectItem = (index) => { const selectedItem = props.menuItems[index]; - props.onItemSelected(selectedItem); + props.onItemSelected(selectedItem, index); setSelectedItemIndex(index); }; @@ -64,6 +72,7 @@ function PopoverMenu(props) { return ( {!_.isEmpty(props.headerText) && {props.headerText}} diff --git a/src/components/PopoverMenu/popoverMenuPropTypes.js b/src/components/PopoverMenu/popoverMenuPropTypes.js index 00a0e920ad0e..7d95c6a860bc 100644 --- a/src/components/PopoverMenu/popoverMenuPropTypes.js +++ b/src/components/PopoverMenu/popoverMenuPropTypes.js @@ -33,6 +33,9 @@ const propTypes = { left: PropTypes.number, }).isRequired, + /** The anchor reference of the CreateMenu popover */ + anchorRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, + /** A react-native-animatable animation definition for the modal display animation. */ animationIn: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), diff --git a/src/components/PopoverProvider/index.js b/src/components/PopoverProvider/index.js new file mode 100644 index 000000000000..f8ecf5e6d135 --- /dev/null +++ b/src/components/PopoverProvider/index.js @@ -0,0 +1,131 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const propTypes = { + children: PropTypes.node.isRequired, +}; + +const defaultProps = {}; + +const PopoverContext = React.createContext({ + onOpen: () => {}, + popover: {}, + close: () => {}, + isOpen: false, +}); + +function PopoverContextProvider(props) { + const [isOpen, setIsOpen] = React.useState(false); + const activePopoverRef = React.useRef(null); + + const closePopover = React.useCallback((anchorRef) => { + if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { + return; + } + activePopoverRef.current.close(); + activePopoverRef.current = null; + setIsOpen(false); + }, []); + + React.useEffect(() => { + const listener = (e) => { + if ( + !activePopoverRef.current || + !activePopoverRef.current.ref || + !activePopoverRef.current.ref.current || + activePopoverRef.current.ref.current.contains(e.target) || + (activePopoverRef.current.anchorRef && activePopoverRef.current.anchorRef.current && activePopoverRef.current.anchorRef.current.contains(e.target)) + ) { + return; + } + const ref = activePopoverRef.current.anchorRef; + closePopover(ref); + }; + document.addEventListener('click', listener, true); + return () => { + document.removeEventListener('click', listener, true); + }; + }, [closePopover]); + + React.useEffect(() => { + const listener = () => { + closePopover(); + }; + document.addEventListener('contextmenu', listener); + return () => { + document.removeEventListener('contextmenu', listener); + }; + }, [closePopover]); + + React.useEffect(() => { + const listener = (e) => { + if (e.key !== 'Escape') { + return; + } + closePopover(); + }; + document.addEventListener('keydown', listener); + return () => { + document.removeEventListener('keydown', listener); + }; + }, [closePopover]); + + React.useEffect(() => { + const listener = () => { + if (document.hasFocus()) { + return; + } + closePopover(); + }; + document.addEventListener('visibilitychange', listener); + return () => { + document.removeEventListener('visibilitychange', listener); + }; + }, [closePopover]); + + React.useEffect(() => { + const listener = (e) => { + if (activePopoverRef.current && activePopoverRef.current.ref && activePopoverRef.current.ref.current && activePopoverRef.current.ref.current.contains(e.target)) { + return; + } + + closePopover(); + }; + document.addEventListener('scroll', listener, true); + return () => { + document.removeEventListener('scroll', listener, true); + }; + }, [closePopover]); + + const onOpen = React.useCallback( + (popoverParams) => { + if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams.ref) { + closePopover(activePopoverRef.current.anchorRef); + } + activePopoverRef.current = popoverParams; + setIsOpen(true); + }, + [closePopover], + ); + + return ( + + {props.children} + + ); +} + +PopoverContextProvider.defaultProps = defaultProps; +PopoverContextProvider.propTypes = propTypes; +PopoverContextProvider.displayName = 'PopoverContextProvider'; + +export default PopoverContextProvider; + +export {PopoverContext}; diff --git a/src/components/PopoverProvider/index.native.js b/src/components/PopoverProvider/index.native.js new file mode 100644 index 000000000000..f34abcb1fa62 --- /dev/null +++ b/src/components/PopoverProvider/index.native.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const propTypes = { + children: PropTypes.node.isRequired, +}; + +const defaultProps = {}; + +const PopoverContext = React.createContext({ + onOpen: () => {}, + popover: {}, + close: () => {}, + isOpen: false, +}); + +function PopoverContextProvider(props) { + return ( + {}, + close: () => {}, + popover: {}, + isOpen: false, + }} + > + {props.children} + + ); +} + +PopoverContextProvider.defaultProps = defaultProps; +PopoverContextProvider.propTypes = propTypes; +PopoverContextProvider.displayName = 'PopoverContextProvider'; + +export default PopoverContextProvider; + +export {PopoverContext}; diff --git a/src/components/PopoverWithMeasuredContent.js b/src/components/PopoverWithMeasuredContent.js index 93ce9be9c415..09643f6b4c12 100644 --- a/src/components/PopoverWithMeasuredContent.js +++ b/src/components/PopoverWithMeasuredContent.js @@ -50,6 +50,7 @@ const defaultProps = { height: 0, width: 0, }, + withoutOverlay: false, }; /** diff --git a/src/components/PopoverWithoutOverlay/index.js b/src/components/PopoverWithoutOverlay/index.js new file mode 100644 index 000000000000..d42f735b19a8 --- /dev/null +++ b/src/components/PopoverWithoutOverlay/index.js @@ -0,0 +1,101 @@ +import React from 'react'; +import {View} from 'react-native'; +import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; +import {PopoverContext} from '../PopoverProvider'; +import * as Modal from '../../libs/actions/Modal'; +import {propTypes, defaultProps} from '../Popover/popoverPropTypes'; +import styles from '../../styles/styles'; +import * as StyleUtils from '../../styles/StyleUtils'; +import getModalStyles from '../../styles/getModalStyles'; +import withWindowDimensions from '../withWindowDimensions'; + +function Popover(props) { + const ref = React.useRef(null); + const {onOpen, close} = React.useContext(PopoverContext); + const {modalStyle, modalContainerStyle, shouldAddTopSafeAreaMargin, shouldAddBottomSafeAreaMargin, shouldAddTopSafeAreaPadding, shouldAddBottomSafeAreaPadding} = getModalStyles( + 'popover', + { + windowWidth: props.windowWidth, + windowHeight: props.windowHeight, + isSmallScreenWidth: false, + }, + props.anchorPosition, + props.innerContainerStyle, + props.outerStyle, + ); + + React.useEffect(() => { + if (props.isVisible) { + props.onModalShow(); + onOpen({ + ref, + close: props.onClose, + anchorRef: props.anchorRef, + }); + } else { + props.onModalHide(); + close(props.anchorRef); + } + Modal.willAlertModalBecomeVisible(props.isVisible); + Modal.setCloseModal(props.isVisible ? () => props.onClose(props.anchorRef) : null); + + // We want this effect to run strictly ONLY when isVisible prop changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.isVisible]); + + if (!props.isVisible) { + return null; + } + + return ( + + + {(insets) => { + const { + paddingTop: safeAreaPaddingTop, + paddingBottom: safeAreaPaddingBottom, + paddingLeft: safeAreaPaddingLeft, + paddingRight: safeAreaPaddingRight, + } = StyleUtils.getSafeAreaPadding(insets); + + const modalPaddingStyles = StyleUtils.getModalPaddingStyles({ + safeAreaPaddingTop, + safeAreaPaddingBottom, + safeAreaPaddingLeft, + safeAreaPaddingRight, + shouldAddBottomSafeAreaMargin, + shouldAddTopSafeAreaMargin, + shouldAddBottomSafeAreaPadding, + shouldAddTopSafeAreaPadding, + modalContainerStyleMarginTop: modalContainerStyle.marginTop, + modalContainerStyleMarginBottom: modalContainerStyle.marginBottom, + modalContainerStylePaddingTop: modalContainerStyle.paddingTop, + modalContainerStylePaddingBottom: modalContainerStyle.paddingBottom, + insets, + }); + return ( + + {props.children} + + ); + }} + + + ); +} + +Popover.propTypes = propTypes; +Popover.defaultProps = defaultProps; +Popover.displayName = 'Popover'; + +export default withWindowDimensions(Popover); diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.js index c271b3a9afb8..de34ebf95242 100644 --- a/src/components/Reactions/AddReactionBubble.js +++ b/src/components/Reactions/AddReactionBubble.js @@ -71,10 +71,14 @@ function AddReactionBubble(props) { ); }; - if (props.onPressOpenPicker) { - props.onPressOpenPicker(openPicker); + if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { + if (props.onPressOpenPicker) { + props.onPressOpenPicker(openPicker); + } else { + openPicker(); + } } else { - openPicker(); + EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); } }; diff --git a/src/components/Reactions/MiniQuickEmojiReactions.js b/src/components/Reactions/MiniQuickEmojiReactions.js index 4d918f5e7f58..1ebb8a971827 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.js +++ b/src/components/Reactions/MiniQuickEmojiReactions.js @@ -85,7 +85,13 @@ function MiniQuickEmojiReactions(props) { ))} { + if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { + openEmojiPicker(); + } else { + EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); + } + })} isDelayButtonStateComplete={false} tooltipText={props.translate('emojiReactions.addReactionTooltip')} > diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js index e54481bcab99..07671eeb0369 100644 --- a/src/components/ThreeDotsMenu/index.js +++ b/src/components/ThreeDotsMenu/index.js @@ -70,7 +70,7 @@ class ThreeDotsMenu extends Component { this.state = { isPopupMenuVisible: false, }; - this.button = null; + this.buttonRef = React.createRef(null); } showPopoverMenu() { @@ -93,7 +93,7 @@ class ThreeDotsMenu extends Component { this.props.onIconPress(); } }} - ref={(el) => (this.button = el)} + ref={this.buttonRef} style={[styles.touchableButtonImage, ...this.props.iconStyles]} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={this.props.translate(this.props.iconTooltip)} @@ -112,6 +112,8 @@ class ThreeDotsMenu extends Component { anchorAlignment={this.props.anchorAlignment} onItemSelected={this.hidePopoverMenu} menuItems={this.props.menuItems} + anchorRef={this.buttonRef} + withoutOverlay /> ); diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js index 4c0e2b551382..e987e67143d6 100755 --- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js +++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js @@ -96,7 +96,7 @@ function BaseVideoChatButtonAndMenu(props) { Linking.openURL(props.guideCalendarLink); return; } - setIsVideoChatMenuActive(true); + setIsVideoChatMenuActive((previousVal) => !previousVal); })} style={styles.touchableButtonImage} accessibilityLabel={props.translate('videoChatButtonAndMenu.tooltip')} @@ -117,6 +117,8 @@ function BaseVideoChatButtonAndMenu(props) { left: videoChatIconPosition.x - 150, top: videoChatIconPosition.y + 40, }} + withoutOverlay + anchorRef={videoChatButtonRef} > {_.map(menuItemData, ({icon, text, onPress}) => ( diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 203d01f5d0f3..81858564b416 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -64,6 +64,7 @@ class PopoverReportActionContextMenu extends React.Component { this.contentRef.current = ref; }; this.setContentRef = this.setContentRef.bind(this); + this.anchorRef = React.createRef(); } componentDidMount() { @@ -302,6 +303,8 @@ class PopoverReportActionContextMenu extends React.Component { animationOutTiming={1} shouldSetModalVisibility={false} fullscreen + withoutOverlay + anchorRef={this.anchorRef} > {}] - Callback to be called after Context Menu is completely hidden + */ +function hideContextMenu(shouldDelay, onHideCallback = () => {}) { + if (!contextMenuRef.current) { + return; + } + if (!shouldDelay) { + contextMenuRef.current.hideContextMenu(onHideCallback); + + return; + } + + // Save the active instanceID for which hide action was called. + // If menu is being closed with a delay, check that whether the same instance exists or a new was created. + // If instance is not same, cancel the hide action + const instanceID = contextMenuRef.current.instanceID; + setTimeout(() => { + if (contextMenuRef.current.instanceID !== instanceID) { + return; + } + + contextMenuRef.current.hideContextMenu(onHideCallback); + }, 800); +} + /** * Show the ReportActionContextMenu modal popover. * @@ -37,6 +66,12 @@ function showContextMenu( if (!contextMenuRef.current) { return; } + // If there is an already open context menu, close it first before opening + // a new one. + if (contextMenuRef.current.instanceID) { + hideContextMenu(); + contextMenuRef.current.runAndResetOnPopoverHide(); + } contextMenuRef.current.showContextMenu( type, event, @@ -54,35 +89,6 @@ function showContextMenu( ); } -/** - * Hide the ReportActionContextMenu modal popover. - * Hides the popover menu with an optional delay - * @param {Boolean} shouldDelay - whether the menu should close after a delay - * @param {Function} [onHideCallback=() => {}] - Callback to be called after Context Menu is completely hidden - */ -function hideContextMenu(shouldDelay, onHideCallback = () => {}) { - if (!contextMenuRef.current) { - return; - } - if (!shouldDelay) { - contextMenuRef.current.hideContextMenu(onHideCallback); - - return; - } - - // Save the active instanceID for which hide action was called. - // If menu is being closed with a delay, check that whether the same instance exists or a new was created. - // If instance is not same, cancel the hide action - const instanceID = contextMenuRef.current.instanceID; - setTimeout(() => { - if (contextMenuRef.current.instanceID !== instanceID) { - return; - } - - contextMenuRef.current.hideContextMenu(onHideCallback); - }, 800); -} - function hideDeleteModal() { if (!contextMenuRef.current) { return; diff --git a/src/pages/home/report/ReactionList/PopoverReactionList.js b/src/pages/home/report/ReactionList/PopoverReactionList.js index 8a9b32b5fd9a..d8c3631d3c33 100644 --- a/src/pages/home/report/ReactionList/PopoverReactionList.js +++ b/src/pages/home/report/ReactionList/PopoverReactionList.js @@ -36,7 +36,7 @@ class PopoverReactionList extends React.Component { }; this.onPopoverHideActionCallback = () => {}; - this.reactionListAnchor = undefined; + this.reactionListAnchor = React.createRef(); this.showReactionList = this.showReactionList.bind(this); this.hideReactionList = this.hideReactionList.bind(this); this.measureReactionListPosition = this.measureReactionListPosition.bind(this); @@ -77,8 +77,8 @@ class PopoverReactionList extends React.Component { */ getReactionListMeasuredLocation() { return new Promise((resolve) => { - if (this.reactionListAnchor) { - this.reactionListAnchor.measureInWindow((x, y) => resolve({x, y})); + if (this.reactionListAnchor.current) { + this.reactionListAnchor.current.measureInWindow((x, y) => resolve({x, y})); } else { resolve({x: 0, y: 0}); } @@ -190,6 +190,8 @@ class PopoverReactionList extends React.Component { animationOutTiming={1} shouldSetModalVisibility={false} fullscreen + withoutOverlay + anchorRef={this.reactionListAnchor} > {}, + }, + ]; + return ( (this.actionButton = el)} + ref={this.actionButtonRef} onPress={(e) => { e.preventDefault(); // Drop focus to avoid blue focus ring. - this.actionButton.blur(); - this.setMenuVisibility(true); + this.actionButtonRef.current.blur(); + this.setMenuVisibility(!this.state.isMenuVisible); }} style={styles.composerSizeButton} disabled={isBlockedFromConcierge || this.props.disabled} @@ -1108,7 +1120,24 @@ class ReportActionCompose extends React.Component { animationInTiming={CONST.ANIMATION_IN_TIMING} isVisible={this.state.isMenuVisible} onClose={() => this.setMenuVisibility(false)} - onItemSelected={() => this.setMenuVisibility(false)} + onItemSelected={(item, index) => { + this.setMenuVisibility(false); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user. + if (index === menuItems.length - 1) { + // Set a flag to block suggestion calculation until we're finished using the file picker, + // which will stop any flickering as the file picker opens on non-native devices. + if (this.willBlurTextInputOnTapOutside) { + this.shouldBlockEmojiCalc = true; + this.shouldBlockMentionCalc = true; + } + openPicker({ + onPicked: displayFileInModal, + }); + } + }} anchorPosition={styles.createMenuPositionReportActionCompose(this.props.windowHeight)} anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} menuItems={[ @@ -1131,6 +1160,8 @@ class ReportActionCompose extends React.Component { }, }, ]} + withoutOverlay + anchorRef={this.actionButtonRef} /> )} diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 1ccdf9261b19..e692e4668f07 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -1,4 +1,4 @@ -import React, {useState, useEffect, useCallback, useImperativeHandle, forwardRef} from 'react'; +import React, {useState, useEffect, useRef, useCallback, useImperativeHandle, forwardRef} from 'react'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; @@ -81,6 +81,7 @@ const defaultProps = { function FloatingActionButtonAndPopover(props) { const [isCreateMenuActive, setIsCreateMenuActive] = useState(false); const isAnonymousUser = Session.isAnonymousUser(); + const anchorRef = useRef(null); const prevIsFocused = usePrevious(props.isFocused); @@ -244,12 +245,21 @@ function FloatingActionButtonAndPopover(props) { ] : []), ]} + withoutOverlay + anchorRef={anchorRef} /> { + if (isCreateMenuActive) { + hideCreateMenu(); + } else { + showCreateMenu(); + } + }} /> ); diff --git a/src/pages/settings/Payments/PaymentMethodList.js b/src/pages/settings/Payments/PaymentMethodList.js index f2d31ea81d79..0d0040752f25 100644 --- a/src/pages/settings/Payments/PaymentMethodList.js +++ b/src/pages/settings/Payments/PaymentMethodList.js @@ -70,6 +70,9 @@ const propTypes = { /** Content for the FlatList header component */ listHeaderComponent: PropTypes.func, + /** React ref being forwarded to the PaymentMethodList Button */ + buttonRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + ...withLocalizePropTypes, }; @@ -89,6 +92,7 @@ const defaultProps = { activePaymentMethodID: '', selectedMethodID: '', listHeaderComponent: null, + buttonRef: () => {}, }; /** @@ -257,6 +261,7 @@ function PaymentMethodList(props) { success shouldShowRightIcon large + ref={props.buttonRef} /> )} diff --git a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js index 346738574da3..beaf26de265f 100644 --- a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js +++ b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useState, useRef} from 'react'; import {ActivityIndicator, View, InteractionManager} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -52,6 +52,8 @@ function BasePaymentsPage(props) { methodID: null, selectedPaymentMethodType: null, }); + const addPaymentMethodAnchorRef = useRef(null); + const deletePaymentMethodAnchorRef = useRef(null); const [anchorPosition, setAnchorPosition] = useState({ anchorPositionHorizontal: 0, anchorPositionVertical: 0, @@ -413,6 +415,7 @@ function BasePaymentsPage(props) { actionPaymentMethodType={shouldShowDefaultDeleteMenu || showPassword.shouldShowPasswordPrompt ? paymentMethod.selectedPaymentMethodType : ''} activePaymentMethodID={shouldShowDefaultDeleteMenu || showPassword.shouldShowPasswordPrompt ? getSelectedPaymentMethodID() : ''} listHeaderComponent={listHeaderComponent} + buttonRef={addPaymentMethodAnchorRef} /> @@ -424,6 +427,7 @@ function BasePaymentsPage(props) { vertical: anchorPosition.anchorPositionVertical - 10, }} onItemSelected={(method) => addPaymentMethodTypePressed(method)} + anchorRef={addPaymentMethodAnchorRef} /> {!showConfirmDeleteContent ? ( @@ -474,6 +480,7 @@ function BasePaymentsPage(props) { style={[shouldShowMakeDefaultButton ? styles.mt4 : {}]} text={translate('common.delete')} danger + ref={deletePaymentMethodAnchorRef} /> ) : (