Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add focus trap to the RHP (v3) #32800

Closed
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"expo": "^49.0.0",
"expo-image": "1.8.1",
"fbjs": "^3.0.2",
"focus-trap-react": "^10.2.3",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-expo": "^49.0.0",
Expand Down
1 change: 1 addition & 0 deletions src/components/ConfirmModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,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
>
<ConfirmContent
title={props.title}
Expand Down
12 changes: 12 additions & 0 deletions src/components/FocusTrapView/index.native.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type FocusTrapViewProps from './types';

/*
* The FocusTrap is only used on web and desktop
*/
function FocusTrapView({children}: FocusTrapViewProps) {
return children;
}

FocusTrapView.displayName = 'FocusTrapView';

export default FocusTrapView;
43 changes: 43 additions & 0 deletions src/components/FocusTrapView/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* The FocusTrap is only used on web and desktop
*/
import FocusTrap from 'focus-trap-react';
import React, {useRef} from 'react';
import {View} from 'react-native';
import viewRef from '@src/types/utils/viewRef';
import type FocusTrapViewProps from './types';

function FocusTrapView({isEnabled = true, isActive = true, shouldEnableAutoFocus = false, shouldReturnFocusOnDeactivate = true, ...props}: FocusTrapViewProps) {
/**
* Focus trap always needs a focusable element.
* In case that we don't have any focusable elements in the modal,
* the FocusTrap will use fallback View element using this ref.
*/
const ref = useRef<HTMLDivElement>(null);

return isEnabled ? (
<FocusTrap
active={isActive}
focusTrapOptions={{
initialFocus: () => (shouldEnableAutoFocus && ref.current) ?? false,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
fallbackFocus: () => ref.current!,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid disabling this lint rule

Copy link
Contributor Author

@kosmydel kosmydel Feb 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was discussed here. I'm not sure if we can find a better approach. We need to pass here a function (which can't return null/undefined), but during the initial function call the ref.current is null.

An alternative is, passing an empty string, but not sure if we have any gain from this approach:

fallbackFocus: () => ref.current ?? '',

clickOutsideDeactivates: true,
returnFocusOnDeactivate: shouldReturnFocusOnDeactivate,
}}
>
<View
ref={viewRef(ref)}
tabIndex={0}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
</FocusTrap>
) : (
props.children
);
}

FocusTrapView.displayName = 'FocusTrapView';

export default FocusTrapView;
27 changes: 27 additions & 0 deletions src/components/FocusTrapView/types.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 11 additions & 2 deletions src/components/Modal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import React, {useState} from 'react';
import FocusTrapView from '@components/FocusTrapView';
import withWindowDimensions from '@components/withWindowDimensions';
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<string>();
Expand Down Expand Up @@ -49,7 +52,13 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
fullscreen={fullscreen}
type={type}
>
{children}
<FocusTrapView
isEnabled={shouldEnableFocusTrap}
isActive
style={styles.noSelect}
>
{children}
</FocusTrapView>
</BaseModal>
);
}
Expand Down
4 changes: 4 additions & 0 deletions src/components/Modal/modalPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const propTypes = {
* */
hideModalContentWhileAnimating: PropTypes.bool,

/** Should the modal use custom focus trap logic */
shouldEnableFocusTrap: PropTypes.bool,

...windowDimensionsPropTypes,
};

Expand All @@ -84,6 +87,7 @@ const defaultProps = {
statusBarTranslucent: true,
avoidKeyboard: false,
hideModalContentWhileAnimating: false,
shouldEnableFocusTrap: false,
};

