diff --git a/package-lock.json b/package-lock.json index 57203662f35..7d74d0fe4cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", + "focus-trap-react": "^10.2.3", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-expo": "50.0.1", @@ -32350,6 +32351,28 @@ "readable-stream": "^2.3.6" } }, + "node_modules/focus-trap": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", + "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "dependencies": { + "tabbable": "^6.2.0" + } + }, + "node_modules/focus-trap-react": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.3.tgz", + "integrity": "sha512-YXBpFu/hIeSu6NnmV2xlXzOYxuWkoOtar9jzgp3lOmjWLWY59C/b8DtDHEAV4SPU07Nd/t+nS/SBNGkhUBFmEw==", + "dependencies": { + "focus-trap": "^7.5.4", + "tabbable": "^6.2.0" + }, + "peerDependencies": { + "prop-types": "^15.8.1", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, "node_modules/follow-redirects": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", @@ -49597,6 +49620,11 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/table": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", diff --git a/package.json b/package.json index a6971f881fd..39c358ffe65 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", + "focus-trap-react": "^10.2.3", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-expo": "50.0.1", diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index f25fc978e3e..913aa0c2dbf 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -97,6 +97,7 @@ function ConfirmModal({ shouldSetModalVisibility={shouldSetModalVisibility} onModalHide={onModalHide} type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM} + shouldEnableFocusTrap > (null); + + return isEnabled ? ( + (shouldEnableAutoFocus && ref.current) ?? false, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + fallbackFocus: () => ref.current!, + clickOutsideDeactivates: true, + returnFocusOnDeactivate: shouldReturnFocusOnDeactivate, + }} + > + + + ) : ( + props.children + ); +} + +FocusTrapView.displayName = 'FocusTrapView'; + +export default FocusTrapView; diff --git a/src/components/FocusTrapView/types.ts b/src/components/FocusTrapView/types.ts new file mode 100644 index 00000000000..a3eb55a5294 --- /dev/null +++ b/src/components/FocusTrapView/types.ts @@ -0,0 +1,27 @@ +import type {ViewProps} from 'react-native'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type FocusTrapViewProps = ChildrenProps & { + /** + * Whether to enable the FocusTrap. + * If the FocusTrap is disabled, we just pass the children through. + */ + isEnabled?: boolean; + + /** + * Whether to disable auto focus + * It is used when the component inside the FocusTrap have their own auto focus logic + */ + shouldEnableAutoFocus?: boolean; + + /** Whether the FocusTrap is active (listening for events) */ + isActive?: boolean; + + /** + * Whether the FocusTrap should return focus to the last focused element when it is deactivated. + * The default value is True, but sometimes we have to disable it, as it causes unexpected behavior. + */ + shouldReturnFocusOnDeactivate?: boolean; +} & ViewProps; + +export default FocusTrapViewProps; diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 71c0fe47ffc..d7b924dd3c6 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -1,12 +1,15 @@ import React, {useState} from 'react'; +import FocusTrapView from '@components/FocusTrapView'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; import StatusBar from '@libs/StatusBar'; import CONST from '@src/CONST'; import BaseModal from './BaseModal'; import type BaseModalProps from './types'; -function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, ...rest}: BaseModalProps) { +function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, shouldEnableFocusTrap = false, ...rest}: BaseModalProps) { + const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); const [previousStatusBarColor, setPreviousStatusBarColor] = useState(); @@ -48,7 +51,13 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = ( fullscreen={fullscreen} type={type} > - {children} + + {children} + ); } diff --git a/src/components/Modal/modalPropTypes.js b/src/components/Modal/modalPropTypes.js index 84e610b694e..7465e28b28a 100644 --- a/src/components/Modal/modalPropTypes.js +++ b/src/components/Modal/modalPropTypes.js @@ -66,6 +66,9 @@ const propTypes = { * */ hideModalContentWhileAnimating: PropTypes.bool, + /** Should the modal use custom focus trap logic */ + shouldEnableFocusTrap: PropTypes.bool, + ...windowDimensionsPropTypes, }; @@ -84,6 +87,7 @@ const defaultProps = { statusBarTranslucent: true, avoidKeyboard: false, hideModalContentWhileAnimating: false, + shouldEnableFocusTrap: false, }; export {propTypes, defaultProps}; diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index a0cdb737d44..85ea763acd5 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -60,6 +60,9 @@ type BaseModalProps = Partial & { * */ hideModalContentWhileAnimating?: boolean; + /** Whether the modal should use focus trap */ + shouldEnableFocusTrap?: boolean; + /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ shouldUseCustomBackdrop?: boolean; }; diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index a7d05a335d4..6f106c50859 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -1,4 +1,4 @@ -import {useNavigation} from '@react-navigation/native'; +import {useIsFocused, useNavigation} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; import type {ForwardedRef, ReactNode} from 'react'; import React, {forwardRef, useEffect, useRef, useState} from 'react'; @@ -17,6 +17,7 @@ import type {RootStackParamList} from '@libs/Navigation/types'; import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; import CustomDevMenu from './CustomDevMenu'; +import FocusTrapView from './FocusTrapView'; import HeaderGap from './HeaderGap'; import KeyboardAvoidingView from './KeyboardAvoidingView'; import OfflineIndicator from './OfflineIndicator'; @@ -89,6 +90,15 @@ type ScreenWrapperProps = { /** Whether to show offline indicator on wide screens */ shouldShowOfflineIndicatorInWideScreen?: boolean; + + /** Whether to disable the focus trap */ + shouldEnableFocusTrap?: boolean; + + /** Whether to disable auto focus of the focus trap */ + shouldEnableAutoFocus?: boolean; + + /** Whether to return focus on deactivate of the focus trap */ + shouldReturnFocusOnDeactivate?: boolean; }; function ScreenWrapper( @@ -110,6 +120,9 @@ function ScreenWrapper( testID, navigation: navigationProp, shouldShowOfflineIndicatorInWideScreen = false, + shouldEnableFocusTrap = true, + shouldEnableAutoFocus = false, + shouldReturnFocusOnDeactivate = true, }: ScreenWrapperProps, ref: ForwardedRef, ) { @@ -128,6 +141,7 @@ function ScreenWrapper( const keyboardState = useKeyboardState(); const {isDevelopment} = useEnvironment(); const {isOffline} = useNetwork(); + const isFocused = useIsFocused(); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined; const minHeight = shouldEnableMinHeight && !Browser.isSafari() ? initialHeight : undefined; @@ -228,26 +242,34 @@ function ScreenWrapper( style={styles.flex1} enabled={shouldEnablePickerAvoiding} > - - {isDevelopment && } - {isDevelopment && } - { - // If props.children is a function, call it to provide the insets to the children. - typeof children === 'function' - ? children({ - insets, - safeAreaPaddingBottomStyle, - didScreenTransitionEnd, - }) - : children - } - {isSmallScreenWidth && shouldShowOfflineIndicator && } - {!isSmallScreenWidth && shouldShowOfflineIndicatorInWideScreen && ( - - )} + + + {isDevelopment && } + {isDevelopment && } + { + // If props.children is a function, call it to provide the insets to the children. + typeof children === 'function' + ? children({ + insets, + safeAreaPaddingBottomStyle, + didScreenTransitionEnd, + }) + : children + } + {isSmallScreenWidth && shouldShowOfflineIndicator && } + {!isSmallScreenWidth && shouldShowOfflineIndicatorInWideScreen && ( + + )} + diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 11e2afeb6e9..eb0fb50a7fc 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -146,7 +146,10 @@ function ProfilePage(props) { }, [accountID, hasMinimumDetails]); return ( - + Navigation.goBack(navigateBackTo)} diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index 513ccbbe307..4b5f90148c4 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -178,7 +178,10 @@ function ReportDetailsPage(props) { ) : null; return ( - + {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index f671f14813d..f2625f9bb7f 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -505,6 +505,7 @@ function ReportScreen({ style={screenWrapperStyle} shouldEnableKeyboardAvoidingView={isTopMostReportId} testID={ReportScreen.displayName} + shouldEnableFocusTrap={false} > {({insets}) => ( diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index 24833fc96fd..4d2e02b6aa0 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -156,6 +156,7 @@ function IOUCurrencySelection(props) { includeSafeAreaPaddingBottom={false} onEntryTransitionEnd={() => optionsSelectorRef.current && optionsSelectorRef.current.focus()} testID={IOUCurrencySelection.displayName} + shouldReturnFocusOnDeactivate={false} > {({didScreenTransitionEnd}) => ( <> diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js index be3afb82272..b28244af05f 100644 --- a/src/pages/iou/SplitBillDetailsPage.js +++ b/src/pages/iou/SplitBillDetailsPage.js @@ -111,7 +111,10 @@ function SplitBillDetailsPage(props) { ); return ( - + diff --git a/src/pages/iou/request/step/IOURequestStepCurrency.js b/src/pages/iou/request/step/IOURequestStepCurrency.js index b4281de4d16..275538adcd3 100644 --- a/src/pages/iou/request/step/IOURequestStepCurrency.js +++ b/src/pages/iou/request/step/IOURequestStepCurrency.js @@ -128,6 +128,7 @@ function IOURequestStepCurrency({ onEntryTransitionEnd={() => optionsSelectorRef.current && optionsSelectorRef.current.focus()} shouldShowWrapper testID={IOURequestStepCurrency.displayName} + shouldReturnFocusOnDeactivate={false} > {({didScreenTransitionEnd}) => ( {}, includeSafeAreaPaddingBottom: false, + shouldReturnFocusOnDeactivate: true, }; -function StepScreenWrapper({testID, headerTitle, onBackButtonPress, onEntryTransitionEnd, children, shouldShowWrapper, includeSafeAreaPaddingBottom}) { +function StepScreenWrapper({testID, headerTitle, onBackButtonPress, onEntryTransitionEnd, children, shouldShowWrapper, includeSafeAreaPaddingBottom, shouldReturnFocusOnDeactivate}) { const styles = useThemeStyles(); if (!shouldShowWrapper) { @@ -48,6 +52,7 @@ function StepScreenWrapper({testID, headerTitle, onBackButtonPress, onEntryTrans onEntryTransitionEnd={onEntryTransitionEnd} testID={testID} shouldEnableMaxHeight={DeviceCapabilities.canUseTouchScreen()} + shouldReturnFocusOnDeactivate={shouldReturnFocusOnDeactivate} > {({insets, safeAreaPaddingBottomStyle, didScreenTransitionEnd}) => ( diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js index 720c2e02b3c..61d7c69ba19 100644 --- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js +++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js @@ -1,6 +1,6 @@ import {useRoute} from '@react-navigation/native'; import lodashGet from 'lodash/get'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; import useAnimatedStepContext from '@components/AnimatedStep/useAnimatedStepContext'; import * as TwoFactorAuthActions from '@userActions/TwoFactorAuthActions'; @@ -17,30 +17,20 @@ import {defaultAccount, TwoFactorAuthPropTypes} from './TwoFactorAuthPropTypes'; function TwoFactorAuthSteps({account = defaultAccount}) { const route = useRoute(); const backTo = lodashGet(route.params, 'backTo', ''); - const [currentStep, setCurrentStep] = useState(CONST.TWO_FACTOR_AUTH_STEPS.CODES); - - const {setAnimationDirection} = useAnimatedStepContext(); - - useEffect(() => () => TwoFactorAuthActions.clearTwoFactorAuthData(), []); - - useEffect(() => { + const currentStep = useMemo(() => { if (account.twoFactorAuthStep) { - setCurrentStep(account.twoFactorAuthStep); - return; - } - - if (account.requiresTwoFactorAuth) { - setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.ENABLED); - } else { - setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.CODES); + return account.twoFactorAuthStep; } + return account.requiresTwoFactorAuth ? CONST.TWO_FACTOR_AUTH_STEPS.ENABLED : CONST.TWO_FACTOR_AUTH_STEPS.CODES; }, [account.requiresTwoFactorAuth, account.twoFactorAuthStep]); + const {setAnimationDirection} = useAnimatedStepContext(); + + useEffect(() => () => TwoFactorAuthActions.clearTwoFactorAuthData(), []); const handleSetStep = useCallback( (step, animationDirection = CONST.ANIMATION_DIRECTION.IN) => { setAnimationDirection(animationDirection); TwoFactorAuthActions.setTwoFactorAuthStep(step); - setCurrentStep(step); }, [setAnimationDirection], ); diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 70198f38f18..6748425bf4f 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -144,6 +144,7 @@ function WorkspacePageWithSections({ shouldEnablePickerAvoiding={false} shouldEnableMaxHeight testID={WorkspacePageWithSections.displayName} + shouldReturnFocusOnDeactivate={false} shouldShowOfflineIndicatorInWideScreen={shouldShowOfflineIndicatorInWideScreen && !shouldShow} >