diff --git a/src/components/Navigation/TopLevelNavigationTabBar/index.tsx b/src/components/Navigation/TopLevelNavigationTabBar/index.tsx index 18bcaa2ee26d2..da32b9ec2575f 100644 --- a/src/components/Navigation/TopLevelNavigationTabBar/index.tsx +++ b/src/components/Navigation/TopLevelNavigationTabBar/index.tsx @@ -67,6 +67,8 @@ function TopLevelNavigationTabBar({state}: TopLevelNavigationTabBarProps) { !shouldUseNarrowLayout ? styles.borderRight : {}, shouldDisplayLHB ? StyleUtils.positioning.l0 : StyleUtils.positioning.b0, ]} + accessibilityElementsHidden={!isReadyToDisplayBottomBar} + aria-hidden={!isReadyToDisplayBottomBar} > {/* We are not rendering NavigationTabBar conditionally for two reasons 1. It's faster to hide/show it than mount a new when needed. diff --git a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx index 9118f8260c537..4a6e60139ba29 100644 --- a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx @@ -8,12 +8,14 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import ModalContext from '@components/Modal/ModalContext'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import useInitialDimensions from '@hooks/useInitialWindowDimensions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useTackInputFocus from '@hooks/useTackInputFocus'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {isMobile, isMobileWebKit, isSafari} from '@libs/Browser'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; +import getPlatform from '@libs/getPlatform'; import addViewportResizeListener from '@libs/VisualViewport'; import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; @@ -108,8 +110,8 @@ function ScreenWrapperContainer({ includePaddingTop = true, includeSafeAreaPaddingBottom = false, isFocused = true, - ref, forwardedFSClass, + ref, }: ScreenWrapperContainerProps) { const {windowHeight} = useWindowDimensions(shouldUseCachedViewportHeight); const {initialHeight} = useInitialDimensions(); @@ -119,6 +121,8 @@ function ScreenWrapperContainer({ const {isBlurred} = useInputBlurState(); const {setIsBlurred} = useInputBlurActions(); const isAvoidingViewportScroll = useTackInputFocus(isFocused && shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && isMobileWebKit()); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const shouldHideFromAccessibility = shouldUseNarrowLayout && getPlatform() === CONST.PLATFORM.WEB && isMobile() && !isFocused; const isUsingEdgeToEdgeMode = enableEdgeToEdgeBottomSafeAreaPadding !== undefined; const shouldKeyboardOffsetBottomSafeAreaPadding = shouldKeyboardOffsetBottomSafeAreaPaddingProp ?? isUsingEdgeToEdgeMode; @@ -210,6 +214,9 @@ function ScreenWrapperContainer({ {...panResponder.panHandlers} testID={testID} fsClass={forwardedFSClass} + tabIndex={-1} + accessibilityElementsHidden={shouldHideFromAccessibility} + aria-hidden={shouldHideFromAccessibility} > >(); const navigation = navigationProp ?? navigationFallback; const isFocused = useIsFocused(); + const screenWrapperRef = useRef(null); + const mergedScreenWrapperRef = mergeRefs(screenWrapperRef, ref); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout for a case where we want to show the offline indicator only on small screens // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); + const shouldMoveAccessibilityFocus = getPlatform() === CONST.PLATFORM.WEB && isMobile(); const styles = useThemeStyles(); const {isDevelopment} = useEnvironment(); @@ -220,6 +227,46 @@ function ScreenWrapper({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!shouldMoveAccessibilityFocus || !didScreenTransitionEnd || !isFocused) { + return; + } + + if (typeof document === 'undefined') { + return; + } + + const element = screenWrapperRef.current as unknown as HTMLElement | null; + if (!element) { + return; + } + + const activeElement = document.activeElement as HTMLElement | null; + if (activeElement && element.contains(activeElement)) { + return; + } + + const focusTargets = element.querySelectorAll('button, [href], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'); + for (const focusTarget of focusTargets) { + const isDisabledTarget = focusTarget.matches(':disabled') || focusTarget.getAttribute('aria-disabled')?.toLowerCase() === 'true'; + if (isDisabledTarget || focusTarget.getAttribute('aria-hidden') === 'true') { + continue; + } + + if (focusTarget === activeElement) { + return; + } + + focusTarget.focus(); + const focusedElement = document.activeElement as HTMLElement | null; + if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) { + return; + } + } + + Accessibility.moveAccessibilityFocus(screenWrapperRef as unknown as Parameters[0]); + }, [didScreenTransitionEnd, isFocused, shouldMoveAccessibilityFocus]); + useFocusEffect( useCallback(() => { // On iOS, the transitionEnd event doesn't trigger some times. As such, we need to set a timeout @@ -255,7 +302,7 @@ function ScreenWrapper({ return ( (screens: Scr return ( // This container is necessary to hide card translation during transition. Without it the user would see un-clipped cards. - + {Object.keys(screens as Required).map((name) => ( { expect(screen.getByTestId('SearchPageNarrow')).toBeTruthy(); // Initially, there are two NavigationTabBars on screen: one from TopLevelNavigationTabBar and one from SearchPageNarrow. - let navigationTabBars = screen.getAllByTestId('NavigationTabBar'); + let navigationTabBars = screen.getAllByTestId('NavigationTabBar', {includeHiddenElements: true}); expect(navigationTabBars).toHaveLength(2); const searchAutocompleteInput = await screen.findByTestId('search-autocomplete-text-input'); @@ -126,12 +126,12 @@ describe('SearchPageNarrow', () => { }); await waitFor(() => { - navigationTabBars = screen.getAllByTestId('NavigationTabBar'); + navigationTabBars = screen.getAllByTestId('NavigationTabBar', {includeHiddenElements: true}); expect(navigationTabBars).toHaveLength(1); }); await waitFor(() => { - const topLevelNavigationTabBar = screen.getByTestId('TopLevelNavigationTabBar'); + const topLevelNavigationTabBar = screen.getByTestId('TopLevelNavigationTabBar', {includeHiddenElements: true}); expect(topLevelNavigationTabBar).toHaveStyle({pointerEvents: 'none', opacity: 0}); }); @@ -142,12 +142,12 @@ describe('SearchPageNarrow', () => { }); await waitFor(() => { - navigationTabBars = screen.getAllByTestId('NavigationTabBar'); + navigationTabBars = screen.getAllByTestId('NavigationTabBar', {includeHiddenElements: true}); expect(navigationTabBars).toHaveLength(2); }); await waitFor(() => { - const topLevelNavigationTabBar = screen.getByTestId('TopLevelNavigationTabBar'); + const topLevelNavigationTabBar = screen.getByTestId('TopLevelNavigationTabBar', {includeHiddenElements: true}); expect(topLevelNavigationTabBar).toHaveStyle({pointerEvents: 'auto', opacity: 1}); }); });