export {propTypes, defaultProps};
3 changes: 3 additions & 0 deletions src/components/Modal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 37 additions & 15 deletions src/components/ScreenWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -86,6 +87,15 @@ type ScreenWrapperProps = {
* This is required because transitionEnd event doesn't trigger in the testing environment.
*/
navigation?: StackNavigationProp<RootStackParamList>;

/** Whether to disable the focus trap */
shouldDisableFocusTrap?: boolean;

kosmydel marked this conversation as resolved.
Show resolved Hide resolved
/** 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(
Expand All @@ -106,6 +116,9 @@ function ScreenWrapper(
onEntryTransitionEnd,
testID,
navigation: navigationProp,
shouldDisableFocusTrap = false,
shouldEnableAutoFocus = false,
kosmydel marked this conversation as resolved.
Show resolved Hide resolved
shouldReturnFocusOnDeactivate = true,
}: ScreenWrapperProps,
ref: ForwardedRef<View>,
) {
Expand All @@ -124,6 +137,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;
Expand Down Expand Up @@ -224,20 +238,28 @@ function ScreenWrapper(
style={styles.flex1}
enabled={shouldEnablePickerAvoiding}
>
<HeaderGap styles={headerGapStyles} />
{isDevelopment && <TestToolsModal />}
{isDevelopment && <CustomDevMenu />}
{
// 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 && <OfflineIndicator style={offlineIndicatorStyle} />}
<FocusTrapView
style={[styles.flex1, styles.noSelect]}
isEnabled={!shouldDisableFocusTrap}
shouldEnableAutoFocus={shouldEnableAutoFocus}
kosmydel marked this conversation as resolved.
Show resolved Hide resolved
shouldReturnFocusOnDeactivate={shouldReturnFocusOnDeactivate}
isActive={isFocused}
>
<HeaderGap styles={headerGapStyles} />
{isDevelopment && <TestToolsModal />}
{isDevelopment && <CustomDevMenu />}
{
// 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 && <OfflineIndicator style={offlineIndicatorStyle} />}
</FocusTrapView>
</PickerAvoidingView>
</KeyboardAvoidingView>
</View>
Expand Down
5 changes: 4 additions & 1 deletion src/pages/ProfilePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ function ProfilePage(props) {
}, [accountID, hasMinimumDetails]);

return (
<ScreenWrapper testID={ProfilePage.displayName}>
<ScreenWrapper
testID={ProfilePage.displayName}
shouldEnableAutoFocus
>
<HeaderWithBackButton
title={props.translate('common.profile')}
onBackButtonPress={() => Navigation.goBack(navigateBackTo)}
Expand Down
5 changes: 4 additions & 1 deletion src/pages/ReportDetailsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ function ReportDetailsPage(props) {
) : null;

return (
<ScreenWrapper testID={ReportDetailsPage.displayName}>
<ScreenWrapper
testID={ReportDetailsPage.displayName}
shouldEnableAutoFocus
>
<FullPageNotFoundView shouldShow={_.isEmpty(props.report)}>
<HeaderWithBackButton
title={props.translate('common.details')}
Expand Down
1 change: 1 addition & 0 deletions src/pages/SearchPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ function SearchPage({betas, personalDetails, reports, isSearchingForReports}) {
includeSafeAreaPaddingBottom={false}
testID={SearchPage.displayName}
onEntryTransitionEnd={updateOptions}
shouldReturnFocusOnDeactivate={false}
>
{({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => (
<>
Expand Down
1 change: 1 addition & 0 deletions src/pages/home/ReportScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ function ReportScreen({
style={screenWrapperStyle}
shouldEnableKeyboardAvoidingView={isTopMostReportId}
testID={ReportScreen.displayName}
shouldDisableFocusTrap
kosmydel marked this conversation as resolved.
Show resolved Hide resolved
>
<FullPageNotFoundView
shouldShow={shouldShowNotFoundPage}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function BaseSidebarScreen(props) {
shouldEnableKeyboardAvoidingView={false}
style={[styles.sidebar, Browser.isMobile() ? styles.userSelectNone : {}]}
testID={BaseSidebarScreen.displayName}
shouldDisableFocusTrap
kosmydel marked this conversation as resolved.
Show resolved Hide resolved
>
{({insets}) => (
<>
Expand Down
1 change: 1 addition & 0 deletions src/pages/iou/IOUCurrencySelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ function IOUCurrencySelection(props) {
includeSafeAreaPaddingBottom={false}
onEntryTransitionEnd={() => optionsSelectorRef.current && optionsSelectorRef.current.focus()}
testID={IOUCurrencySelection.displayName}
shouldReturnFocusOnDeactivate={false}
>
{({didScreenTransitionEnd}) => (
<>
Expand Down
Loading
Loading