From aaa954456e941068fd7c56f59f77a772437511a0 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 19 Jul 2024 11:59:23 +0700 Subject: [PATCH 01/10] fix: only add active tab as focus trap container --- .../FocusTrapForScreen/FocusTrapProps.ts | 3 + .../FocusTrapForScreen/index.web.tsx | 4 +- src/components/HeaderWithBackButton/index.tsx | 15 +++-- src/components/HeaderWithBackButton/types.ts | 3 + src/components/ScreenWrapper.tsx | 6 +- src/components/TabSelector/TabSelector.tsx | 12 +++- src/hooks/useFocusTrapContainers/index.tsx | 7 +++ .../useFocusTrapContainers/index.web.tsx | 17 ++++++ src/hooks/useFocusTrapContainers/type.ts | 7 +++ src/pages/iou/MoneyRequestAmountForm.tsx | 11 +++- src/pages/iou/request/IOURequestStartPage.tsx | 58 +++++++++++++------ .../iou/request/step/IOURequestStepAmount.tsx | 5 ++ .../request/step/IOURequestStepDistance.tsx | 10 +++- .../request/step/IOURequestStepScan/index.tsx | 8 ++- .../request/step/IOURequestStepScan/types.ts | 2 + 15 files changed, 140 insertions(+), 28 deletions(-) create mode 100644 src/hooks/useFocusTrapContainers/index.tsx create mode 100644 src/hooks/useFocusTrapContainers/index.web.tsx create mode 100644 src/hooks/useFocusTrapContainers/type.ts diff --git a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts index d2f6e532344..ab2d3f29fa1 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts +++ b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts @@ -1,5 +1,8 @@ +import type FocusTrap from "focus-trap-react"; + type FocusTrapForScreenProps = { children: React.ReactNode; + focusTrapSettings?: Pick; }; export default FocusTrapForScreenProps; diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index e7fe135c952..8876b67bfca 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -9,7 +9,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; 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(); @@ -36,6 +36,7 @@ function FocusTrapForScreen({children}: FocusTrapProps) { {children} diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 2d73e3c2dd2..4e59dfbc22d 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -1,5 +1,3 @@ -import React, {useMemo} from 'react'; -import {Keyboard, StyleSheet, View} from 'react-native'; import Avatar from '@components/Avatar'; import AvatarWithDisplayName from '@components/AvatarWithDisplayName'; import Header from '@components/Header'; @@ -15,11 +13,13 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; -import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; -import variables from '@styles/variables'; +import getButtonState from '@libs/getButtonState'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import variables from '@styles/variables'; +import React, { useMemo } from 'react'; +import { Keyboard, StyleSheet, View } from 'react-native'; import type HeaderWithBackButtonProps from './types'; function HeaderWithBackButton({ @@ -60,6 +60,7 @@ function HeaderWithBackButton({ shouldNavigateToTopMostReport = false, progressBarPercentage, style, + registerFocusTrapContainer }: HeaderWithBackButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -141,6 +142,12 @@ function HeaderWithBackButton({ shouldOverlay && StyleSheet.absoluteFillObject, style, ]} + ref={(viewNode) => { + if(viewNode) { + const unregister = registerFocusTrapContainer?.(viewNode as unknown as HTMLElement); + return () => unregister?.(); + } + }} > {shouldShowBackButton && ( diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 6b08dd74dc8..1cbf9d836f1 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -9,6 +9,7 @@ import type {Policy, Report} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; +import { RegisterFocusTrapContainerCallback } from '@hooks/useFocusTrapContainers/type'; type ThreeDotsMenuItem = { /** An icon element displayed on the left side */ @@ -130,6 +131,8 @@ type HeaderWithBackButtonProps = Partial & { /** Additional styles to add to the component */ style?: StyleProp; + + registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; export type {ThreeDotsMenuItem}; diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index f845cfda363..142fbdff798 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -26,6 +26,7 @@ import OfflineIndicator from './OfflineIndicator'; import SafeAreaConsumer from './SafeAreaConsumer'; import TestToolsModal from './TestToolsModal'; import withNavigationFallback from './withNavigationFallback'; +import FocusTrapForScreenProps from './FocusTrap/FocusTrapForScreen/FocusTrapProps'; type ScreenWrapperChildrenProps = { insets: EdgeInsets; @@ -99,6 +100,8 @@ type ScreenWrapperProps = { /** Whether to show offline indicator on wide screens */ shouldShowOfflineIndicatorInWideScreen?: boolean; + + focusTrapSettings?: FocusTrapForScreenProps['focusTrapSettings']; }; type ScreenWrapperStatusContextType = {didScreenTransitionEnd: boolean}; @@ -126,6 +129,7 @@ function ScreenWrapper( shouldAvoidScrollOnVirtualViewport = true, shouldShowOfflineIndicatorInWideScreen = false, shouldUseCachedViewportHeight = false, + focusTrapSettings }: ScreenWrapperProps, ref: ForwardedRef, ) { @@ -242,7 +246,7 @@ function ScreenWrapper( } return ( - + void; + + registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; type IconAndTitle = { @@ -53,7 +56,7 @@ function getOpacity(position: Animated.AnimatedInterpolation, routesLeng return activeValue; } -function TabSelector({state, navigation, onTabPress = () => {}, position}: TabSelectorProps) { +function TabSelector({state, navigation, onTabPress = () => {}, position, registerFocusTrapContainer}: TabSelectorProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -83,7 +86,12 @@ function TabSelector({state, navigation, onTabPress = () => {}, position}: TabSe }, [defaultAffectedAnimatedTabs, state.index]); return ( - + { + if(viewNode) { + const unregister = registerFocusTrapContainer?.(viewNode as unknown as HTMLElement); + return () => unregister?.(); + } + }}> {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); diff --git a/src/hooks/useFocusTrapContainers/index.tsx b/src/hooks/useFocusTrapContainers/index.tsx new file mode 100644 index 00000000000..cbe3142b7e8 --- /dev/null +++ b/src/hooks/useFocusTrapContainers/index.tsx @@ -0,0 +1,7 @@ +import { UseFocusTrapContainers } from "./type" + +const useFocusTrapContainers: UseFocusTrapContainers = () => { + return [[], () => () => {}] +} + +export default useFocusTrapContainers; \ No newline at end of file diff --git a/src/hooks/useFocusTrapContainers/index.web.tsx b/src/hooks/useFocusTrapContainers/index.web.tsx new file mode 100644 index 00000000000..c3fab72f143 --- /dev/null +++ b/src/hooks/useFocusTrapContainers/index.web.tsx @@ -0,0 +1,17 @@ +import { useCallback, useState } from "react"; +import { UseFocusTrapContainers } from "./type"; + +const useFocusTrapContainers: UseFocusTrapContainers = () => { + const [containers, setContainers] = useState([]); + + const addContainer = useCallback((container: HTMLElement) => { + const removeContainer = () => setContainers((prevContainers) => prevContainers.filter((c) => c !== container)); + setContainers((prevContainers) => prevContainers.includes(container) ? prevContainers : [...prevContainers, container]); + + return removeContainer; + }, []) + + return [containers, addContainer] +} + +export default useFocusTrapContainers; \ No newline at end of file diff --git a/src/hooks/useFocusTrapContainers/type.ts b/src/hooks/useFocusTrapContainers/type.ts new file mode 100644 index 00000000000..ad823b45c6c --- /dev/null +++ b/src/hooks/useFocusTrapContainers/type.ts @@ -0,0 +1,7 @@ +type RemoveFocusTrapContainerFunction = () => void; + +type RegisterFocusTrapContainerCallback = (container: HTMLElement) => RemoveFocusTrapContainerFunction; + +type UseFocusTrapContainers = () => [HTMLElement[], RegisterFocusTrapContainerCallback]; + +export type {UseFocusTrapContainers, RegisterFocusTrapContainerCallback} \ No newline at end of file diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index f03b4638c83..834d80c9fcb 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -25,6 +25,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {SelectedTabRequest} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import { RegisterFocusTrapContainerCallback } from '@hooks/useFocusTrapContainers/type'; type CurrentMoney = {amount: string; currency: string; paymentMethod?: PaymentMethodType}; @@ -67,6 +68,8 @@ type MoneyRequestAmountFormProps = { /** Whether the user input should be kept or not */ shouldKeepUserInput?: boolean; + + registerFocusTrapContainer?: RegisterFocusTrapContainerCallback }; const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01; @@ -92,6 +95,7 @@ function MoneyRequestAmountForm( onSubmitButtonPress, selectedTab = CONST.TAB_REQUEST.MANUAL, shouldKeepUserInput = false, + registerFocusTrapContainer }: MoneyRequestAmountFormProps, forwardedRef: ForwardedRef, ) { @@ -251,7 +255,12 @@ function MoneyRequestAmountForm( }, [selectedTab]); return ( - + { + if(scrollViewNode) { + const unregister = registerFocusTrapContainer?.(scrollViewNode as unknown as HTMLElement); + return () => unregister?.(); + } + }} contentContainerStyle={styles.flexGrow1}> onMouseDown(event, [AMOUNT_VIEW_ID])} diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index e798051710f..e686d291ea3 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -29,6 +29,7 @@ import IOURequestStepAmount from './step/IOURequestStepAmount'; import IOURequestStepDistance from './step/IOURequestStepDistance'; import IOURequestStepScan from './step/IOURequestStepScan'; import type {WithWritableReportOrNotFoundProps} from './step/withWritableReportOrNotFound'; +import useFocusTrapContainers from '@hooks/useFocusTrapContainers'; type IOURequestStartPageOnyxProps = { /** The report that holds the transaction */ @@ -76,20 +77,20 @@ 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); - }, []), - ); + // 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(() => { @@ -117,6 +118,20 @@ function IOURequestStartPage({ [policy, reportID, isFromGlobalCreate, transaction], ); + const [containers, addContainer] = useFocusTrapContainers(); + const [manualTabContainers, addManualTabContainer] = useFocusTrapContainers(); + const [scanTabContainers, addScanTabContainer] = useFocusTrapContainers(); + const [distanceTabContainers, addDistanceTabContainer] = useFocusTrapContainers(); + + const focusTrapContainers = [ + ...containers, + ...(selectedTab === CONST.TAB_REQUEST.MANUAL ? manualTabContainers : []), + ...(selectedTab === CONST.TAB_REQUEST.SCAN ? scanTabContainers : []), + ...(selectedTab === CONST.TAB_REQUEST.DISTANCE ? distanceTabContainers : []), + ] + + console.log(focusTrapContainers); + 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 +152,7 @@ function IOURequestStartPage({ shouldEnableMinHeight={DeviceCapabilities.canUseTouchScreen()} headerGapStyles={isDraggingOver ? [styles.receiptDropHeaderGap] : []} testID={IOURequestStartPage.displayName} + focusTrapSettings={{containerElements: focusTrapContainers, focusTrapOptions: {preventScroll: true}}} > {({safeAreaPaddingBottomStyle}) => ( {iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.INVOICE ? ( ( + + )} > {() => ( )} - {() => } - {shouldDisplayDistanceRequest && {() => }} + {() => } + {shouldDisplayDistanceRequest && {() => }} ) : ( )} diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 069f86ce089..3ddcba5f9aa 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -26,6 +26,7 @@ import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; +import { RegisterFocusTrapContainerCallback } from '@hooks/useFocusTrapContainers/type'; type AmountParams = { amount: string; @@ -57,6 +58,8 @@ type IOURequestStepAmountProps = IOURequestStepAmountOnyxProps & /** Whether the user input should be kept or not */ shouldKeepUserInput?: boolean; + + registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; function IOURequestStepAmount({ @@ -72,6 +75,7 @@ function IOURequestStepAmount({ skipConfirmation, draftTransaction, shouldKeepUserInput = false, + registerFocusTrapContainer, }: IOURequestStepAmountProps) { const {translate} = useLocalize(); const textInput = useRef(null); @@ -323,6 +327,7 @@ function IOURequestStepAmount({ onCurrencyButtonPress={navigateToCurrencySelectionPage} onSubmitButtonPress={saveAmountAndCurrency} selectedTab={iouRequestType} + registerFocusTrapContainer={registerFocusTrapContainer} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 5d3b701424d..d91fd21d772 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -37,6 +37,7 @@ import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; +import { RegisterFocusTrapContainerCallback } from '@hooks/useFocusTrapContainers/type'; type IOURequestStepDistanceOnyxProps = { /** backup version of the original transaction */ @@ -57,6 +58,7 @@ type IOURequestStepDistanceProps = IOURequestStepDistanceOnyxProps & WithWritableReportOrNotFoundProps & { /** The transaction object being modified in Onyx */ transaction: OnyxEntry; + registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; function IOURequestStepDistance({ @@ -70,6 +72,7 @@ function IOURequestStepDistance({ personalDetails, currentUserPersonalDetails, skipConfirmation, + registerFocusTrapContainer, }: IOURequestStepDistanceProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -454,7 +457,12 @@ function IOURequestStepDistance({ shouldShowWrapper={!isCreatingNewRequest} > <> - + { + if(viewNode) { + const unregister = registerFocusTrapContainer?.(viewNode as unknown as HTMLElement); + return () => unregister?.(); + } + }}> (waypoints[item]?.keyForList ?? waypoints[item]?.address ?? '') + item} diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 44e4819eae4..7ea897b5016 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -55,6 +55,7 @@ function IOURequestStepScan({ personalDetails, currentUserPersonalDetails, skipConfirmation, + registerFocusTrapContainer, }: Omit) { const theme = useTheme(); const styles = useThemeStyles(); @@ -672,7 +673,12 @@ function IOURequestStepScan({ testID={IOURequestStepScan.displayName} > {(isDraggingOverWrapper) => ( - + { + if (viewNode) { + const unregister = registerFocusTrapContainer?.(viewNode as unknown as HTMLElement); + return () => unregister?.(); + } + }} style={[styles.flex1, !Browser.isMobile() && styles.uploadReceiptView(isSmallScreenWidth)]}> {!(isDraggingOver ?? isDraggingOverWrapper) && (Browser.isMobile() ? mobileCameraView() : desktopUploadView())} { diff --git a/src/pages/iou/request/step/IOURequestStepScan/types.ts b/src/pages/iou/request/step/IOURequestStepScan/types.ts index 0b130a79507..99cbe9e89e5 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/types.ts @@ -3,6 +3,7 @@ import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentU import type {WithWritableReportOrNotFoundProps} from '@pages/iou/request/step/withWritableReportOrNotFound'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import { RegisterFocusTrapContainerCallback } from '@hooks/useFocusTrapContainers/type'; type IOURequestStepOnyxProps = { user: OnyxEntry; @@ -22,6 +23,7 @@ type IOURequestStepScanProps = IOURequestStepOnyxProps & WithWritableReportOrNotFoundProps & { /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ transaction: OnyxEntry; + registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; export type {IOURequestStepOnyxProps, IOURequestStepScanProps}; From 5ae3af845443fbae87d8e23a0a9ceb9fd48ed9ce Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 23 Jul 2024 10:10:58 +0700 Subject: [PATCH 02/10] fix: lint, typescript and prettier --- .../FocusTrapForScreen/FocusTrapProps.ts | 2 +- src/components/HeaderWithBackButton/index.tsx | 17 ++-- src/components/HeaderWithBackButton/types.ts | 2 +- src/components/ScreenWrapper.tsx | 4 +- src/components/TabSelector/TabSelector.tsx | 18 ++-- src/hooks/useFocusTrapContainers/index.tsx | 8 +- .../useFocusTrapContainers/index.web.tsx | 23 +++--- src/hooks/useFocusTrapContainers/type.ts | 9 +- src/pages/iou/MoneyRequestAmountForm.tsx | 20 +++-- src/pages/iou/request/IOURequestStartPage.tsx | 82 ++++++++++--------- .../iou/request/step/IOURequestStepAmount.tsx | 2 +- .../request/step/IOURequestStepDistance.tsx | 16 ++-- .../request/step/IOURequestStepScan/index.tsx | 14 ++-- .../request/step/IOURequestStepScan/types.ts | 2 +- 14 files changed, 124 insertions(+), 95 deletions(-) diff --git a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts index ab2d3f29fa1..28006b52fbc 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts +++ b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts @@ -1,4 +1,4 @@ -import type FocusTrap from "focus-trap-react"; +import type FocusTrap from 'focus-trap-react'; type FocusTrapForScreenProps = { children: React.ReactNode; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 4e59dfbc22d..bd8429a1d28 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -1,3 +1,5 @@ +import React, {useMemo} from 'react'; +import {Keyboard, StyleSheet, View} from 'react-native'; import Avatar from '@components/Avatar'; import AvatarWithDisplayName from '@components/AvatarWithDisplayName'; import Header from '@components/Header'; @@ -13,13 +15,11 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; -import Navigation from '@libs/Navigation/Navigation'; import getButtonState from '@libs/getButtonState'; +import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import variables from '@styles/variables'; -import React, { useMemo } from 'react'; -import { Keyboard, StyleSheet, View } from 'react-native'; import type HeaderWithBackButtonProps from './types'; function HeaderWithBackButton({ @@ -60,7 +60,7 @@ function HeaderWithBackButton({ shouldNavigateToTopMostReport = false, progressBarPercentage, style, - registerFocusTrapContainer + registerFocusTrapContainer, }: HeaderWithBackButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -143,10 +143,11 @@ function HeaderWithBackButton({ style, ]} ref={(viewNode) => { - if(viewNode) { - const unregister = registerFocusTrapContainer?.(viewNode as unknown as HTMLElement); - return () => unregister?.(); + if (!viewNode) { + return; } + const unregister = registerFocusTrapContainer?.(viewNode); + return () => unregister?.(); }} > diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 1cbf9d836f1..2a57b4dbc84 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -2,6 +2,7 @@ import type {ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import type {Action} from '@hooks/useSingleExecution'; import type {StepCounterParams} from '@src/languages/types'; import type {AnchorPosition} from '@src/styles'; @@ -9,7 +10,6 @@ import type {Policy, Report} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; -import { RegisterFocusTrapContainerCallback } from '@hooks/useFocusTrapContainers/type'; type ThreeDotsMenuItem = { /** An icon element displayed on the left side */ diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 142fbdff798..acce583fc57 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -20,13 +20,13 @@ 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'; import SafeAreaConsumer from './SafeAreaConsumer'; import TestToolsModal from './TestToolsModal'; import withNavigationFallback from './withNavigationFallback'; -import FocusTrapForScreenProps from './FocusTrap/FocusTrapForScreen/FocusTrapProps'; type ScreenWrapperChildrenProps = { insets: EdgeInsets; @@ -129,7 +129,7 @@ function ScreenWrapper( shouldAvoidScrollOnVirtualViewport = true, shouldShowOfflineIndicatorInWideScreen = false, shouldUseCachedViewportHeight = false, - focusTrapSettings + focusTrapSettings, }: ScreenWrapperProps, ref: ForwardedRef, ) { diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index f3c893a20dd..65b00cd22e4 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -4,19 +4,19 @@ import type {Animated} from 'react-native'; import {View} from 'react-native'; import * as Expensicons from '@components/Icon/Expensicons'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import TabSelectorItem from './TabSelectorItem'; -import { RegisterFocusTrapContainerCallback } from '@hooks/useFocusTrapContainers/type'; type TabSelectorProps = MaterialTopTabBarProps & { /* Callback fired when tab is pressed */ onTabPress?: (name: string) => void; - registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; + registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; type IconAndTitle = { @@ -86,12 +86,16 @@ function TabSelector({state, navigation, onTabPress = () => {}, position, regist }, [defaultAffectedAnimatedTabs, state.index]); return ( - { - if(viewNode) { - const unregister = registerFocusTrapContainer?.(viewNode as unknown as HTMLElement); + { + if (!viewNode) { + return; + } + const unregister = registerFocusTrapContainer?.(viewNode); return () => unregister?.(); - } - }}> + }} + > {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); diff --git a/src/hooks/useFocusTrapContainers/index.tsx b/src/hooks/useFocusTrapContainers/index.tsx index cbe3142b7e8..af2cd506d1b 100644 --- a/src/hooks/useFocusTrapContainers/index.tsx +++ b/src/hooks/useFocusTrapContainers/index.tsx @@ -1,7 +1,5 @@ -import { UseFocusTrapContainers } from "./type" +import type {UseFocusTrapContainers} from './type'; -const useFocusTrapContainers: UseFocusTrapContainers = () => { - return [[], () => () => {}] -} +const useFocusTrapContainers: UseFocusTrapContainers = () => [[], () => () => {}]; -export default useFocusTrapContainers; \ No newline at end of file +export default useFocusTrapContainers; diff --git a/src/hooks/useFocusTrapContainers/index.web.tsx b/src/hooks/useFocusTrapContainers/index.web.tsx index c3fab72f143..2198b7ae8c4 100644 --- a/src/hooks/useFocusTrapContainers/index.web.tsx +++ b/src/hooks/useFocusTrapContainers/index.web.tsx @@ -1,17 +1,18 @@ -import { useCallback, useState } from "react"; -import { UseFocusTrapContainers } from "./type"; +import {useCallback, useState} from 'react'; +import type {FocusTrapContainerElement, UseFocusTrapContainers} from './type'; const useFocusTrapContainers: UseFocusTrapContainers = () => { - const [containers, setContainers] = useState([]); + const [containers, setContainers] = useState([]); - const addContainer = useCallback((container: HTMLElement) => { - const removeContainer = () => setContainers((prevContainers) => prevContainers.filter((c) => c !== container)); - setContainers((prevContainers) => prevContainers.includes(container) ? prevContainers : [...prevContainers, container]); + const addContainer = useCallback((container: FocusTrapContainerElement) => { + const containerAsHTMLElement = container as unknown as HTMLElement; + const removeContainer = () => setContainers((prevContainers) => prevContainers.filter((c) => c !== container)); + setContainers((prevContainers) => (prevContainers.includes(containerAsHTMLElement) ? prevContainers : [...prevContainers, containerAsHTMLElement])); - return removeContainer; - }, []) + return removeContainer; + }, []); - return [containers, addContainer] -} + return [containers, addContainer]; +}; -export default useFocusTrapContainers; \ No newline at end of file +export default useFocusTrapContainers; diff --git a/src/hooks/useFocusTrapContainers/type.ts b/src/hooks/useFocusTrapContainers/type.ts index ad823b45c6c..f5030fcfbb0 100644 --- a/src/hooks/useFocusTrapContainers/type.ts +++ b/src/hooks/useFocusTrapContainers/type.ts @@ -1,7 +1,12 @@ +import type {View} from 'react-native'; +import type ScrollView from '@components/ScrollView'; + +type FocusTrapContainerElement = HTMLElement | View | React.ElementRef; + type RemoveFocusTrapContainerFunction = () => void; -type RegisterFocusTrapContainerCallback = (container: HTMLElement) => RemoveFocusTrapContainerFunction; +type RegisterFocusTrapContainerCallback = (container: FocusTrapContainerElement) => RemoveFocusTrapContainerFunction; type UseFocusTrapContainers = () => [HTMLElement[], RegisterFocusTrapContainerCallback]; -export type {UseFocusTrapContainers, RegisterFocusTrapContainerCallback} \ No newline at end of file +export type {UseFocusTrapContainers, RegisterFocusTrapContainerCallback, FocusTrapContainerElement}; diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 834d80c9fcb..a75dc36f76c 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -11,6 +11,7 @@ import type {MoneyRequestAmountInputRef} from '@components/MoneyRequestAmountInp import ScrollView from '@components/ScrollView'; import SettlementButton from '@components/SettlementButton'; import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputFocused'; +import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -25,7 +26,6 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {SelectedTabRequest} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import { RegisterFocusTrapContainerCallback } from '@hooks/useFocusTrapContainers/type'; type CurrentMoney = {amount: string; currency: string; paymentMethod?: PaymentMethodType}; @@ -69,7 +69,7 @@ type MoneyRequestAmountFormProps = { /** Whether the user input should be kept or not */ shouldKeepUserInput?: boolean; - registerFocusTrapContainer?: RegisterFocusTrapContainerCallback + registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01; @@ -95,7 +95,7 @@ function MoneyRequestAmountForm( onSubmitButtonPress, selectedTab = CONST.TAB_REQUEST.MANUAL, shouldKeepUserInput = false, - registerFocusTrapContainer + registerFocusTrapContainer, }: MoneyRequestAmountFormProps, forwardedRef: ForwardedRef, ) { @@ -255,12 +255,16 @@ function MoneyRequestAmountForm( }, [selectedTab]); return ( - { - if(scrollViewNode) { - const unregister = registerFocusTrapContainer?.(scrollViewNode as unknown as HTMLElement); + { + if (!scrollViewNode) { + return; + } + const unregister = registerFocusTrapContainer?.(scrollViewNode); return () => unregister?.(); - } - }} contentContainerStyle={styles.flexGrow1}> + }} + contentContainerStyle={styles.flexGrow1} + > onMouseDown(event, [AMOUNT_VIEW_ID])} diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index e686d291ea3..09c09c87774 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -1,5 +1,4 @@ -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'; @@ -7,19 +6,20 @@ import DragAndDropProvider from '@components/DragAndDrop/Provider'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import type {TabSelectorProps} from '@components/TabSelector/TabSelector'; import TabSelector from '@components/TabSelector/TabSelector'; +import useFocusTrapContainers from '@hooks/useFocusTrapContainers'; 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 * 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'; @@ -29,7 +29,6 @@ import IOURequestStepAmount from './step/IOURequestStepAmount'; import IOURequestStepDistance from './step/IOURequestStepDistance'; import IOURequestStepScan from './step/IOURequestStepScan'; import type {WithWritableReportOrNotFoundProps} from './step/withWritableReportOrNotFound'; -import useFocusTrapContainers from '@hooks/useFocusTrapContainers'; type IOURequestStartPageOnyxProps = { /** The report that holds the transaction */ @@ -77,21 +76,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) { @@ -118,19 +102,32 @@ function IOURequestStartPage({ [policy, reportID, isFromGlobalCreate, transaction], ); - const [containers, addContainer] = useFocusTrapContainers(); + const [otherContainers, addOtherContainer] = useFocusTrapContainers(); const [manualTabContainers, addManualTabContainer] = useFocusTrapContainers(); const [scanTabContainers, addScanTabContainer] = useFocusTrapContainers(); const [distanceTabContainers, addDistanceTabContainer] = useFocusTrapContainers(); - const focusTrapContainers = [ - ...containers, - ...(selectedTab === CONST.TAB_REQUEST.MANUAL ? manualTabContainers : []), - ...(selectedTab === CONST.TAB_REQUEST.SCAN ? scanTabContainers : []), - ...(selectedTab === CONST.TAB_REQUEST.DISTANCE ? distanceTabContainers : []), - ] + const focusTrapContainers = useMemo( + () => + [ + ...otherContainers, + ...(selectedTab === CONST.TAB_REQUEST.MANUAL ? manualTabContainers : []), + ...(selectedTab === CONST.TAB_REQUEST.SCAN ? scanTabContainers : []), + ...(selectedTab === CONST.TAB_REQUEST.DISTANCE ? distanceTabContainers : []), + ] as HTMLElement[], + [otherContainers, manualTabContainers, scanTabContainers, distanceTabContainers, selectedTab], + ); - console.log(focusTrapContainers); + const TabSelectorWithFocusTrapInclusion = useCallback( + (props: TabSelectorProps) => ( + + ), + [addOtherContainer], + ); if (!transaction?.transactionID) { // The draft transaction is initialized only after the component is mounted, @@ -163,18 +160,13 @@ function IOURequestStartPage({ {iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.INVOICE ? ( ( - - )} + tabBar={TabSelectorWithFocusTrapInclusion} > {() => ( @@ -185,8 +177,24 @@ function IOURequestStartPage({ /> )} - {() => } - {shouldDisplayDistanceRequest && {() => }} + + {() => ( + + )} + + {shouldDisplayDistanceRequest && ( + + {() => ( + + )} + + )} ) : ( <> - { - if(viewNode) { - const unregister = registerFocusTrapContainer?.(viewNode as unknown as HTMLElement); + { + if (!viewNode) { + return; + } + const unregister = registerFocusTrapContainer?.(viewNode); return () => unregister?.(); - } - }}> + }} + > (waypoints[item]?.keyForList ?? waypoints[item]?.address ?? '') + item} diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 871a4d0d729..14ef8b84197 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -690,12 +690,16 @@ function IOURequestStepScan({ testID={IOURequestStepScan.displayName} > {(isDraggingOverWrapper) => ( - { - if (viewNode) { - const unregister = registerFocusTrapContainer?.(viewNode as unknown as HTMLElement); + { + if (!viewNode) { + return; + } + const unregister = registerFocusTrapContainer?.(viewNode); return () => unregister?.(); - } - }} style={[styles.flex1, !Browser.isMobile() && styles.uploadReceiptView(isSmallScreenWidth)]}> + }} + style={[styles.flex1, !Browser.isMobile() && styles.uploadReceiptView(isSmallScreenWidth)]} + > {!(isDraggingOver ?? isDraggingOverWrapper) && (Browser.isMobile() ? mobileCameraView() : desktopUploadView())} { diff --git a/src/pages/iou/request/step/IOURequestStepScan/types.ts b/src/pages/iou/request/step/IOURequestStepScan/types.ts index 99cbe9e89e5..e6055c13b20 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/types.ts @@ -1,9 +1,9 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import type {WithWritableReportOrNotFoundProps} from '@pages/iou/request/step/withWritableReportOrNotFound'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -import { RegisterFocusTrapContainerCallback } from '@hooks/useFocusTrapContainers/type'; type IOURequestStepOnyxProps = { user: OnyxEntry; From dc19b2c33eb0954caf2b23989050fdd54f478ec1 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 25 Jul 2024 19:55:57 +0700 Subject: [PATCH 03/10] fix: clean up implementation --- src/components/HeaderWithBackButton/index.tsx | 8 -- src/components/HeaderWithBackButton/types.ts | 3 - src/components/TabSelector/TabSelector.tsx | 16 +-- .../useFocusTrapContainerElements/index.tsx | 5 + .../index.web.tsx | 6 +- .../useFocusTrapContainerElements/type.ts | 12 ++ src/hooks/useFocusTrapContainers/index.tsx | 5 - src/hooks/useFocusTrapContainers/type.ts | 12 -- src/pages/iou/MoneyRequestAmountForm.tsx | 15 +-- src/pages/iou/request/IOURequestStartPage.tsx | 115 +++++++++++------- .../iou/request/step/IOURequestStepAmount.tsx | 5 - .../request/step/IOURequestStepDistance.tsx | 14 +-- .../request/step/IOURequestStepScan/index.tsx | 12 +- .../request/step/IOURequestStepScan/types.ts | 2 - 14 files changed, 97 insertions(+), 133 deletions(-) create mode 100644 src/hooks/useFocusTrapContainerElements/index.tsx rename src/hooks/{useFocusTrapContainers => useFocusTrapContainerElements}/index.web.tsx (75%) create mode 100644 src/hooks/useFocusTrapContainerElements/type.ts delete mode 100644 src/hooks/useFocusTrapContainers/index.tsx delete mode 100644 src/hooks/useFocusTrapContainers/type.ts diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index bd8429a1d28..2d73e3c2dd2 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -60,7 +60,6 @@ function HeaderWithBackButton({ shouldNavigateToTopMostReport = false, progressBarPercentage, style, - registerFocusTrapContainer, }: HeaderWithBackButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -142,13 +141,6 @@ function HeaderWithBackButton({ shouldOverlay && StyleSheet.absoluteFillObject, style, ]} - ref={(viewNode) => { - if (!viewNode) { - return; - } - const unregister = registerFocusTrapContainer?.(viewNode); - return () => unregister?.(); - }} > {shouldShowBackButton && ( diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 2a57b4dbc84..6b08dd74dc8 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -2,7 +2,6 @@ import type {ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {PopoverMenuItem} from '@components/PopoverMenu'; -import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import type {Action} from '@hooks/useSingleExecution'; import type {StepCounterParams} from '@src/languages/types'; import type {AnchorPosition} from '@src/styles'; @@ -131,8 +130,6 @@ type HeaderWithBackButtonProps = Partial & { /** Additional styles to add to the component */ style?: StyleProp; - - registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; export type {ThreeDotsMenuItem}; diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index 65b00cd22e4..882d99f38e3 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -4,7 +4,6 @@ import type {Animated} from 'react-native'; import {View} from 'react-native'; import * as Expensicons from '@components/Icon/Expensicons'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,8 +14,6 @@ import TabSelectorItem from './TabSelectorItem'; type TabSelectorProps = MaterialTopTabBarProps & { /* Callback fired when tab is pressed */ onTabPress?: (name: string) => void; - - registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; type IconAndTitle = { @@ -56,7 +53,7 @@ function getOpacity(position: Animated.AnimatedInterpolation, routesLeng return activeValue; } -function TabSelector({state, navigation, onTabPress = () => {}, position, registerFocusTrapContainer}: TabSelectorProps) { +function TabSelector({state, navigation, onTabPress = () => {}, position}: TabSelectorProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -86,16 +83,7 @@ function TabSelector({state, navigation, onTabPress = () => {}, position, regist }, [defaultAffectedAnimatedTabs, state.index]); return ( - { - if (!viewNode) { - return; - } - const unregister = registerFocusTrapContainer?.(viewNode); - return () => unregister?.(); - }} - > + {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); diff --git a/src/hooks/useFocusTrapContainerElements/index.tsx b/src/hooks/useFocusTrapContainerElements/index.tsx new file mode 100644 index 00000000000..165097f8d03 --- /dev/null +++ b/src/hooks/useFocusTrapContainerElements/index.tsx @@ -0,0 +1,5 @@ +import type {UseFocusTrapContainerElements} from './type'; + +const useFocusTrapContainerElements: UseFocusTrapContainerElements = () => [[], () => () => {}]; + +export default useFocusTrapContainerElements; diff --git a/src/hooks/useFocusTrapContainers/index.web.tsx b/src/hooks/useFocusTrapContainerElements/index.web.tsx similarity index 75% rename from src/hooks/useFocusTrapContainers/index.web.tsx rename to src/hooks/useFocusTrapContainerElements/index.web.tsx index 2198b7ae8c4..e3285bd70fb 100644 --- a/src/hooks/useFocusTrapContainers/index.web.tsx +++ b/src/hooks/useFocusTrapContainerElements/index.web.tsx @@ -1,7 +1,7 @@ import {useCallback, useState} from 'react'; -import type {FocusTrapContainerElement, UseFocusTrapContainers} from './type'; +import type {FocusTrapContainerElement, UseFocusTrapContainerElements} from './type'; -const useFocusTrapContainers: UseFocusTrapContainers = () => { +const useFocusTrapContainerElements: UseFocusTrapContainerElements = () => { const [containers, setContainers] = useState([]); const addContainer = useCallback((container: FocusTrapContainerElement) => { @@ -15,4 +15,4 @@ const useFocusTrapContainers: UseFocusTrapContainers = () => { return [containers, addContainer]; }; -export default useFocusTrapContainers; +export default useFocusTrapContainerElements; diff --git a/src/hooks/useFocusTrapContainerElements/type.ts b/src/hooks/useFocusTrapContainerElements/type.ts new file mode 100644 index 00000000000..b0025df0705 --- /dev/null +++ b/src/hooks/useFocusTrapContainerElements/type.ts @@ -0,0 +1,12 @@ +import type {View} from 'react-native'; +import type ScrollView from '@components/ScrollView'; + +type FocusTrapContainerElement = HTMLElement | View | React.ElementRef; + +type RemoveFocusTrapContainerElementFunc = () => void; + +type RegisterFocusTrapContainerElementCallback = (container: FocusTrapContainerElement) => RemoveFocusTrapContainerElementFunc; + +type UseFocusTrapContainerElements = () => [HTMLElement[], RegisterFocusTrapContainerElementCallback]; + +export type {UseFocusTrapContainerElements, RegisterFocusTrapContainerElementCallback, FocusTrapContainerElement}; diff --git a/src/hooks/useFocusTrapContainers/index.tsx b/src/hooks/useFocusTrapContainers/index.tsx deleted file mode 100644 index af2cd506d1b..00000000000 --- a/src/hooks/useFocusTrapContainers/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type {UseFocusTrapContainers} from './type'; - -const useFocusTrapContainers: UseFocusTrapContainers = () => [[], () => () => {}]; - -export default useFocusTrapContainers; diff --git a/src/hooks/useFocusTrapContainers/type.ts b/src/hooks/useFocusTrapContainers/type.ts deleted file mode 100644 index f5030fcfbb0..00000000000 --- a/src/hooks/useFocusTrapContainers/type.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type {View} from 'react-native'; -import type ScrollView from '@components/ScrollView'; - -type FocusTrapContainerElement = HTMLElement | View | React.ElementRef; - -type RemoveFocusTrapContainerFunction = () => void; - -type RegisterFocusTrapContainerCallback = (container: FocusTrapContainerElement) => RemoveFocusTrapContainerFunction; - -type UseFocusTrapContainers = () => [HTMLElement[], RegisterFocusTrapContainerCallback]; - -export type {UseFocusTrapContainers, RegisterFocusTrapContainerCallback, FocusTrapContainerElement}; diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index a75dc36f76c..f03b4638c83 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -11,7 +11,6 @@ import type {MoneyRequestAmountInputRef} from '@components/MoneyRequestAmountInp import ScrollView from '@components/ScrollView'; import SettlementButton from '@components/SettlementButton'; import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputFocused'; -import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -68,8 +67,6 @@ type MoneyRequestAmountFormProps = { /** Whether the user input should be kept or not */ shouldKeepUserInput?: boolean; - - registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01; @@ -95,7 +92,6 @@ function MoneyRequestAmountForm( onSubmitButtonPress, selectedTab = CONST.TAB_REQUEST.MANUAL, shouldKeepUserInput = false, - registerFocusTrapContainer, }: MoneyRequestAmountFormProps, forwardedRef: ForwardedRef, ) { @@ -255,16 +251,7 @@ function MoneyRequestAmountForm( }, [selectedTab]); return ( - { - if (!scrollViewNode) { - return; - } - const unregister = registerFocusTrapContainer?.(scrollViewNode); - return () => unregister?.(); - }} - contentContainerStyle={styles.flexGrow1} - > + onMouseDown(event, [AMOUNT_VIEW_ID])} diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index 09c09c87774..745f8d2fcfb 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -8,7 +8,8 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import type {TabSelectorProps} from '@components/TabSelector/TabSelector'; import TabSelector from '@components/TabSelector/TabSelector'; -import useFocusTrapContainers from '@hooks/useFocusTrapContainers'; +import useFocusTrapContainerElements from '@hooks/useFocusTrapContainerElements'; +import type {FocusTrapContainerElement, RegisterFocusTrapContainerElementCallback} from '@hooks/useFocusTrapContainerElements/type'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -49,6 +50,16 @@ type IOURequestStartPageOnyxProps = { type IOURequestStartPageProps = IOURequestStartPageOnyxProps & WithWritableReportOrNotFoundProps; +const refCallbackWithFocusTrap = (registerCb: RegisterFocusTrapContainerElementCallback) => { + return (view: FocusTrapContainerElement | null | undefined) => { + if (!view) { + return; + } + const deregister = registerCb(view); + return deregister; + }; +}; + function IOURequestStartPage({ report, policy, @@ -102,31 +113,38 @@ function IOURequestStartPage({ [policy, reportID, isFromGlobalCreate, transaction], ); - const [otherContainers, addOtherContainer] = useFocusTrapContainers(); - const [manualTabContainers, addManualTabContainer] = useFocusTrapContainers(); - const [scanTabContainers, addScanTabContainer] = useFocusTrapContainers(); - const [distanceTabContainers, addDistanceTabContainer] = useFocusTrapContainers(); - - const focusTrapContainers = useMemo( - () => - [ - ...otherContainers, - ...(selectedTab === CONST.TAB_REQUEST.MANUAL ? manualTabContainers : []), - ...(selectedTab === CONST.TAB_REQUEST.SCAN ? scanTabContainers : []), - ...(selectedTab === CONST.TAB_REQUEST.DISTANCE ? distanceTabContainers : []), - ] as HTMLElement[], - [otherContainers, manualTabContainers, scanTabContainers, distanceTabContainers, selectedTab], - ); + const [containerElements, registerContainerElement] = useFocusTrapContainerElements(); + const [manualTabContainerElements, registerManualTabContainerElement] = useFocusTrapContainerElements(); + const [scanTabContainerElements, registerScanTabContainerElement] = useFocusTrapContainerElements(); + const [distanceTabContainerElements, registerDistanceTabContainerElement] = useFocusTrapContainerElements(); + + const focusTrapContainers = useMemo(() => { + const result: HTMLElement[] = [...containerElements]; + switch (selectedTab) { + case CONST.TAB_REQUEST.MANUAL: + return result.concat(manualTabContainerElements); + case CONST.TAB_REQUEST.SCAN: + return result.concat(scanTabContainerElements); + case CONST.TAB_REQUEST.DISTANCE: + return result.concat(distanceTabContainerElements); + default: + return result; + } + }, [selectedTab, containerElements, manualTabContainerElements, scanTabContainerElements, distanceTabContainerElements]); const TabSelectorWithFocusTrapInclusion = useCallback( - (props: TabSelectorProps) => ( - - ), - [addOtherContainer], + (props: TabSelectorProps) => { + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); + }, + [styles.w100, registerContainerElement], ); if (!transaction?.transactionID) { @@ -157,11 +175,15 @@ function IOURequestStartPage({ isDisabled={selectedTab !== CONST.TAB_REQUEST.SCAN} > - + + + {iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.INVOICE ? ( {() => ( - + + + )} {() => ( - + + + )} {shouldDisplayDistanceRequest && ( {() => ( - + + + )} )} @@ -200,7 +230,6 @@ function IOURequestStartPage({ )} diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index c534566e60c..069f86ce089 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -5,7 +5,6 @@ import {useOnyx, withOnyx} from 'react-native-onyx'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import useLocalize from '@hooks/useLocalize'; import * as TransactionEdit from '@libs/actions/TransactionEdit'; import * as CurrencyUtils from '@libs/CurrencyUtils'; @@ -58,8 +57,6 @@ type IOURequestStepAmountProps = IOURequestStepAmountOnyxProps & /** Whether the user input should be kept or not */ shouldKeepUserInput?: boolean; - - registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; function IOURequestStepAmount({ @@ -75,7 +72,6 @@ function IOURequestStepAmount({ skipConfirmation, draftTransaction, shouldKeepUserInput = false, - registerFocusTrapContainer, }: IOURequestStepAmountProps) { const {translate} = useLocalize(); const textInput = useRef(null); @@ -327,7 +323,6 @@ function IOURequestStepAmount({ onCurrencyButtonPress={navigateToCurrencySelectionPage} onSubmitButtonPress={saveAmountAndCurrency} selectedTab={iouRequestType} - registerFocusTrapContainer={registerFocusTrapContainer} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index ba56b2f74bc..66f7ec28fa0 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -13,7 +13,6 @@ import DotIndicatorMessage from '@components/DotIndicatorMessage'; import DraggableList from '@components/DraggableList'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; @@ -58,7 +57,6 @@ type IOURequestStepDistanceProps = IOURequestStepDistanceOnyxProps & WithWritableReportOrNotFoundProps & { /** The transaction object being modified in Onyx */ transaction: OnyxEntry; - registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; function IOURequestStepDistance({ @@ -72,7 +70,6 @@ function IOURequestStepDistance({ personalDetails, currentUserPersonalDetails, skipConfirmation, - registerFocusTrapContainer, }: IOURequestStepDistanceProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -469,16 +466,7 @@ function IOURequestStepDistance({ shouldShowWrapper={!isCreatingNewRequest} > <> - { - if (!viewNode) { - return; - } - const unregister = registerFocusTrapContainer?.(viewNode); - return () => unregister?.(); - }} - > + (waypoints[item]?.keyForList ?? waypoints[item]?.address ?? '') + item} diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 14ef8b84197..61e04b4483e 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -56,7 +56,6 @@ function IOURequestStepScan({ personalDetails, currentUserPersonalDetails, skipConfirmation, - registerFocusTrapContainer, }: Omit) { const theme = useTheme(); const styles = useThemeStyles(); @@ -690,16 +689,7 @@ function IOURequestStepScan({ testID={IOURequestStepScan.displayName} > {(isDraggingOverWrapper) => ( - { - if (!viewNode) { - return; - } - const unregister = registerFocusTrapContainer?.(viewNode); - return () => unregister?.(); - }} - style={[styles.flex1, !Browser.isMobile() && styles.uploadReceiptView(isSmallScreenWidth)]} - > + {!(isDraggingOver ?? isDraggingOverWrapper) && (Browser.isMobile() ? mobileCameraView() : desktopUploadView())} { diff --git a/src/pages/iou/request/step/IOURequestStepScan/types.ts b/src/pages/iou/request/step/IOURequestStepScan/types.ts index e6055c13b20..0b130a79507 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/types.ts @@ -1,6 +1,5 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import type {RegisterFocusTrapContainerCallback} from '@hooks/useFocusTrapContainers/type'; import type {WithWritableReportOrNotFoundProps} from '@pages/iou/request/step/withWritableReportOrNotFound'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -23,7 +22,6 @@ type IOURequestStepScanProps = IOURequestStepOnyxProps & WithWritableReportOrNotFoundProps & { /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ transaction: OnyxEntry; - registerFocusTrapContainer?: RegisterFocusTrapContainerCallback; }; export type {IOURequestStepOnyxProps, IOURequestStepScanProps}; From f2825faad6f3aaa673993cb812c5673e7835e0ae Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 25 Jul 2024 19:59:10 +0700 Subject: [PATCH 04/10] fix: add some comment for new focus trap hook --- src/hooks/useFocusTrapContainerElements/index.web.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/hooks/useFocusTrapContainerElements/index.web.tsx b/src/hooks/useFocusTrapContainerElements/index.web.tsx index e3285bd70fb..8a1ff95ec62 100644 --- a/src/hooks/useFocusTrapContainerElements/index.web.tsx +++ b/src/hooks/useFocusTrapContainerElements/index.web.tsx @@ -1,3 +1,7 @@ +/** + * This is a common hook to register the container elements of the focus trap + * Only available on web/desktop platform + */ import {useCallback, useState} from 'react'; import type {FocusTrapContainerElement, UseFocusTrapContainerElements} from './type'; From cfeed3bb12b833900d798137937098c2eb381275 Mon Sep 17 00:00:00 2001 From: dominictb Date: Wed, 31 Jul 2024 14:38:42 +0700 Subject: [PATCH 05/10] chore: enhance focus trap for onxy tab component --- .../FocusTrapContainerElementProps.ts | 7 ++ .../FocusTrapContainerElement/index.tsx | 9 ++ .../FocusTrapContainerElement/index.web.tsx | 30 ++++++ .../FocusTrapForScreen/FocusTrapProps.ts | 2 +- .../FocusTrapForScreen/index.web.tsx | 5 +- src/libs/Navigation/OnyxTabNavigator.tsx | 98 ++++++++++++++----- src/pages/NewChatPage.tsx | 1 + src/pages/NewChatSelectorPage.tsx | 74 +++++++++++--- src/pages/iou/request/IOURequestStartPage.tsx | 94 +++++++----------- src/pages/workspace/WorkspaceNewRoomPage.tsx | 1 + 10 files changed, 223 insertions(+), 98 deletions(-) create mode 100644 src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts create mode 100644 src/components/FocusTrap/FocusTrapContainerElement/index.tsx create mode 100644 src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx diff --git a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts new file mode 100644 index 00000000000..e2e3645a3d8 --- /dev/null +++ b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts @@ -0,0 +1,7 @@ +import type {ViewProps} from 'react-native-svg/lib/typescript/fabric/utils'; + +type FocusTrapContainerElementProps = ViewProps & { + 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 00000000000..39da870cbf3 --- /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 00000000000..b19598970d7 --- /dev/null +++ b/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx @@ -0,0 +1,30 @@ +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) { + // eslint-disable + r.current = node; + } + if (node) { + onContainerElementChanged?.(node as unknown as HTMLElement); + return () => onContainerElementChanged?.(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 28006b52fbc..2c9b23a3001 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts +++ b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts @@ -2,7 +2,7 @@ import type FocusTrap from 'focus-trap-react'; type FocusTrapForScreenProps = { children: React.ReactNode; - focusTrapSettings?: Pick; + focusTrapSettings?: Pick; }; export default FocusTrapForScreenProps; diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index 52edb692655..c2a45d39f3f 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -16,6 +16,9 @@ function FocusTrapForScreen({children, focusTrapSettings}: FocusTrapProps) { 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,7 +34,7 @@ function FocusTrapForScreen({children, focusTrapSettings}: FocusTrapProps) { return false; } return true; - }, [isFocused, isSmallScreenWidth, route.name]); + }, [isFocused, isSmallScreenWidth, route.name, focusTrapSettings?.active]); return ( React.ReactNode; screenListeners?: ScreenListeners; + + onTabFocusTrapContainerElementChanged?: (containerElement?: HTMLElement | null) => void; }; // eslint-disable-next-line rulesdir/no-inline-named-export -export const TopTab = createMaterialTopTabNavigator(); +const TopTab = createMaterialTopTabNavigator(); + +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) { +function OnyxTabNavigator({id, selectedTab, children, onTabFocusTrapContainerElementChanged, onTabSelected = () => {}, screenListeners, ...rest}: OnyxTabNavigatorProps) { + const [focusTrapContainerElementMapping, setFocusTrapContainerElementMapping] = useState>({}); + + const setTabFocusTrapContainerElement = useCallback((tabName: string, containerElement: HTMLElement | null) => { + setFocusTrapContainerElementMapping((prevMapping) => { + const resultMapping = {...prevMapping}; + if (containerElement) { + resultMapping[tabName] = containerElement; + } else { + delete resultMapping[tabName]; + } + return resultMapping; + }); + }, []); + + useEffect(() => { + onTabFocusTrapContainerElementChanged?.(selectedTab ? focusTrapContainerElementMapping[selectedTab] : null); + }, [selectedTab, focusTrapContainerElementMapping, onTabFocusTrapContainerElementChanged]); + 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} + + { + 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} + + + ); +} + +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 ( + {children} - + ); } @@ -71,3 +119,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 17baddd3925..763ad3c7483 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -319,6 +319,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) { includePaddingTop={false} shouldEnablePickerAvoiding={false} testID={NewChatPage.displayName} + focusTrapSettings={{active: false}} > (null); + const [tabBarContainerElement, setTabBarContainerElement] = useState(null); + const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); + + const containerElements = useMemo(() => { + return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); + }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); + + const TabBarWithFocusTrapInclusion = useCallback( + (props: TabSelectorProps) => { + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); + }, + [styles], + ); + + 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 745f8d2fcfb..f4e3da44735 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -3,19 +3,18 @@ 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'; import type {TabSelectorProps} from '@components/TabSelector/TabSelector'; import TabSelector from '@components/TabSelector/TabSelector'; -import useFocusTrapContainerElements from '@hooks/useFocusTrapContainerElements'; -import type {FocusTrapContainerElement, RegisterFocusTrapContainerElementCallback} from '@hooks/useFocusTrapContainerElements/type'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; 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'; @@ -50,16 +49,6 @@ type IOURequestStartPageOnyxProps = { type IOURequestStartPageProps = IOURequestStartPageOnyxProps & WithWritableReportOrNotFoundProps; -const refCallbackWithFocusTrap = (registerCb: RegisterFocusTrapContainerElementCallback) => { - return (view: FocusTrapContainerElement | null | undefined) => { - if (!view) { - return; - } - const deregister = registerCb(view); - return deregister; - }; -}; - function IOURequestStartPage({ report, policy, @@ -113,38 +102,31 @@ function IOURequestStartPage({ [policy, reportID, isFromGlobalCreate, transaction], ); - const [containerElements, registerContainerElement] = useFocusTrapContainerElements(); - const [manualTabContainerElements, registerManualTabContainerElement] = useFocusTrapContainerElements(); - const [scanTabContainerElements, registerScanTabContainerElement] = useFocusTrapContainerElements(); - const [distanceTabContainerElements, registerDistanceTabContainerElement] = useFocusTrapContainerElements(); + const [headerWithBackBtnContainerElement, setHeaderWithBackButtonContainerElement] = useState(null); + const [tabBarContainerElement, setTabBarContainerElement] = useState(null); + const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); - const focusTrapContainers = useMemo(() => { - const result: HTMLElement[] = [...containerElements]; - switch (selectedTab) { - case CONST.TAB_REQUEST.MANUAL: - return result.concat(manualTabContainerElements); - case CONST.TAB_REQUEST.SCAN: - return result.concat(scanTabContainerElements); - case CONST.TAB_REQUEST.DISTANCE: - return result.concat(distanceTabContainerElements); - default: - return result; - } - }, [selectedTab, containerElements, manualTabContainerElements, scanTabContainerElements, distanceTabContainerElements]); + const onTabFocusTrapContainerElementChanged = useCallback((activeTabElement?: HTMLElement | null) => { + setActiveTabContainerElement(activeTabElement ?? null); + }, []); + + const focusTrapContainerElements = useMemo(() => { + return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); + }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); const TabSelectorWithFocusTrapInclusion = useCallback( (props: TabSelectorProps) => { return ( - {/* eslint-disable-next-line react/jsx-props-no-spreading */} - + ); }, - [styles.w100, registerContainerElement], + [styles], ); if (!transaction?.transactionID) { @@ -167,7 +149,7 @@ function IOURequestStartPage({ shouldEnableMinHeight={DeviceCapabilities.canUseTouchScreen()} headerGapStyles={isDraggingOver ? [styles.receiptDropHeaderGap] : []} testID={IOURequestStartPage.displayName} - focusTrapSettings={{containerElements: focusTrapContainers, focusTrapOptions: {preventScroll: true}}} + focusTrapSettings={{containerElements: focusTrapContainerElements, focusTrapOptions: {preventScroll: true}}} > {({safeAreaPaddingBottomStyle}) => ( - - + + {iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.INVOICE ? ( {() => ( - + - + )} {() => ( - + - + )} {shouldDisplayDistanceRequest && ( {() => ( - + - + )} )} ) : ( - + + + )} diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx index 3f53f9e1cf6..be1e21a2f10 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.tsx +++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx @@ -253,6 +253,7 @@ function WorkspaceNewRoomPage({policies, reports, formState, session, activePoli includePaddingTop={false} shouldEnablePickerAvoiding={false} testID={WorkspaceNewRoomPage.displayName} + focusTrapSettings={{active: false}} > {({insets}) => workspaceOptions.length === 0 ? ( From eaa1fe23b8b5ddf632df40d19b1e9d0872d4da21 Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 5 Aug 2024 10:50:54 +0700 Subject: [PATCH 06/10] fix: typescript issue --- src/pages/NewChatSelectorPage.tsx | 2 +- src/pages/iou/request/IOURequestStartPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/NewChatSelectorPage.tsx b/src/pages/NewChatSelectorPage.tsx index 73f7f19ba2b..47bcf61712b 100755 --- a/src/pages/NewChatSelectorPage.tsx +++ b/src/pages/NewChatSelectorPage.tsx @@ -22,7 +22,7 @@ function NewChatSelectorPage() { const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); const containerElements = useMemo(() => { - return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); + return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element) as HTMLElement[]; }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); const TabBarWithFocusTrapInclusion = useCallback( diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index f4e3da44735..a495ad16370 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -111,7 +111,7 @@ function IOURequestStartPage({ }, []); const focusTrapContainerElements = useMemo(() => { - return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); + return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element) as HTMLElement[]; }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); const TabSelectorWithFocusTrapInclusion = useCallback( From d19ddbd5cb3901a68e9a8fbce3dc70528b25cf9b Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 6 Aug 2024 09:36:52 +0700 Subject: [PATCH 07/10] chore: clean up implementation --- .../FocusTrapContainerElementProps.ts | 2 +- .../FocusTrap/FocusTrapContainerElement/index.web.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts index e2e3645a3d8..13b1f9b6222 100644 --- a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts +++ b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts @@ -1,4 +1,4 @@ -import type {ViewProps} from 'react-native-svg/lib/typescript/fabric/utils'; +import type {ViewProps} from 'react-native'; type FocusTrapContainerElementProps = ViewProps & { onContainerElementChanged?: (element: HTMLElement | null) => void; diff --git a/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx b/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx index b19598970d7..58e02d8e84b 100644 --- a/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx @@ -1,3 +1,6 @@ +/** + * 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'; @@ -11,7 +14,6 @@ function FocusTrapContainerElement({onContainerElementChanged, ...props}: FocusT if (typeof r === 'function') { r(node); } else if (r) { - // eslint-disable r.current = node; } if (node) { From 48fd683e04633dbc5283e12060fa45186b088910 Mon Sep 17 00:00:00 2001 From: dominictb Date: Wed, 7 Aug 2024 15:54:22 +0700 Subject: [PATCH 08/10] fix: add button role to tab selector item --- src/components/TabSelector/TabSelectorItem.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index d5507c8c83d..635b9deddcf 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}) => ( From fa5115a257fd9430192a09701c6d392c43bf50c9 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 8 Aug 2024 10:00:40 +0700 Subject: [PATCH 09/10] chore: clean up implementation move tab bar with focus trap inclusion inside onyx tab navigator --- .../FocusTrapContainerElementProps.ts | 1 + .../FocusTrapForScreen/FocusTrapProps.ts | 2 + src/components/ScreenWrapper.tsx | 1 + src/components/TabSelector/TabSelector.tsx | 94 ++++++++++--------- .../useFocusTrapContainerElements/index.tsx | 5 - .../index.web.tsx | 22 ----- .../useFocusTrapContainerElements/type.ts | 12 --- src/libs/Navigation/OnyxTabNavigator.tsx | 36 ++++++- src/pages/NewChatSelectorPage.tsx | 21 +---- src/pages/iou/request/IOURequestStartPage.tsx | 25 +---- 10 files changed, 92 insertions(+), 127 deletions(-) delete mode 100644 src/hooks/useFocusTrapContainerElements/index.tsx delete mode 100644 src/hooks/useFocusTrapContainerElements/index.web.tsx delete mode 100644 src/hooks/useFocusTrapContainerElements/type.ts diff --git a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts index 13b1f9b6222..004e8bd6dbc 100644 --- a/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts +++ b/src/components/FocusTrap/FocusTrapContainerElement/FocusTrapContainerElementProps.ts @@ -1,6 +1,7 @@ import type {ViewProps} from 'react-native'; type FocusTrapContainerElementProps = ViewProps & { + /** Callback to register focus trap container element */ onContainerElementChanged?: (element: HTMLElement | null) => void; }; diff --git a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts index 2c9b23a3001..aa063a10271 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts +++ b/src/components/FocusTrap/FocusTrapForScreen/FocusTrapProps.ts @@ -2,6 +2,8 @@ import type FocusTrap from 'focus-trap-react'; type FocusTrapForScreenProps = { children: React.ReactNode; + + /** Overrides the focus trap settings */ focusTrapSettings?: Pick; }; diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index acce583fc57..866c648d934 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -101,6 +101,7 @@ type ScreenWrapperProps = { /** Whether to show offline indicator on wide screens */ shouldShowOfflineIndicatorInWideScreen?: boolean; + /** Overrides the focus trap default settings */ focusTrapSettings?: FocusTrapForScreenProps['focusTrapSettings']; }; diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index 882d99f38e3..7fca66b5f8c 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -2,6 +2,7 @@ import type {MaterialTopTabBarProps} from '@react-navigation/material-top-tabs/l import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {Animated} from 'react-native'; import {View} from 'react-native'; +import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import * as Expensicons from '@components/Icon/Expensicons'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import useLocalize from '@hooks/useLocalize'; @@ -14,6 +15,9 @@ import TabSelectorItem from './TabSelectorItem'; type TabSelectorProps = MaterialTopTabBarProps & { /* Callback fired when tab is pressed */ onTabPress?: (name: string) => 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/hooks/useFocusTrapContainerElements/index.tsx b/src/hooks/useFocusTrapContainerElements/index.tsx deleted file mode 100644 index 165097f8d03..00000000000 --- a/src/hooks/useFocusTrapContainerElements/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type {UseFocusTrapContainerElements} from './type'; - -const useFocusTrapContainerElements: UseFocusTrapContainerElements = () => [[], () => () => {}]; - -export default useFocusTrapContainerElements; diff --git a/src/hooks/useFocusTrapContainerElements/index.web.tsx b/src/hooks/useFocusTrapContainerElements/index.web.tsx deleted file mode 100644 index 8a1ff95ec62..00000000000 --- a/src/hooks/useFocusTrapContainerElements/index.web.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/** - * This is a common hook to register the container elements of the focus trap - * Only available on web/desktop platform - */ -import {useCallback, useState} from 'react'; -import type {FocusTrapContainerElement, UseFocusTrapContainerElements} from './type'; - -const useFocusTrapContainerElements: UseFocusTrapContainerElements = () => { - const [containers, setContainers] = useState([]); - - const addContainer = useCallback((container: FocusTrapContainerElement) => { - const containerAsHTMLElement = container as unknown as HTMLElement; - const removeContainer = () => setContainers((prevContainers) => prevContainers.filter((c) => c !== container)); - setContainers((prevContainers) => (prevContainers.includes(containerAsHTMLElement) ? prevContainers : [...prevContainers, containerAsHTMLElement])); - - return removeContainer; - }, []); - - return [containers, addContainer]; -}; - -export default useFocusTrapContainerElements; diff --git a/src/hooks/useFocusTrapContainerElements/type.ts b/src/hooks/useFocusTrapContainerElements/type.ts deleted file mode 100644 index b0025df0705..00000000000 --- a/src/hooks/useFocusTrapContainerElements/type.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type {View} from 'react-native'; -import type ScrollView from '@components/ScrollView'; - -type FocusTrapContainerElement = HTMLElement | View | React.ElementRef; - -type RemoveFocusTrapContainerElementFunc = () => void; - -type RegisterFocusTrapContainerElementCallback = (container: FocusTrapContainerElement) => RemoveFocusTrapContainerElementFunc; - -type UseFocusTrapContainerElements = () => [HTMLElement[], RegisterFocusTrapContainerElementCallback]; - -export type {UseFocusTrapContainerElements, RegisterFocusTrapContainerElementCallback, FocusTrapContainerElement}; diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index c5c9ae8e4cb..b166fb8da75 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -34,7 +34,11 @@ type OnyxTabNavigatorProps = OnyxTabNavigatorOnyxProps & screenListeners?: ScreenListeners; - onTabFocusTrapContainerElementChanged?: (containerElement?: HTMLElement | null) => void; + /** Callback to register the focus trap container elements of the current active tab */ + onActiveTabFocusTrapContainerElementChanged?: (containerElement: HTMLElement | null) => void; + + /** Callback to register the focus trap container elements of the tab bar */ + onTabBarFocusTrapContainerElementChanged?: (containerElement: HTMLElement | null) => void; }; // eslint-disable-next-line rulesdir/no-inline-named-export @@ -44,7 +48,17 @@ const TabFocusTrapContext = React.createContext<(tabName: string, containerEleme // 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, onTabFocusTrapContainerElementChanged, onTabSelected = () => {}, screenListeners, ...rest}: OnyxTabNavigatorProps) { +function OnyxTabNavigator({ + id, + selectedTab, + tabBar: TabBar, + children, + onTabBarFocusTrapContainerElementChanged, + onActiveTabFocusTrapContainerElementChanged, + onTabSelected = () => {}, + screenListeners, + ...rest +}: OnyxTabNavigatorProps) { const [focusTrapContainerElementMapping, setFocusTrapContainerElementMapping] = useState>({}); const setTabFocusTrapContainerElement = useCallback((tabName: string, containerElement: HTMLElement | null) => { @@ -59,9 +73,22 @@ function OnyxTabNavigator({id, selectedTab, children, onTabFocusTrapContainerEle }); }, []); + const TabBarWithFocusTrapInclusion = useCallback( + (props: TabSelectorProps) => { + return ( + + ); + }, + [onTabBarFocusTrapContainerElementChanged, TabBar], + ); + useEffect(() => { - onTabFocusTrapContainerElementChanged?.(selectedTab ? focusTrapContainerElementMapping[selectedTab] : null); - }, [selectedTab, focusTrapContainerElementMapping, onTabFocusTrapContainerElementChanged]); + onActiveTabFocusTrapContainerElementChanged?.(selectedTab ? focusTrapContainerElementMapping[selectedTab] : null); + }, [selectedTab, focusTrapContainerElementMapping, onActiveTabFocusTrapContainerElementChanged]); return ( @@ -72,6 +99,7 @@ function OnyxTabNavigator({id, selectedTab, children, onTabFocusTrapContainerEle initialRouteName={selectedTab} backBehavior="initialRoute" keyboardDismissMode="none" + tabBar={TabBarWithFocusTrapInclusion} screenListeners={{ state: (e) => { const event = e as unknown as EventMapCore['state']; diff --git a/src/pages/NewChatSelectorPage.tsx b/src/pages/NewChatSelectorPage.tsx index 47bcf61712b..7de25074444 100755 --- a/src/pages/NewChatSelectorPage.tsx +++ b/src/pages/NewChatSelectorPage.tsx @@ -4,7 +4,6 @@ import {View} from 'react-native'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import type {TabSelectorProps} from '@components/TabSelector/TabSelector'; import TabSelector from '@components/TabSelector/TabSelector'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -25,21 +24,6 @@ function NewChatSelectorPage() { return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element) as HTMLElement[]; }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); - const TabBarWithFocusTrapInclusion = useCallback( - (props: TabSelectorProps) => { - return ( - - {/* eslint-disable-next-line react/jsx-props-no-spreading */} - - - ); - }, - [styles], - ); - const onTabFocusTrapContainerElementChanged = useCallback((activeTabElement?: HTMLElement | null) => { setActiveTabContainerElement(activeTabElement ?? null); }, []); @@ -64,8 +48,9 @@ function NewChatSelectorPage() { {() => ( diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index a495ad16370..e20ac5ac7ce 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -7,7 +7,6 @@ import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerE import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import type {TabSelectorProps} from '@components/TabSelector/TabSelector'; import TabSelector from '@components/TabSelector/TabSelector'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; @@ -106,29 +105,10 @@ function IOURequestStartPage({ const [tabBarContainerElement, setTabBarContainerElement] = useState(null); const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); - const onTabFocusTrapContainerElementChanged = useCallback((activeTabElement?: HTMLElement | null) => { - setActiveTabContainerElement(activeTabElement ?? null); - }, []); - const focusTrapContainerElements = useMemo(() => { return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element) as HTMLElement[]; }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); - const TabSelectorWithFocusTrapInclusion = useCallback( - (props: TabSelectorProps) => { - return ( - - {/* eslint-disable-next-line react/jsx-props-no-spreading */} - - - ); - }, - [styles], - ); - 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. @@ -171,8 +151,9 @@ function IOURequestStartPage({ {() => ( From 61718f84908820bea5aafb90199ae13ce84e98bd Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 12 Aug 2024 17:03:05 +0700 Subject: [PATCH 10/10] chore: update code comments --- .../FocusTrapContainerElement/index.web.tsx | 5 +-- src/libs/Navigation/OnyxTabNavigator.tsx | 38 ++++++++++++++++++- src/pages/NewChatPage.tsx | 1 + src/pages/NewChatSelectorPage.tsx | 18 +++++---- src/pages/iou/request/IOURequestStartPage.tsx | 2 +- src/pages/workspace/WorkspaceNewRoomPage.tsx | 1 + 6 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx b/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx index 58e02d8e84b..15307ee2360 100644 --- a/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx @@ -16,10 +16,7 @@ function FocusTrapContainerElement({onContainerElementChanged, ...props}: FocusT } else if (r) { r.current = node; } - if (node) { - onContainerElementChanged?.(node as unknown as HTMLElement); - return () => onContainerElementChanged?.(null); - } + onContainerElementChanged?.(node as unknown as HTMLElement | null); }} // eslint-disable-next-line react/jsx-props-no-spreading {...props} diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index b166fb8da75..a1d9d1786dd 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -34,20 +34,30 @@ type OnyxTabNavigatorProps = OnyxTabNavigatorOnyxProps & screenListeners?: ScreenListeners; - /** Callback to register the focus trap container elements of the current active tab */ + /** 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 */ + /** 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 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 +// 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, @@ -59,8 +69,10 @@ function OnyxTabNavigator({ 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}; @@ -73,6 +85,10 @@ function OnyxTabNavigator({ }); }, []); + /** + * 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 ( @@ -86,6 +102,7 @@ function OnyxTabNavigator({ [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]); @@ -119,6 +136,23 @@ function OnyxTabNavigator({ ); } +/** + * 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(); diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 763ad3c7483..19accfbe5b2 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -319,6 +319,7 @@ 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]); @@ -37,13 +38,14 @@ function NewChatSelectorPage() { testID={NewChatSelectorPage.displayName} focusTrapSettings={{containerElements}} > - - - - + + {({safeAreaPaddingBottomStyle}) => ( {({insets}) =>