diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index b2264b86cf57..d9e1ab3a5e6f 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -262,7 +262,6 @@ "../../src/components/MoneyReportHeaderActions/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/MoneyReportHeaderModals.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/components/MoneyRequestAmountInput.tsx" "react-hooks/immutability" 2 -"../../src/components/MoneyRequestConfirmationList.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/components/MoneyRequestConfirmationList.tsx" "react-hooks/set-state-in-effect" 2 "../../src/components/MoneyRequestConfirmationList/ConfirmationFooterContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -504,7 +503,6 @@ "../../src/hooks/useAnimatedHighlightStyle/index.ts" "react-hooks/set-state-in-effect" 2 "../../src/hooks/useAssignCard.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useAutoCreateTrackWorkspace.ts" "no-restricted-imports" 1 -"../../src/hooks/useAutoFocusInput.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/hooks/useAutoUpdateTimezone.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useAutocompleteSuggestions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useBasePopoverReactionList/index.ts" "no-restricted-syntax" 2 @@ -523,8 +521,8 @@ "../../src/hooks/useDebounceNonReactive.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useDebouncedState.ts" "react-hooks/refs" 2 "../../src/hooks/useDebouncedValue.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/hooks/useDialogContainerFocus/index.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/hooks/useDialogContainerFocus/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/hooks/useDialogContainerFocus/index.ts" "no-restricted-imports" 1 "../../src/hooks/useDiscardChangesConfirmation/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useDomainGroupFilter.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useDragAndDrop/types.ts" "@typescript-eslint/no-deprecated/React.MutableRefObject" 1 diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 681f0c5301b4..92a3e87be6a2 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -37,6 +37,9 @@ type HeaderProps = { /** Whether this is the screen-level header (registers dialog label and focus). Only HeaderWithBackButton should set this. */ isScreenHeader?: boolean; + + /** Whether to skip focus of the first interactive element inside the header after the RHP transition for screen reader announcement. */ + shouldSkipFocusAfterTransition?: boolean; }; function Header({ @@ -49,11 +52,12 @@ function Header({ subTitleLink = '', numberOfTitleLines = 2, isScreenHeader = false, + shouldSkipFocusAfterTransition = false, }: HeaderProps) { const styles = useThemeStyles(); const {isTransitionReady, claimInitialFocus, containerRef} = useDialogLabelRegistration(isScreenHeader ? title : ''); - useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus); + useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus, shouldSkipFocusAfterTransition); const renderedSubtitle = useMemo( () => ( diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index add2910ee29e..f1158dd3b63e 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -78,6 +78,7 @@ function HeaderWithBackButton({ subTitleLink = '', shouldMinimizeMenuButton = false, openParentReportInCurrentTab = false, + shouldSkipFocusAfterTransition = false, }: HeaderWithBackButtonProps) { // Avatar-header routes skip Header, so register the dialog label here. useDialogLabelRegistration(shouldShowReportAvatarWithDisplay ? (report?.reportName ?? '') : ''); @@ -151,6 +152,7 @@ function HeaderWithBackButton({ subTitleLink={subTitleLink} numberOfTitleLines={1} isScreenHeader + shouldSkipFocusAfterTransition={shouldSkipFocusAfterTransition} /> ); }, [ @@ -173,6 +175,7 @@ function HeaderWithBackButton({ translate, openParentReportInCurrentTab, shouldDisplayStatus, + shouldSkipFocusAfterTransition, ]); const ThreeDotMenuButton = useMemo(() => { if (shouldShowThreeDotsButton) { diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index fbbbba888290..a117b1f6c5e7 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -170,6 +170,9 @@ type HeaderWithBackButtonProps = Partial & { shouldMinimizeMenuButton?: boolean; /** Whether to open the parent report link in the current tab if possible */ openParentReportInCurrentTab?: boolean; + + /** Whether to skip focus of the first interactive element inside the header after the RHP transition for screen reader announcement. */ + shouldSkipFocusAfterTransition?: boolean; }; export type {ThreeDotsMenuItem}; diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 89f825bbe90e..5f454570cac6 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1,7 +1,6 @@ -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import {InteractionManager, View} from 'react-native'; +import {useIsFocused} from '@react-navigation/native'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import useAttendees from '@hooks/useAttendees'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -14,7 +13,6 @@ import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; -import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import {isCategoryDescriptionRequired} from '@libs/CategoryUtils'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -485,22 +483,6 @@ function MoneyRequestConfirmationList({ onSendMoney, }); - const focusTimeoutRef = useRef(null); - useFocusEffect( - useCallback(() => { - // Blurring the active element after transition fights AmountField focus in the new manual flow (RHP reopen). - if (isNewManualExpenseFlowEnabled) { - return undefined; - } - focusTimeoutRef.current = setTimeout(() => { - InteractionManager.runAfterInteractions(() => { - blurActiveElement(); - }); - }, CONST.ANIMATED_TRANSITION); - return () => focusTimeoutRef.current && clearTimeout(focusTimeoutRef.current); - }, [isNewManualExpenseFlowEnabled]), - ); - const isCompactMode = !showMoreFields && isScanRequest && !isInLandscapeMode; const selectionListStyle = { containerStyle: [styles.flexBasisAuto], diff --git a/src/hooks/useAutoFocusInput.ts b/src/hooks/useAutoFocusInput.ts index ea29b398cc03..d58c711a1a62 100644 --- a/src/hooks/useAutoFocusInput.ts +++ b/src/hooks/useAutoFocusInput.ts @@ -2,13 +2,12 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native'; import {useCallback, useEffect, useRef, useState} from 'react'; import type {RefObject} from 'react'; import type {TextInput} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. -import {InteractionManager} from 'react-native'; import Accessibility from '@libs/Accessibility'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import {moveSelectionToEnd, scrollToBottom} from '@libs/InputUtils'; import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; import {shouldSkipAutoFocusDueToExistingFocus} from '@libs/NavigationFocusReturn'; import {Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter'; @@ -66,30 +65,32 @@ export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInpu ) { return; } - const focusTaskHandle = InteractionManager.runAfterInteractions(() => { - if (inputRef.current && isMultiline) { - moveSelectionToEnd(inputRef.current); - } - isWindowReadyToFocus().then(() => { - // Null-ref claim would block fallbacks on the destination screen. - const input = inputRef.current; - if (!input) { - return; - } - if (shouldSkipAutoFocusDueToExistingFocus()) { - return; - } - if (!tryClaim(Priorities.AUTO)) { - return; + const focusTaskHandle = TransitionTracker.runAfterTransitions({ + callback: () => { + if (inputRef.current && isMultiline) { + moveSelectionToEnd(inputRef.current); } - // Silent no-op (RN-Web TextInput hidden/disabled) leaves AUTO claimed; release so INITIAL/RETURN aren't blocked for 2s. - const beforeActive = typeof document !== 'undefined' ? document.activeElement : null; - input.focus(); - if (beforeActive !== null && document.activeElement === beforeActive) { - resetCycle(); - } - }); - setIsScreenTransitionEnded(false); + isWindowReadyToFocus().then(() => { + // Null-ref claim would block fallbacks on the destination screen. + const input = inputRef.current; + if (!input) { + return; + } + if (shouldSkipAutoFocusDueToExistingFocus()) { + return; + } + if (!tryClaim(Priorities.AUTO)) { + return; + } + // Silent no-op (RN-Web TextInput hidden/disabled) leaves AUTO claimed; release so INITIAL/RETURN aren't blocked for 2s. + const beforeActive = typeof document !== 'undefined' ? document.activeElement : null; + input.focus(); + if (beforeActive !== null && document.activeElement === beforeActive) { + resetCycle(); + } + }); + setIsScreenTransitionEnded(false); + }, }); return () => { diff --git a/src/hooks/useDialogContainerFocus/index.ts b/src/hooks/useDialogContainerFocus/index.ts index b7678226c3a8..6bc55e16cc35 100644 --- a/src/hooks/useDialogContainerFocus/index.ts +++ b/src/hooks/useDialogContainerFocus/index.ts @@ -1,9 +1,8 @@ import {useEffect} from 'react'; -// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions. -import {InteractionManager} from 'react-native'; import FOCUSABLE_SELECTOR from '@libs/focusableSelector'; import hasFocusableAttributes from '@libs/focusGuards'; import getHadTabNavigation from '@libs/hadTabNavigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {Priorities, tryClaim} from '@libs/ScreenFocusArbiter'; import type UseDialogContainerFocus from './types'; @@ -25,32 +24,34 @@ function focusFirstInteractiveElement(container: HTMLElement | null): boolean { } /** Focuses the first interactive element inside the dialog after the RHP transition for screen reader announcement. */ -const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimInitialFocus) => { +const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimInitialFocus, skipDialogContainerFocus = false) => { useEffect(() => { - if (!isReady || !claimInitialFocus?.()) { + if (!isReady || !claimInitialFocus?.() || skipDialogContainerFocus) { return; } let cancelled = false; let frameId: number; // Deferred past useAutoFocusInput's InteractionManager + Promise chain. - const interactionHandle = InteractionManager.runAfterInteractions(() => { - if (cancelled) { - return; - } - frameId = requestAnimationFrame(() => { + const interactionHandle = TransitionTracker.runAfterTransitions({ + callback: () => { if (cancelled) { return; } - const container = ref.current as unknown as HTMLElement | null; - focusFirstInteractiveElement(container); - }); + frameId = requestAnimationFrame(() => { + if (cancelled) { + return; + } + const container = ref.current as unknown as HTMLElement | null; + focusFirstInteractiveElement(container); + }); + }, }); return () => { cancelled = true; interactionHandle.cancel(); cancelAnimationFrame(frameId); }; - }, [isReady, ref, claimInitialFocus]); + }, [isReady, ref, claimInitialFocus, skipDialogContainerFocus]); }; export default useDialogContainerFocus; diff --git a/src/hooks/useDialogContainerFocus/types.ts b/src/hooks/useDialogContainerFocus/types.ts index d9305561c4ce..c02c82ba87dc 100644 --- a/src/hooks/useDialogContainerFocus/types.ts +++ b/src/hooks/useDialogContainerFocus/types.ts @@ -1,6 +1,6 @@ import type {RefObject} from 'react'; import type {View} from 'react-native'; -type UseDialogContainerFocus = (ref: RefObject, isReady: boolean, claimInitialFocus?: () => boolean) => void; +type UseDialogContainerFocus = (ref: RefObject, isReady: boolean, claimInitialFocus?: () => boolean, skipDialogContainerFocus?: boolean) => void; export default UseDialogContainerFocus; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index d05a62d8a9a1..a7170556c7b5 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -783,6 +783,8 @@ function IOURequestStepConfirmation({ title={headerTitle} subtitle={hasMultipleTransactions ? `${currentTransactionIndex + 1} ${translate('common.of')} ${transactions.length}` : undefined} onBackButtonPress={navigateBack} + /** Skip focus of the first interactive element in the header to make sure that Enter key submits the expense on the confirmation page instead of navigating back. */ + shouldSkipFocusAfterTransition > {hasMultipleTransactions ? (