diff --git a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts new file mode 100644 index 000000000000..004e8bd6dbc4 --- /dev/null +++ b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts @@ -0,0 +1,8 @@ +import type {ViewProps} from 'react-native'; + +type FocusTrapContainerElementProps = ViewProps & { + /** Callback to register focus trap container element */ + onContainerElementChanged?: (element: HTMLElement | null) => void; +}; + +export default FocusTrapContainerElementProps; diff --git a/src/components/FocusTrap/FocusTrapContainerElement/index.tsx b/src/components/FocusTrap/FocusTrapContainerElement/index.tsx new file mode 100644 index 000000000000..39da870cbf3a --- /dev/null +++ b/src/components/FocusTrap/FocusTrapContainerElement/index.tsx @@ -0,0 +1,9 @@ +import type FocusTrapContainerElementProps from './FocusTrapContainerElementProps'; + +function FocusTrapContainerElement({children}: FocusTrapContainerElementProps) { + return children; +} + +FocusTrapContainerElement.displayName = 'FocusTrapContainerElement'; + +export default FocusTrapContainerElement; diff --git a/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx b/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx new file mode 100644 index 000000000000..15307ee23604 --- /dev/null +++ b/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx @@ -0,0 +1,29 @@ +/** + * A wrapper View component allowing us to register a container element for a FocusTrap + */ +import type {ForwardedRef} from 'react'; +import React from 'react'; +import {View} from 'react-native'; +import type FocusTrapContainerElementProps from './FocusTrapContainerElementProps'; + +function FocusTrapContainerElement({onContainerElementChanged, ...props}: FocusTrapContainerElementProps, ref?: ForwardedRef) { + return ( + { + const r = ref; + if (typeof r === 'function') { + r(node); + } else if (r) { + r.current = node; + } + onContainerElementChanged?.(node as unknown as HTMLElement | null); + }} + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + /> + ); +} + +FocusTrapContainerElement.displayName = 'FocusTrapContainerElement'; + +export default React.forwardRef(FocusTrapContainerElement); diff --git a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts index d2f6e5323445..aa063a10271a 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts +++ b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts @@ -1,5 +1,10 @@ +import type FocusTrap from 'focus-trap-react'; + type FocusTrapForScreenProps = { children: React.ReactNode; + + /** Overrides the focus trap settings */ + focusTrapSettings?: Pick; }; export default FocusTrapForScreenProps; diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index cd8dc3ddaa0a..c2a45d39f3f3 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -10,12 +10,15 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import CONST from '@src/CONST'; import type FocusTrapProps from './FocusTrapProps'; -function FocusTrapForScreen({children}: FocusTrapProps) { +function FocusTrapForScreen({children, focusTrapSettings}: FocusTrapProps) { const isFocused = useIsFocused(); const route = useRoute(); const {isSmallScreenWidth} = useWindowDimensions(); const isActive = useMemo(() => { + if (typeof focusTrapSettings?.active !== 'undefined') { + return focusTrapSettings.active; + } // Focus trap can't be active on bottom tab screens because it would block access to the tab bar. if (BOTTOM_TAB_SCREENS.find((screen) => screen === route.name)) { return false; @@ -31,12 +34,13 @@ function FocusTrapForScreen({children}: FocusTrapProps) { return false; } return true; - }, [isFocused, isSmallScreenWidth, route.name]); + }, [isFocused, isSmallScreenWidth, route.name, focusTrapSettings?.active]); return ( {children} diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index f845cfda3638..866c648d934e 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -20,6 +20,7 @@ import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; import CustomDevMenu from './CustomDevMenu'; import FocusTrapForScreens from './FocusTrap/FocusTrapForScreen'; +import type FocusTrapForScreenProps from './FocusTrap/FocusTrapForScreen/FocusTrapProps'; import HeaderGap from './HeaderGap'; import KeyboardAvoidingView from './KeyboardAvoidingView'; import OfflineIndicator from './OfflineIndicator'; @@ -99,6 +100,9 @@ type ScreenWrapperProps = { /** Whether to show offline indicator on wide screens */ shouldShowOfflineIndicatorInWideScreen?: boolean; + + /** Overrides the focus trap default settings */ + focusTrapSettings?: FocusTrapForScreenProps['focusTrapSettings']; }; type ScreenWrapperStatusContextType = {didScreenTransitionEnd: boolean}; @@ -126,6 +130,7 @@ function ScreenWrapper( shouldAvoidScrollOnVirtualViewport = true, shouldShowOfflineIndicatorInWideScreen = false, shouldUseCachedViewportHeight = false, + focusTrapSettings, }: ScreenWrapperProps, ref: ForwardedRef, ) { @@ -242,7 +247,7 @@ function ScreenWrapper( } return ( - + void; + + /** Callback to register focus trap container element */ + onFocusTrapContainerElementChanged?: (element: HTMLElement | null) => void; }; type IconAndTitle = { @@ -53,7 +57,7 @@ function getOpacity(position: Animated.AnimatedInterpolation, routesLeng return activeValue; } -function TabSelector({state, navigation, onTabPress = () => {}, position}: TabSelectorProps) { +function TabSelector({state, navigation, onTabPress = () => {}, position, onFocusTrapContainerElementChanged}: TabSelectorProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -83,49 +87,51 @@ function TabSelector({state, navigation, onTabPress = () => {}, position}: TabSe }, [defaultAffectedAnimatedTabs, state.index]); return ( - - {state.routes.map((route, index) => { - const activeOpacity = getOpacity(position, state.routes.length, index, true, affectedAnimatedTabs); - const inactiveOpacity = getOpacity(position, state.routes.length, index, false, affectedAnimatedTabs); - const backgroundColor = getBackgroundColor(state.routes.length, index, affectedAnimatedTabs); - const isActive = index === state.index; - const {icon, title} = getIconAndTitle(route.name, translate); - - const onPress = () => { - if (isActive) { - return; - } - - setAffectedAnimatedTabs([state.index, index]); - - const event = navigation.emit({ - type: 'tabPress', - target: route.key, - canPreventDefault: true, - }); - - if (!event.defaultPrevented) { - // The `merge: true` option makes sure that the params inside the tab screen are preserved - navigation.navigate({key: route.key, merge: true}); - } - - onTabPress(route.name); - }; - - return ( - - ); - })} - + + + {state.routes.map((route, index) => { + const activeOpacity = getOpacity(position, state.routes.length, index, true, affectedAnimatedTabs); + const inactiveOpacity = getOpacity(position, state.routes.length, index, false, affectedAnimatedTabs); + const backgroundColor = getBackgroundColor(state.routes.length, index, affectedAnimatedTabs); + const isActive = index === state.index; + const {icon, title} = getIconAndTitle(route.name, translate); + + const onPress = () => { + if (isActive) { + return; + } + + setAffectedAnimatedTabs([state.index, index]); + + const event = navigation.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }); + + if (!event.defaultPrevented) { + // The `merge: true` option makes sure that the params inside the tab screen are preserved + navigation.navigate({key: route.key, merge: true}); + } + + onTabPress(route.name); + }; + + return ( + + ); + })} + + ); } diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index d5507c8c83db..635b9deddcfe 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {Animated, StyleSheet} from 'react-native'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import TabIcon from './TabIcon'; import TabLabel from './TabLabel'; @@ -37,6 +38,7 @@ function TabSelectorItem({icon, title = '', onPress = () => {}, backgroundColor style={[styles.tabSelectorButton]} wrapperStyle={[styles.flex1]} onPress={onPress} + role={CONST.ROLE.BUTTON} > {({hovered}) => ( diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 734d8ee2a373..a1d9d1786dd2 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -1,10 +1,13 @@ import type {MaterialTopTabNavigationEventMap} from '@react-navigation/material-top-tabs'; import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs'; import type {EventMapCore, NavigationState, ScreenListeners} from '@react-navigation/native'; -import React from 'react'; -import {withOnyx} from 'react-native-onyx'; +import {useRoute} from '@react-navigation/native'; +import React, {useCallback, useContext, useEffect, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import type {TabSelectorProps} from '@components/TabSelector/TabSelector'; +import useThemeStyles from '@hooks/useThemeStyles'; import type {IOURequestType} from '@libs/actions/IOU'; import Tab from '@userActions/Tab'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -30,37 +33,144 @@ type OnyxTabNavigatorProps = OnyxTabNavigatorOnyxProps & tabBar: (props: TabSelectorProps) => React.ReactNode; screenListeners?: ScreenListeners; + + /** Callback to register the focus trap container elements of the current active tab. + * Use this in the parent component to get the focus trap container element of the active tab, + * then pass it to the ScreenWrapper so that only focusable elements of the active tab are included in the focus trap + * Check the `IOURequestStartPage.tsx` and `NewChatSelectorPage.tsx` components for example usage + */ + onActiveTabFocusTrapContainerElementChanged?: (containerElement: HTMLElement | null) => void; + + /** Callback to register the focus trap container elements of the tab bar. + * This callback is useful when the custom-rendered tab bar is supporting the focus trap container element registration (which is the case of `TabSelector.tsx` component). + * Together, with the `onActiveTabFocusTrapContainerElementChanged` callback, we can manage the focus trap of the tab navigator in the parent component. + */ + onTabBarFocusTrapContainerElementChanged?: (containerElement: HTMLElement | null) => void; }; // eslint-disable-next-line rulesdir/no-inline-named-export -export const TopTab = createMaterialTopTabNavigator(); +const TopTab = createMaterialTopTabNavigator(); + +// The TabFocusTrapContext is to collect the focus trap container element of each tab screen. +// This provider is placed in the OnyxTabNavigator component and the consumer is in the TabScreenWithFocusTrapWrapper component. +const TabFocusTrapContext = React.createContext<(tabName: string, containerElement: HTMLElement | null) => void>(() => {}); // This takes all the same props as MaterialTopTabsNavigator: https://reactnavigation.org/docs/material-top-tab-navigator/#props, // except ID is now required, and it gets a `selectedTab` from Onyx -function OnyxTabNavigator({id, selectedTab, children, onTabSelected = () => {}, screenListeners, ...rest}: OnyxTabNavigatorProps) { +// It also takes 2 more optional callbacks to manage the focus trap container elements of the tab bar and the active tab +function OnyxTabNavigator({ + id, + selectedTab, + tabBar: TabBar, + children, + onTabBarFocusTrapContainerElementChanged, + onActiveTabFocusTrapContainerElementChanged, + onTabSelected = () => {}, + screenListeners, + ...rest +}: OnyxTabNavigatorProps) { + // Mapping of tab name to focus trap container element + const [focusTrapContainerElementMapping, setFocusTrapContainerElementMapping] = useState>({}); + + // This callback is used to register the focus trap container element of each avaiable tab screen + const setTabFocusTrapContainerElement = useCallback((tabName: string, containerElement: HTMLElement | null) => { + setFocusTrapContainerElementMapping((prevMapping) => { + const resultMapping = {...prevMapping}; + if (containerElement) { + resultMapping[tabName] = containerElement; + } else { + delete resultMapping[tabName]; + } + return resultMapping; + }); + }, []); + + /** + * This is a TabBar wrapper component that includes the focus trap container element callback. + * In `TabSelector.tsx` component, the callback prop to register focus trap container element is supported out of the box + */ + const TabBarWithFocusTrapInclusion = useCallback( + (props: TabSelectorProps) => { + return ( + + ); + }, + [onTabBarFocusTrapContainerElementChanged, TabBar], + ); + + // If the selected tab changes, we need to update the focus trap container element of the active tab + useEffect(() => { + onActiveTabFocusTrapContainerElementChanged?.(selectedTab ? focusTrapContainerElementMapping[selectedTab] : null); + }, [selectedTab, focusTrapContainerElementMapping, onActiveTabFocusTrapContainerElementChanged]); + + return ( + + { + const event = e as unknown as EventMapCore['state']; + const state = event.data.state; + const index = state.index; + const routeNames = state.routeNames; + Tab.setSelectedTab(id, routeNames[index] as SelectedTabRequest); + onTabSelected(routeNames[index] as IOURequestType); + }, + ...(screenListeners ?? {}), + }} + screenOptions={defaultScreenOptions} + > + {children} + + + ); +} + +/** + * We should use this wrapper for each tab screen. This will help register the focus trap container element of each tab screen. + * In the OnyxTabNavigator component, depending on the selected tab, we will further register the correct container element of the current active tab to the parent focus trap. + * This must be used if we want to include all tabbable elements of one tab screen in the parent focus trap if that tab screen is active. + * Example usage (check the `IOURequestStartPage.tsx` and `NewChatSelectorPage.tsx` components for more info) + * ```tsx + * + * + * {() => ( + * + * + * + * )} + * + * + * ``` + */ +function TabScreenWithFocusTrapWrapper({children}: {children?: React.ReactNode}) { + const route = useRoute(); + const styles = useThemeStyles(); + const setTabContainerElement = useContext(TabFocusTrapContext); + const handleContainerElementChanged = useCallback( + (element: HTMLElement | null) => { + setTabContainerElement(route.name, element); + }, + [setTabContainerElement, route.name], + ); + return ( - { - const event = e as unknown as EventMapCore['state']; - const state = event.data.state; - const index = state.index; - const routeNames = state.routeNames; - Tab.setSelectedTab(id, routeNames[index] as SelectedTabRequest); - onTabSelected(routeNames[index] as IOURequestType); - }, - ...(screenListeners ?? {}), - }} - screenOptions={defaultScreenOptions} + {children} - + ); } @@ -71,3 +181,5 @@ export default withOnyx({ key: ({id}) => `${ONYXKEYS.COLLECTION.SELECTED_TAB}${id}`, }, })(OnyxTabNavigator); + +export {TabScreenWithFocusTrapWrapper, TopTab}; diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 17baddd39251..19accfbe5b20 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -319,6 +319,8 @@ function NewChatPage({isGroupChat}: NewChatPageProps) { includePaddingTop={false} shouldEnablePickerAvoiding={false} testID={NewChatPage.displayName} + // Disable the focus trap of this page to activate the parent focus trap in `NewChatSelectorPage`. + focusTrapSettings={{active: false}} > (null); + const [tabBarContainerElement, setTabBarContainerElement] = useState(null); + const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); + + // Theoretically, the focus trap container element can be null (due to component unmount/remount), so we filter out the null elements + const containerElements = useMemo(() => { + return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element) as HTMLElement[]; + }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); + + const onTabFocusTrapContainerElementChanged = useCallback((activeTabElement?: HTMLElement | null) => { + setActiveTabContainerElement(activeTabElement ?? null); + }, []); return ( - + + + + - - + + {() => ( + + + + )} + + + {() => ( + + + + )} + ); diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index e798051710fd..2a2d5bee5bc3 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -1,9 +1,9 @@ -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; +import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -12,14 +12,13 @@ import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import * as KeyDownPressListener from '@libs/KeyboardShortcut/KeyDownPressListener'; import Navigation from '@libs/Navigation/Navigation'; -import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import * as IOU from '@userActions/IOU'; import type {IOURequestType} from '@userActions/IOU'; +import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -76,21 +75,6 @@ function IOURequestStartPage({ const {canUseP2PDistanceRequests} = usePermissions(iouType); const isFromGlobalCreate = isEmptyObject(report?.reportID); - useFocusEffect( - useCallback(() => { - const handler = (event: KeyboardEvent) => { - if (event.code !== CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { - return; - } - event.preventDefault(); - event.stopPropagation(); - }; - KeyDownPressListener.addKeyDownPressListener(handler); - - return () => KeyDownPressListener.removeKeyDownPressListener(handler); - }, []), - ); - // Clear out the temporary expense if the reportID in the URL has changed from the transaction's reportID useEffect(() => { if (transaction?.reportID === reportID) { @@ -117,6 +101,14 @@ function IOURequestStartPage({ [policy, reportID, isFromGlobalCreate, transaction], ); + const [headerWithBackBtnContainerElement, setHeaderWithBackButtonContainerElement] = useState(null); + const [tabBarContainerElement, setTabBarContainerElement] = useState(null); + const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); + + const focusTrapContainerElements = useMemo(() => { + return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element) as HTMLElement[]; + }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); + if (!transaction?.transactionID) { // The draft transaction is initialized only after the component is mounted, // which will lead to briefly displaying the Not Found page without this loader. @@ -137,6 +129,7 @@ function IOURequestStartPage({ shouldEnableMinHeight={DeviceCapabilities.canUseTouchScreen()} headerGapStyles={isDraggingOver ? [styles.receiptDropHeaderGap] : []} testID={IOURequestStartPage.displayName} + focusTrapSettings={{containerElements: focusTrapContainerElements}} > {({safeAreaPaddingBottomStyle}) => ( - + + + + {iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.INVOICE ? ( {() => ( - + + + + )} + + + {() => ( + + + )} - {() => } - {shouldDisplayDistanceRequest && {() => }} + {shouldDisplayDistanceRequest && ( + + {() => ( + + + + )} + + )} ) : ( - + + + )} diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx index 3f53f9e1cf6d..c9218b9761b9 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.tsx +++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx @@ -253,6 +253,8 @@ function WorkspaceNewRoomPage({policies, reports, formState, session, activePoli includePaddingTop={false} shouldEnablePickerAvoiding={false} testID={WorkspaceNewRoomPage.displayName} + // Disable the focus trap of this page to activate the parent focus trap in `NewChatSelectorPage`. + focusTrapSettings={{active: false}} > {({insets}) => workspaceOptions.length === 0 ? (