diff --git a/package-lock.json b/package-lock.json index acda40cdfdf2..1abac7e5e109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "domhandler": "^4.3.0", "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29", "fbjs": "^3.0.2", + "focus-trap-react": "^10.2.1", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-when": "^3.5.2", @@ -30787,6 +30788,28 @@ "readable-stream": "^2.3.6" } }, + "node_modules/focus-trap": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz", + "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==", + "dependencies": { + "tabbable": "^6.2.0" + } + }, + "node_modules/focus-trap-react": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz", + "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==", + "dependencies": { + "focus-trap": "^7.5.2", + "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", @@ -49044,6 +49067,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", @@ -75075,6 +75103,23 @@ "readable-stream": "^2.3.6" } }, + "focus-trap": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz", + "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==", + "requires": { + "tabbable": "^6.2.0" + } + }, + "focus-trap-react": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz", + "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==", + "requires": { + "focus-trap": "^7.5.2", + "tabbable": "^6.2.0" + } + }, "follow-redirects": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", @@ -88065,6 +88110,11 @@ "version": "2.0.15", "dev": true }, + "tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "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 d64a392990f4..b159e6212041 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "domhandler": "^4.3.0", "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29", "fbjs": "^3.0.2", + "focus-trap-react": "^10.2.1", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-when": "^3.5.2", diff --git a/src/components/ConfirmModal.js b/src/components/ConfirmModal.js index 3fe3838c8c81..7c720c4bd681 100755 --- a/src/components/ConfirmModal.js +++ b/src/components/ConfirmModal.js @@ -98,6 +98,7 @@ function ConfirmModal(props) { shouldSetModalVisibility={props.shouldSetModalVisibility} onModalHide={props.onModalHide} type={props.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, + }} + > + + + ) : ( + 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 000000000000..500b4b4315d9 --- /dev/null +++ b/src/components/FocusTrapView/types.ts @@ -0,0 +1,21 @@ +import {ViewProps} from 'react-native'; +import 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; +} & ViewProps; + +export default FocusTrapViewProps; diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index f760d3c0244e..710ecd79b375 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -1,13 +1,16 @@ import React, {useState} from 'react'; +import FocusTrapView from '@components/FocusTrapView'; import withWindowDimensions from '@components/withWindowDimensions'; import StatusBar from '@libs/StatusBar'; import * as StyleUtils from '@styles/StyleUtils'; import useTheme from '@styles/themes/useTheme'; +import useThemeStyles from '@styles/useThemeStyles'; import CONST from '@src/CONST'; import BaseModal from './BaseModal'; import 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 [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 84e610b694e4..7465e28b28ad 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 3fa60e6ac765..ddb51a68ba1b 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -61,6 +61,9 @@ type BaseModalProps = WindowDimensionsProps & * See: https://github.com/react-native-modal/react-native-modal/pull/116 * */ hideModalContentWhileAnimating?: boolean; + + /** Whether the modal should use focus trap */ + shouldEnableFocusTrap?: boolean; }; export default BaseModalProps; diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js index f9173c15da7d..01c28b3b8463 100644 --- a/src/components/ScreenWrapper/index.js +++ b/src/components/ScreenWrapper/index.js @@ -1,10 +1,11 @@ -import {useNavigation} from '@react-navigation/native'; +import {useIsFocused, useNavigation} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import React, {useEffect, useRef, useState} from 'react'; import {Keyboard, PanResponder, View} from 'react-native'; import {PickerAvoidingView} from 'react-native-picker-select'; import _ from 'underscore'; import CustomDevMenu from '@components/CustomDevMenu'; +import FocusTrapView from '@components/FocusTrapView'; import HeaderGap from '@components/HeaderGap'; import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import OfflineIndicator from '@components/OfflineIndicator'; @@ -39,6 +40,8 @@ const ScreenWrapper = React.forwardRef( shouldDismissKeyboardBeforeClose, onEntryTransitionEnd, testID, + shouldDisableFocusTrap, + shouldEnableAutoFocus, }, ref, ) => { @@ -48,6 +51,7 @@ const ScreenWrapper = React.forwardRef( const {isDevelopment} = useEnvironment(); const {isOffline} = useNetwork(); const navigation = useNavigation(); + const isFocused = useIsFocused(); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined; const minHeight = shouldEnableMinHeight ? initialHeight : undefined; @@ -146,20 +150,27 @@ const ScreenWrapper = React.forwardRef( style={styles.flex1} enabled={shouldEnablePickerAvoiding} > - - {isDevelopment && } - {isDevelopment && } - { - // If props.children is a function, call it to provide the insets to the children. - _.isFunction(children) - ? children({ - insets, - safeAreaPaddingBottomStyle, - didScreenTransitionEnd, - }) - : children - } - {isSmallScreenWidth && shouldShowOfflineIndicator && } + + + {isDevelopment && } + {isDevelopment && } + { + // If props.children is a function, call it to provide the insets to the children. + _.isFunction(children) + ? children({ + insets, + safeAreaPaddingBottomStyle, + didScreenTransitionEnd, + }) + : children + } + {isSmallScreenWidth && shouldShowOfflineIndicator && } + diff --git a/src/components/ScreenWrapper/propTypes.js b/src/components/ScreenWrapper/propTypes.js index c98968bb112b..8984c860a15f 100644 --- a/src/components/ScreenWrapper/propTypes.js +++ b/src/components/ScreenWrapper/propTypes.js @@ -48,6 +48,12 @@ const propTypes = { /** Styles for the offline indicator */ offlineIndicatorStyle: stylePropTypes, + + /** Whether to disable the focus trap */ + shouldDisableFocusTrap: PropTypes.bool, + + /** Whether to disable auto focus of the focus trap */ + shouldEnableAutoFocus: PropTypes.bool, }; const defaultProps = { @@ -63,6 +69,8 @@ const defaultProps = { shouldShowOfflineIndicator: true, offlineIndicatorStyle: [], headerGapStyles: [], + shouldDisableFocusTrap: false, + shouldEnableAutoFocus: false, }; export {propTypes, defaultProps}; diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 4b3c927ef317..17ea63ca1003 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -148,7 +148,10 @@ function ProfilePage(props) { }, [accountID, hasMinimumDetails]); return ( - + Navigation.goBack(navigateBackTo)} diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index 88d5ddec0c54..abfe625f1508 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -163,7 +163,10 @@ function ReportDetailsPage(props) { ) : null; return ( - + {({insets}) => ( <> diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js index d1fe21d8cf4e..2ebe96d60ed8 100644 --- a/src/pages/iou/SplitBillDetailsPage.js +++ b/src/pages/iou/SplitBillDetailsPage.js @@ -109,7 +109,10 @@ function SplitBillDetailsPage(props) { ); return ( - + diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js index 31a33efa3996..9a9e42f75576 100644 --- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js +++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js @@ -1,4 +1,4 @@ -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'; @@ -13,30 +13,20 @@ import TwoFactorAuthContext from './TwoFactorAuthContext'; import {defaultAccount, TwoFactorAuthPropTypes} from './TwoFactorAuthPropTypes'; function TwoFactorAuthSteps({account = defaultAccount}) { - 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/types/utils/viewRef.ts b/src/types/utils/viewRef.ts new file mode 100644 index 000000000000..015d88cc5a8b --- /dev/null +++ b/src/types/utils/viewRef.ts @@ -0,0 +1,5 @@ +import {View} from 'react-native'; + +const viewRef = (ref: React.RefObject) => ref as React.RefObject; + +export default viewRef;