diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.js index c7ca3f948da4..2314f2fcf64e 100644 --- a/src/components/ContextMenuItem.js +++ b/src/components/ContextMenuItem.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {forwardRef, useImperativeHandle} from 'react'; import PropTypes from 'prop-types'; import MenuItem from './MenuItem'; import Icon from './Icon'; @@ -34,6 +34,12 @@ const propTypes = { /** The action accept for anonymous user or not */ isAnonymousAction: PropTypes.bool, + + /** Whether the menu item is focused or not */ + isFocused: PropTypes.bool, + + /** Forwarded ref to ContextMenuItem */ + innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), }; const defaultProps = { @@ -42,9 +48,11 @@ const defaultProps = { successText: '', description: '', isAnonymousAction: false, + isFocused: false, + innerRef: null, }; -function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini, description, isAnonymousAction}) { +function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini, description, isAnonymousAction, isFocused, innerRef}) { const {windowWidth} = useWindowDimensions(); const [isThrottledButtonActive, setThrottledButtonInactive] = useThrottledButtonState(); @@ -61,6 +69,8 @@ function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini, } }; + useImperativeHandle(innerRef, () => ({triggerPressAndUpdateSuccess})); + const itemIcon = !isThrottledButtonActive && successIcon ? successIcon : icon; const itemText = !isThrottledButtonActive && successText ? successText : text; @@ -89,6 +99,7 @@ function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini, descriptionTextStyle={styles.breakAll} style={getContextMenuItemStyles(windowWidth)} isAnonymousAction={isAnonymousAction} + focused={isFocused} /> ); } @@ -97,4 +108,10 @@ ContextMenuItem.propTypes = propTypes; ContextMenuItem.defaultProps = defaultProps; ContextMenuItem.displayName = 'ContextMenuItem'; -export default ContextMenuItem; +export default forwardRef((props, ref) => ( + +)); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js index 6382af6a898e..fc56b3b1fac9 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js @@ -1,4 +1,4 @@ -import React, {useMemo, useState, memo} from 'react'; +import React, {useRef, useMemo, useState, memo} from 'react'; import {InteractionManager, View} from 'react-native'; import lodashGet from 'lodash/get'; import _ from 'underscore'; @@ -15,6 +15,9 @@ import {withBetas} from '../../../../components/OnyxProvider'; import * as Session from '../../../../libs/actions/Session'; import {hideContextMenu} from './ReportActionContextMenu'; import ONYXKEYS from '../../../../ONYXKEYS'; +import CONST from '../../../../CONST'; +import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; +import useKeyboardShortcut from '../../../../hooks/useKeyboardShortcut'; const propTypes = { /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ @@ -45,6 +48,7 @@ const defaultProps = { ...GenericReportActionContextMenuDefaultProps, }; function BaseReportActionContextMenu(props) { + const menuItemRefs = useRef({}); const [shouldKeepOpen, setShouldKeepOpen] = useState(false); const wrapperStyle = getReportActionContextMenuStyles(props.isMini, props.isSmallScreenWidth); @@ -58,6 +62,20 @@ function BaseReportActionContextMenu(props) { const shouldShowFilter = (contextAction) => contextAction.shouldShow(props.type, reportAction, props.isArchivedRoom, props.betas, props.anchor, props.isChronosReport, props.reportID, props.isPinnedChat, props.isUnreadChat); + const shouldEnableArrowNavigation = !props.isMini && (props.isVisible || shouldKeepOpen); + const filteredContextMenuActions = _.filter(ContextMenuActions, shouldShowFilter); + + // Context menu actions that are not rendered as menu items are excluded from arrow navigation + const nonMenuItemActionIndexes = _.map(filteredContextMenuActions, (contextAction, index) => (_.isFunction(contextAction.renderContent) ? index : undefined)); + const disabledIndexes = _.filter(nonMenuItemActionIndexes, (index) => !_.isUndefined(index)); + + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + initialFocusedIndex: -1, + disabledIndexes, + maxIndex: filteredContextMenuActions.length - 1, + isActive: shouldEnableArrowNavigation, + }); + /** * Checks if user is anonymous. If true and the action doesn't accept for anonymous user, hides the context menu and * shows the sign in modal. Else, executes the callback. @@ -77,13 +95,31 @@ function BaseReportActionContextMenu(props) { } }; + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.ENTER, + (event) => { + if (!menuItemRefs.current[focusedIndex]) { + return; + } + + // Ensures the event does not cause side-effects beyond the context menu, e.g. when an outside element is focused + if (event) { + event.stopPropagation(); + } + + menuItemRefs.current[focusedIndex].triggerPressAndUpdateSuccess(); + setFocusedIndex(-1); + }, + {isActive: shouldEnableArrowNavigation}, + ); + return ( (props.isVisible || shouldKeepOpen) && ( - {_.map(_.filter(ContextMenuActions, shouldShowFilter), (contextAction) => { + {_.map(filteredContextMenuActions, (contextAction, index) => { const closePopup = !props.isMini; const payload = { reportAction, @@ -106,6 +142,9 @@ function BaseReportActionContextMenu(props) { return ( { + menuItemRefs.current[index] = ref; + }} icon={contextAction.icon} text={props.translate(contextAction.textTranslateKey, {action: reportAction})} successIcon={contextAction.successIcon} @@ -115,6 +154,7 @@ function BaseReportActionContextMenu(props) { onPress={() => interceptAnonymousUser(() => contextAction.onPress(closePopup, payload), contextAction.isAnonymousAction)} description={contextAction.getDescription(props.selection, props.isSmallScreenWidth)} isAnonymousAction={contextAction.isAnonymousAction} + isFocused={focusedIndex === index} /> ); })}