Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix: enable tab on IOU request start page #45982

Merged
merged 12 commits into from
Aug 12, 2024
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type FocusTrap from 'focus-trap-react';

type FocusTrapForScreenProps = {
children: React.ReactNode;
focusTrapSettings?: Pick<FocusTrap.Props, 'containerElements' | 'focusTrapOptions'>;
};

export default FocusTrapForScreenProps;
4 changes: 3 additions & 1 deletion src/components/FocusTrap/FocusTrapForScreen/index.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -36,6 +36,7 @@ function FocusTrapForScreen({children}: FocusTrapProps) {
<FocusTrap
active={isActive}
paused={!isFocused}
containerElements={focusTrapSettings?.containerElements?.length ? focusTrapSettings.containerElements : undefined}
focusTrapOptions={{
trapStack: sharedTrapStack,
allowOutsideClick: true,
Expand All @@ -54,6 +55,7 @@ function FocusTrapForScreen({children}: FocusTrapProps) {
}
return element;
},
...(focusTrapSettings?.focusTrapOptions ?? {}),
}}
>
{children}
Expand Down
8 changes: 8 additions & 0 deletions src/components/HeaderWithBackButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function HeaderWithBackButton({
shouldNavigateToTopMostReport = false,
progressBarPercentage,
style,
registerFocusTrapContainer,
}: HeaderWithBackButtonProps) {
const theme = useTheme();
const styles = useThemeStyles();
Expand Down Expand Up @@ -141,6 +142,13 @@ function HeaderWithBackButton({
shouldOverlay && StyleSheet.absoluteFillObject,
style,
]}
ref={(viewNode) => {
if (!viewNode) {
return;
}
const unregister = registerFocusTrapContainer?.(viewNode);
return () => unregister?.();
}}
>
<View style={[styles.dFlex, styles.flexRow, styles.alignItemsCenter, styles.flexGrow1, styles.justifyContentBetween, styles.overflowHidden]}>
{shouldShowBackButton && (
Expand Down
3 changes: 3 additions & 0 deletions src/components/HeaderWithBackButton/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -130,6 +131,8 @@ type HeaderWithBackButtonProps = Partial<ChildrenProps> & {

/** Additional styles to add to the component */
style?: StyleProp<ViewStyle>;

registerFocusTrapContainer?: RegisterFocusTrapContainerCallback;
dominictb marked this conversation as resolved.
Show resolved Hide resolved
};

export type {ThreeDotsMenuItem};
Expand Down
6 changes: 5 additions & 1 deletion src/components/ScreenWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import toggleTestToolsModal from '@userActions/TestTool';
import CONST from '@src/CONST';
import CustomDevMenu from './CustomDevMenu';
import FocusTrapForScreens from './FocusTrap/FocusTrapForScreen';
import type FocusTrapForScreenProps from './FocusTrap/FocusTrapForScreen/FocusTrapProps';
import HeaderGap from './HeaderGap';
import KeyboardAvoidingView from './KeyboardAvoidingView';
import OfflineIndicator from './OfflineIndicator';
Expand Down Expand Up @@ -99,6 +100,8 @@ type ScreenWrapperProps = {

/** Whether to show offline indicator on wide screens */
shouldShowOfflineIndicatorInWideScreen?: boolean;

focusTrapSettings?: FocusTrapForScreenProps['focusTrapSettings'];
dominictb marked this conversation as resolved.
Show resolved Hide resolved
};

type ScreenWrapperStatusContextType = {didScreenTransitionEnd: boolean};
Expand Down Expand Up @@ -126,6 +129,7 @@ function ScreenWrapper(
shouldAvoidScrollOnVirtualViewport = true,
shouldShowOfflineIndicatorInWideScreen = false,
shouldUseCachedViewportHeight = false,
focusTrapSettings,
}: ScreenWrapperProps,
ref: ForwardedRef<View>,
) {
Expand Down Expand Up @@ -242,7 +246,7 @@ function ScreenWrapper(
}

return (
<FocusTrapForScreens>
<FocusTrapForScreens focusTrapSettings={focusTrapSettings}>
<View
ref={ref}
style={[styles.flex1, {minHeight}]}
Expand Down
16 changes: 14 additions & 2 deletions src/components/TabSelector/TabSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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';
Expand All @@ -14,6 +15,8 @@ import TabSelectorItem from './TabSelectorItem';
type TabSelectorProps = MaterialTopTabBarProps & {
/* Callback fired when tab is pressed */
onTabPress?: (name: string) => void;

registerFocusTrapContainer?: RegisterFocusTrapContainerCallback;
};

type IconAndTitle = {
Expand Down Expand Up @@ -53,7 +56,7 @@ function getOpacity(position: Animated.AnimatedInterpolation<number>, 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();
Expand Down Expand Up @@ -83,7 +86,16 @@ function TabSelector({state, navigation, onTabPress = () => {}, position}: TabSe
}, [defaultAffectedAnimatedTabs, state.index]);

return (
<View style={styles.tabSelector}>
<View
style={styles.tabSelector}
ref={(viewNode) => {
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);
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/useFocusTrapContainers/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type {UseFocusTrapContainers} from './type';

const useFocusTrapContainers: UseFocusTrapContainers = () => [[], () => () => {}];

export default useFocusTrapContainers;
18 changes: 18 additions & 0 deletions src/hooks/useFocusTrapContainers/index.web.tsx
dominictb marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {useCallback, useState} from 'react';
import type {FocusTrapContainerElement, UseFocusTrapContainers} from './type';

const useFocusTrapContainers: UseFocusTrapContainers = () => {
dominictb marked this conversation as resolved.
Show resolved Hide resolved
const [containers, setContainers] = useState<HTMLElement[]>([]);
dominictb marked this conversation as resolved.
Show resolved Hide resolved

const addContainer = useCallback((container: FocusTrapContainerElement) => {
dominictb marked this conversation as resolved.
Show resolved Hide resolved
const containerAsHTMLElement = container as unknown as HTMLElement;
Copy link
Contributor

Choose a reason for hiding this comment

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

and here containerElementAsHTMLElement instead of containerAsHTMLElement

const removeContainer = () => setContainers((prevContainers) => prevContainers.filter((c) => c !== container));
Copy link
Contributor

Choose a reason for hiding this comment

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

and here removeContainerElement instead of removeContainer

setContainers((prevContainers) => (prevContainers.includes(containerAsHTMLElement) ? prevContainers : [...prevContainers, containerAsHTMLElement]));

return removeContainer;
}, []);

return [containers, addContainer];
};

export default useFocusTrapContainers;
12 changes: 12 additions & 0 deletions src/hooks/useFocusTrapContainers/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type {View} from 'react-native';
import type ScrollView from '@components/ScrollView';

type FocusTrapContainerElement = HTMLElement | View | React.ElementRef<typeof ScrollView>;

type RemoveFocusTrapContainerFunction = () => void;

type RegisterFocusTrapContainerCallback = (container: FocusTrapContainerElement) => RemoveFocusTrapContainerFunction;

type UseFocusTrapContainers = () => [HTMLElement[], RegisterFocusTrapContainerCallback];

export type {UseFocusTrapContainers, RegisterFocusTrapContainerCallback, FocusTrapContainerElement};
15 changes: 14 additions & 1 deletion src/pages/iou/MoneyRequestAmountForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +68,8 @@ type MoneyRequestAmountFormProps = {

/** Whether the user input should be kept or not */
shouldKeepUserInput?: boolean;

registerFocusTrapContainer?: RegisterFocusTrapContainerCallback;
Copy link
Contributor

Choose a reason for hiding this comment

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

and here registerFocusTrapContainerElement instead of registerFocusTrapContainer

};

const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01;
Expand All @@ -92,6 +95,7 @@ function MoneyRequestAmountForm(
onSubmitButtonPress,
selectedTab = CONST.TAB_REQUEST.MANUAL,
shouldKeepUserInput = false,
registerFocusTrapContainer,
}: MoneyRequestAmountFormProps,
forwardedRef: ForwardedRef<BaseTextInputRef>,
) {
Expand Down Expand Up @@ -251,7 +255,16 @@ function MoneyRequestAmountForm(
}, [selectedTab]);

return (
<ScrollView contentContainerStyle={styles.flexGrow1}>
<ScrollView
ref={(scrollViewNode) => {
if (!scrollViewNode) {
return;
}
const unregister = registerFocusTrapContainer?.(scrollViewNode);
return () => unregister?.();
}}
contentContainerStyle={styles.flexGrow1}
>
<View
id={AMOUNT_VIEW_ID}
onMouseDown={(event) => onMouseDown(event, [AMOUNT_VIEW_ID])}
Expand Down
76 changes: 54 additions & 22 deletions src/pages/iou/request/IOURequestStartPage.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import DragAndDropProvider from '@components/DragAndDrop/Provider';
import 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';
Expand Down Expand Up @@ -76,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) {
Expand All @@ -117,6 +102,33 @@ function IOURequestStartPage({
[policy, reportID, isFromGlobalCreate, transaction],
);

const [otherContainers, addOtherContainer] = useFocusTrapContainers();
const [manualTabContainers, addManualTabContainer] = useFocusTrapContainers();
const [scanTabContainers, addScanTabContainer] = useFocusTrapContainers();
dominictb marked this conversation as resolved.
Show resolved Hide resolved
const [distanceTabContainers, addDistanceTabContainer] = useFocusTrapContainers();

const focusTrapContainers = useMemo<HTMLElement[]>(
() =>
dominictb marked this conversation as resolved.
Show resolved Hide resolved
[
...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 TabSelectorWithFocusTrapInclusion = useCallback(
(props: TabSelectorProps) => (
<TabSelector
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
registerFocusTrapContainer={addOtherContainer}
/>
),
[addOtherContainer],
);

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.
Expand All @@ -137,6 +149,7 @@ function IOURequestStartPage({
shouldEnableMinHeight={DeviceCapabilities.canUseTouchScreen()}
headerGapStyles={isDraggingOver ? [styles.receiptDropHeaderGap] : []}
testID={IOURequestStartPage.displayName}
focusTrapSettings={{containerElements: focusTrapContainers, focusTrapOptions: {preventScroll: true}}}
>
dominictb marked this conversation as resolved.
Show resolved Hide resolved
{({safeAreaPaddingBottomStyle}) => (
<DragAndDropProvider
Expand All @@ -147,28 +160,47 @@ function IOURequestStartPage({
<HeaderWithBackButton
title={tabTitles[iouType]}
onBackButtonPress={navigateBack}
registerFocusTrapContainer={addOtherContainer}
/>
dominictb marked this conversation as resolved.
Show resolved Hide resolved
{iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.INVOICE ? (
<OnyxTabNavigator
id={CONST.TAB.IOU_REQUEST_TYPE}
onTabSelected={resetIOUTypeIfChanged}
tabBar={TabSelector}
tabBar={TabSelectorWithFocusTrapInclusion}
>
<TopTab.Screen name={CONST.TAB_REQUEST.MANUAL}>
{() => (
<IOURequestStepAmount
shouldKeepUserInput
route={route}
registerFocusTrapContainer={addManualTabContainer}
/>
dominictb marked this conversation as resolved.
Show resolved Hide resolved
)}
</TopTab.Screen>
<TopTab.Screen name={CONST.TAB_REQUEST.SCAN}>
{() => (
<IOURequestStepScan
registerFocusTrapContainer={addScanTabContainer}
route={route}
/>
)}
</TopTab.Screen>
<TopTab.Screen name={CONST.TAB_REQUEST.SCAN}>{() => <IOURequestStepScan route={route} />}</TopTab.Screen>
{shouldDisplayDistanceRequest && <TopTab.Screen name={CONST.TAB_REQUEST.DISTANCE}>{() => <IOURequestStepDistance route={route} />}</TopTab.Screen>}
{shouldDisplayDistanceRequest && (
<TopTab.Screen name={CONST.TAB_REQUEST.DISTANCE}>
{() => (
<IOURequestStepDistance
route={route}
registerFocusTrapContainer={addDistanceTabContainer}
/>
)}
</TopTab.Screen>
)}
</OnyxTabNavigator>
) : (
<IOURequestStepAmount
route={route}
shouldKeepUserInput
registerFocusTrapContainer={addManualTabContainer}
/>
)}
</View>
Expand Down
Loading
Loading