From 6f68f0674e711158b816009402f7c99ced1eb79e Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 4 Feb 2026 14:43:22 +0430 Subject: [PATCH 1/3] fix(a11y): move focus to opened menu layout on mWeb --- .../TopLevelNavigationTabBar/index.tsx | 2 + .../ScreenWrapper/ScreenWrapperContainer.tsx | 60 ++++++++++--------- src/components/ScreenWrapper/index.tsx | 36 +++++++++-- .../ModalStackNavigators/index.tsx | 7 ++- 4 files changed, 73 insertions(+), 32 deletions(-) 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..4a043bb80502c 100644 --- a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx @@ -9,12 +9,14 @@ import ModalContext from '@components/Modal/ModalContext'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import useInitialDimensions from '@hooks/useInitialWindowDimensions'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; 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 addViewportResizeListener from '@libs/VisualViewport'; +import getPlatform from '@libs/getPlatform'; import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; @@ -83,34 +85,33 @@ type ScreenWrapperContainerProps = ForwardedFSClassProps & * Whether the screen is focused. (Only passed if wrapped in ScreenWrapper) */ isFocused?: boolean; - - /** Reference to the outer element */ - ref?: ForwardedRef; }>; -function ScreenWrapperContainer({ - children, - style, - testID, - bottomContent, - bottomContentStyle: bottomContentStyleProp, - keyboardAvoidingViewBehavior = 'padding', - keyboardVerticalOffset, - shouldEnableKeyboardAvoidingView = true, - shouldEnableMaxHeight = false, - shouldEnableMinHeight = false, - shouldEnablePickerAvoiding = true, - shouldDismissKeyboardBeforeClose = true, - shouldAvoidScrollOnVirtualViewport = true, - shouldUseCachedViewportHeight = false, - shouldKeyboardOffsetBottomSafeAreaPadding: shouldKeyboardOffsetBottomSafeAreaPaddingProp, - enableEdgeToEdgeBottomSafeAreaPadding, - includePaddingTop = true, - includeSafeAreaPaddingBottom = false, - isFocused = true, - ref, - forwardedFSClass, -}: ScreenWrapperContainerProps) { +const ScreenWrapperContainer = React.forwardRef(function ScreenWrapperContainer( + { + children, + style, + testID, + bottomContent, + bottomContentStyle: bottomContentStyleProp, + keyboardAvoidingViewBehavior = 'padding', + keyboardVerticalOffset, + shouldEnableKeyboardAvoidingView = true, + shouldEnableMaxHeight = false, + shouldEnableMinHeight = false, + shouldEnablePickerAvoiding = true, + shouldDismissKeyboardBeforeClose = true, + shouldAvoidScrollOnVirtualViewport = true, + shouldUseCachedViewportHeight = false, + shouldKeyboardOffsetBottomSafeAreaPadding: shouldKeyboardOffsetBottomSafeAreaPaddingProp, + enableEdgeToEdgeBottomSafeAreaPadding, + includePaddingTop = true, + includeSafeAreaPaddingBottom = false, + isFocused = true, + forwardedFSClass, + }: ScreenWrapperContainerProps, + ref: ForwardedRef, +) { const {windowHeight} = useWindowDimensions(shouldUseCachedViewportHeight); const {initialHeight} = useInitialDimensions(); const styles = useThemeStyles(); @@ -119,6 +120,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 +213,9 @@ function ScreenWrapperContainer({ {...panResponder.panHandlers} testID={testID} fsClass={forwardedFSClass} + tabIndex={-1} + accessibilityElementsHidden={shouldHideFromAccessibility} + aria-hidden={shouldHideFromAccessibility} > {bottomContent}} ); -} +}); ScreenWrapperContainer.displayName = 'ScreenWrapperContainer'; diff --git a/src/components/ScreenWrapper/index.tsx b/src/components/ScreenWrapper/index.tsx index dc0d80442f701..907bfaa15516e 100644 --- a/src/components/ScreenWrapper/index.tsx +++ b/src/components/ScreenWrapper/index.tsx @@ -1,8 +1,8 @@ import {useFocusEffect, useIsFocused, useNavigation, usePreventRemove} from '@react-navigation/native'; import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; import type {ReactNode} from 'react'; -import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import type {StyleProp, View, ViewStyle} from 'react-native'; import {DeviceEventEmitter, Keyboard} from 'react-native'; import type {EdgeInsets} from 'react-native-safe-area-context'; import CustomDevMenu from '@components/CustomDevMenu'; @@ -16,11 +16,14 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useThemeStyles from '@hooks/useThemeStyles'; +import Accessibility from '@libs/Accessibility'; +import {isMobile} from '@libs/Browser'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import NarrowPaneContext from '@libs/Navigation/AppNavigator/Navigators/NarrowPaneContext'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList, RootNavigatorParamList} from '@libs/Navigation/types'; +import getPlatform from '@libs/getPlatform'; import {closeReactNativeApp} from '@userActions/HybridApp'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -91,7 +94,6 @@ function ScreenWrapper({ shouldKeyboardOffsetBottomSafeAreaPadding: shouldKeyboardOffsetBottomSafeAreaPaddingProp, isOfflineIndicatorTranslucent, focusTrapSettings, - ref, ...restContainerProps }: ScreenWrapperProps) { /** @@ -104,10 +106,12 @@ function ScreenWrapper({ const navigationFallback = useNavigation>(); const navigation = navigationProp ?? navigationFallback; const isFocused = useIsFocused(); + const screenWrapperRef = useRef(null); // 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 +224,30 @@ function ScreenWrapper({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!shouldMoveAccessibilityFocus || !didScreenTransitionEnd || !isFocused) { + 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 focusTarget = element.querySelector('button, [href], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'); + if (focusTarget && focusTarget !== activeElement) { + focusTarget.focus(); + 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 +283,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) => ( Date: Sun, 8 Feb 2026 11:30:01 +0430 Subject: [PATCH 2/3] fixed lint, test, and prettier failures. --- .../ScreenWrapper/ScreenWrapperContainer.tsx | 59 +++++++++---------- src/components/ScreenWrapper/index.tsx | 7 ++- tests/ui/SearchPageTest.tsx | 10 ++-- 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx index 4a043bb80502c..4a6e60139ba29 100644 --- a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx @@ -8,15 +8,15 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import ModalContext from '@components/Modal/ModalContext'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import useInitialDimensions from '@hooks/useInitialWindowDimensions'; -import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; 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 addViewportResizeListener from '@libs/VisualViewport'; import getPlatform from '@libs/getPlatform'; +import addViewportResizeListener from '@libs/VisualViewport'; import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; @@ -85,33 +85,34 @@ type ScreenWrapperContainerProps = ForwardedFSClassProps & * Whether the screen is focused. (Only passed if wrapped in ScreenWrapper) */ isFocused?: boolean; + + /** Reference to the outer element */ + ref?: ForwardedRef; }>; -const ScreenWrapperContainer = React.forwardRef(function ScreenWrapperContainer( - { - children, - style, - testID, - bottomContent, - bottomContentStyle: bottomContentStyleProp, - keyboardAvoidingViewBehavior = 'padding', - keyboardVerticalOffset, - shouldEnableKeyboardAvoidingView = true, - shouldEnableMaxHeight = false, - shouldEnableMinHeight = false, - shouldEnablePickerAvoiding = true, - shouldDismissKeyboardBeforeClose = true, - shouldAvoidScrollOnVirtualViewport = true, - shouldUseCachedViewportHeight = false, - shouldKeyboardOffsetBottomSafeAreaPadding: shouldKeyboardOffsetBottomSafeAreaPaddingProp, - enableEdgeToEdgeBottomSafeAreaPadding, - includePaddingTop = true, - includeSafeAreaPaddingBottom = false, - isFocused = true, - forwardedFSClass, - }: ScreenWrapperContainerProps, - ref: ForwardedRef, -) { +function ScreenWrapperContainer({ + children, + style, + testID, + bottomContent, + bottomContentStyle: bottomContentStyleProp, + keyboardAvoidingViewBehavior = 'padding', + keyboardVerticalOffset, + shouldEnableKeyboardAvoidingView = true, + shouldEnableMaxHeight = false, + shouldEnableMinHeight = false, + shouldEnablePickerAvoiding = true, + shouldDismissKeyboardBeforeClose = true, + shouldAvoidScrollOnVirtualViewport = true, + shouldUseCachedViewportHeight = false, + shouldKeyboardOffsetBottomSafeAreaPadding: shouldKeyboardOffsetBottomSafeAreaPaddingProp, + enableEdgeToEdgeBottomSafeAreaPadding, + includePaddingTop = true, + includeSafeAreaPaddingBottom = false, + isFocused = true, + forwardedFSClass, + ref, +}: ScreenWrapperContainerProps) { const {windowHeight} = useWindowDimensions(shouldUseCachedViewportHeight); const {initialHeight} = useInitialDimensions(); const styles = useThemeStyles(); @@ -243,9 +244,7 @@ const ScreenWrapperContainer = React.forwardRef{bottomContent}} ); -}); - -ScreenWrapperContainer.displayName = 'ScreenWrapperContainer'; +} export default React.memo(ScreenWrapperContainer); export type {ScreenWrapperContainerProps}; diff --git a/src/components/ScreenWrapper/index.tsx b/src/components/ScreenWrapper/index.tsx index 5ad243373a95a..6fe72c0b1ebde 100644 --- a/src/components/ScreenWrapper/index.tsx +++ b/src/components/ScreenWrapper/index.tsx @@ -19,11 +19,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Accessibility from '@libs/Accessibility'; import {isMobile} from '@libs/Browser'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; +import getPlatform from '@libs/getPlatform'; +import mergeRefs from '@libs/mergeRefs'; import NarrowPaneContext from '@libs/Navigation/AppNavigator/Navigators/NarrowPaneContext'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList, RootNavigatorParamList} from '@libs/Navigation/types'; -import getPlatform from '@libs/getPlatform'; import {closeReactNativeApp} from '@userActions/HybridApp'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -94,6 +95,7 @@ function ScreenWrapper({ shouldKeyboardOffsetBottomSafeAreaPadding: shouldKeyboardOffsetBottomSafeAreaPaddingProp, isOfflineIndicatorTranslucent, focusTrapSettings, + ref, ...restContainerProps }: ScreenWrapperProps) { /** @@ -107,6 +109,7 @@ function ScreenWrapper({ 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 @@ -283,7 +286,7 @@ function ScreenWrapper({ return ( { 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}); }); }); From fc08e3ee51b935a8fd1755e1c4ae593b49c13697 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 9 Feb 2026 19:06:13 +0430 Subject: [PATCH 3/3] refactored based on ai suggestion --- src/components/ScreenWrapper/index.tsx | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/components/ScreenWrapper/index.tsx b/src/components/ScreenWrapper/index.tsx index 6fe72c0b1ebde..242c1cd5b6934 100644 --- a/src/components/ScreenWrapper/index.tsx +++ b/src/components/ScreenWrapper/index.tsx @@ -232,20 +232,36 @@ function ScreenWrapper({ 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; + const activeElement = document.activeElement as HTMLElement | null; if (activeElement && element.contains(activeElement)) { return; } - const focusTarget = element.querySelector('button, [href], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'); - if (focusTarget && focusTarget !== activeElement) { + 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(); - return; + const focusedElement = document.activeElement as HTMLElement | null; + if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) { + return; + } } Accessibility.moveAccessibilityFocus(screenWrapperRef as unknown as Parameters[0]);