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}
/>
);
})}