Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions src/components/DatePicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import TextInput from '@components/TextInput';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
Expand Down Expand Up @@ -41,10 +42,13 @@ function DatePicker({
const [isModalVisible, setIsModalVisible] = useState(false);
const [selectedDate, setSelectedDate] = useState(() => value ?? defaultValue ?? '');
const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0});
const textInputRef = useRef<BaseTextInputRef>(null);
const textInputRef = useRef<BaseTextInputRef | null>(null);
const anchorRef = useRef<View>(null);
const [isInverted, setIsInverted] = useState(false);
const isAutoFocused = useRef(false);

const {inputCallbackRef: autoFocusCallbackRef} = useAutoFocusInput();
const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef);
autoFocusCallbackRefRef.current = autoFocusCallbackRef;

useEffect(() => {
if (shouldSaveDraft && formID) {
Expand Down Expand Up @@ -103,16 +107,20 @@ function DatePicker({
});
}, [calculatePopoverPosition, windowWidth]);

useEffect(() => {
if (!autoFocus || isAutoFocused.current) {
return;
}
isAutoFocused.current = true;
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.runAfterInteractions(() => {
textInputRef.current?.focus();
});
}, [autoFocus]);
// Combined ref: updates textInputRef (needed for blur() in showDatePickerModal) and connects
// autoFocusCallbackRef only when autoFocus=true so useAutoFocusInput's useFocusEffect cleanup
// can cancel any pending focus task when the screen starts closing.
const combinedTextInputRef = useCallback(
(ref: BaseTextInputRef | null) => {
textInputRef.current = ref;
if (autoFocus) {
(autoFocusCallbackRefRef.current as unknown as (ref: BaseTextInputRef | null) => void)(ref);
}
},
// autoFocusCallbackRefRef is a stable ref — its identity never changes, so it's not a dep
// eslint-disable-next-line react-hooks/exhaustive-deps
[autoFocus],
);

const getValidDateForCalendar = useMemo(() => {
if (!selectedDate) {
Expand All @@ -129,7 +137,7 @@ function DatePicker({
style={styles.mv2}
>
<TextInput
ref={textInputRef}
ref={combinedTextInputRef}
inputID={inputID}
forceActiveLabel
icon={selectedDate ? null : icons.Calendar}
Expand Down
31 changes: 10 additions & 21 deletions src/components/SelectionList/ListItem/SplitListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import React, {useCallback, useState} from 'react';
import {View} from 'react-native';
import Icon from '@components/Icon';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import type {ListItem} from '@components/SelectionList/types';
import Text from '@components/Text';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {getDecodedCategoryName} from '@libs/CategoryUtils';
Expand Down Expand Up @@ -37,8 +37,6 @@ function SplitListItem<TItem extends ListItem>({
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus();

const splitItem = item as unknown as SplitListItemType;

const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(splitItem.originalAmount, splitItem.currency);
Expand All @@ -55,7 +53,7 @@ function SplitListItem<TItem extends ListItem>({
[splitItem],
);

const inputRef = useRef<BaseTextInputRef | null>(null);
const {inputCallbackRef: autoFocusCallbackRef} = useAutoFocusInput();

// Animated highlight style for selected item
const animatedHighlightStyle = useAnimatedHighlightStyle({
Expand All @@ -75,23 +73,14 @@ function SplitListItem<TItem extends ListItem>({
onInputFocus?.(item);
}, [onInputFocus, item]);

// Auto-focus input when item is selected and screen transition ends
useEffect(() => {
if (!didScreenTransitionEnd || !splitItem.isSelected || !splitItem.isEditable || !inputRef.current) {
// Only connect the auto-focus ref to the selected item so useAutoFocusInput's useFocusEffect
// cleanup can cancel any pending focus task when the screen starts closing, preventing
// the focused input from interfering with the close animation.
const inputCallbackRef: (ref: BaseTextInputRef | null) => void = (ref) => {
if (!splitItem.isSelected || !splitItem.isEditable) {
return;
}

// Use InteractionManager to ensure input focus happens after all animations/interactions complete.
// This prevents focus from interrupting modal close/open animations which would cause UI glitches
// and "jumping" behavior when quickly navigating between screens.
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.runAfterInteractions(() => {
inputRef.current?.focus();
});
}, [didScreenTransitionEnd, splitItem.isSelected, splitItem.isEditable]);

const inputCallbackRef = (ref: BaseTextInputRef | null) => {
inputRef.current = ref;
(autoFocusCallbackRef as unknown as (ref: BaseTextInputRef | null) => void)(ref);
};

const isPercentageMode = splitItem.mode === CONST.TAB.SPLIT.PERCENTAGE;
Expand Down
5 changes: 5 additions & 0 deletions src/pages/iou/request/step/IOURequestEditReportCommon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider';
import SelectionList from '@components/SelectionList';
import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem';
import type {ListItem} from '@components/SelectionList/types';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDebouncedState from '@hooks/useDebouncedState';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
Expand Down Expand Up @@ -67,6 +69,7 @@ function IOURequestEditReportCommon({
isTimeRequest = false,
}: Props) {
const icons = useMemoizedLazyExpensifyIcons(['Close', 'Document']);
const {inputCallbackRef} = useAutoFocusInput();
const {translate, localeCompare} = useLocalize();
const personalDetails = usePersonalDetails();
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
Expand Down Expand Up @@ -302,6 +305,8 @@ function IOURequestEditReportCommon({
label: translate('common.search'),
headerMessage,
onChangeText: setSearchValue,
disableAutoFocus: true,
ref: inputCallbackRef as (ref: BaseTextInputRef | null) => void,
}}
shouldSingleExecuteRowSelect
initiallyFocusedItemKey={selectedReportID}
Expand Down
21 changes: 12 additions & 9 deletions tests/ui/IOURequestEditReportCommonTest.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {NavigationContainer} from '@react-navigation/native';
import {act, render, screen} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import ComposeProviders from '@components/ComposeProviders';
Expand Down Expand Up @@ -44,15 +45,17 @@ jest.mock('@components/OptionListContextProvider', () => ({
*/
const renderIOURequestEditReportCommon = ({selectedReportID = '', selectedPolicyID}: {selectedReportID: string; selectedPolicyID?: string}) =>
render(
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>
<IOURequestEditReportCommon
selectedReportID={selectedReportID}
selectedPolicyID={selectedPolicyID}
selectReport={jest.fn()}
backTo=""
isPerDiemRequest={false}
/>
</ComposeProviders>,
<NavigationContainer>
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>
<IOURequestEditReportCommon
selectedReportID={selectedReportID}
selectedPolicyID={selectedPolicyID}
selectReport={jest.fn()}
backTo=""
isPerDiemRequest={false}
/>
</ComposeProviders>
</NavigationContainer>,
);

describe('IOURequestEditReportCommon', () => {
Expand Down
Loading