Skip to content

Commit d61052a

Browse files
authored
Merge pull request Expensify#81390 from marufsharifi/fix/a11y-focus-on-opened-menu-mweb
Fix screen reader focus not moving to opened menu on mWeb
2 parents 337f2a5 + 7ccd1a2 commit d61052a

5 files changed

Lines changed: 68 additions & 10 deletions

File tree

src/components/Navigation/TopLevelNavigationTabBar/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ function TopLevelNavigationTabBar({state}: TopLevelNavigationTabBarProps) {
6767
!shouldUseNarrowLayout ? styles.borderRight : {},
6868
shouldDisplayLHB ? StyleUtils.positioning.l0 : StyleUtils.positioning.b0,
6969
]}
70+
accessibilityElementsHidden={!isReadyToDisplayBottomBar}
71+
aria-hidden={!isReadyToDisplayBottomBar}
7072
>
7173
{/* We are not rendering NavigationTabBar conditionally for two reasons
7274
1. It's faster to hide/show it than mount a new when needed.

src/components/ScreenWrapper/ScreenWrapperContainer.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
88
import ModalContext from '@components/Modal/ModalContext';
99
import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
1010
import useInitialDimensions from '@hooks/useInitialWindowDimensions';
11+
import useResponsiveLayout from '@hooks/useResponsiveLayout';
1112
import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
1213
import useTackInputFocus from '@hooks/useTackInputFocus';
1314
import useThemeStyles from '@hooks/useThemeStyles';
1415
import useWindowDimensions from '@hooks/useWindowDimensions';
1516
import {isMobile, isMobileWebKit, isSafari} from '@libs/Browser';
1617
import type {ForwardedFSClassProps} from '@libs/Fullstory/types';
18+
import getPlatform from '@libs/getPlatform';
1719
import addViewportResizeListener from '@libs/VisualViewport';
1820
import toggleTestToolsModal from '@userActions/TestTool';
1921
import CONST from '@src/CONST';
@@ -108,8 +110,8 @@ function ScreenWrapperContainer({
108110
includePaddingTop = true,
109111
includeSafeAreaPaddingBottom = false,
110112
isFocused = true,
111-
ref,
112113
forwardedFSClass,
114+
ref,
113115
}: ScreenWrapperContainerProps) {
114116
const {windowHeight} = useWindowDimensions(shouldUseCachedViewportHeight);
115117
const {initialHeight} = useInitialDimensions();
@@ -119,6 +121,8 @@ function ScreenWrapperContainer({
119121
const {isBlurred} = useInputBlurState();
120122
const {setIsBlurred} = useInputBlurActions();
121123
const isAvoidingViewportScroll = useTackInputFocus(isFocused && shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && isMobileWebKit());
124+
const {shouldUseNarrowLayout} = useResponsiveLayout();
125+
const shouldHideFromAccessibility = shouldUseNarrowLayout && getPlatform() === CONST.PLATFORM.WEB && isMobile() && !isFocused;
122126

123127
const isUsingEdgeToEdgeMode = enableEdgeToEdgeBottomSafeAreaPadding !== undefined;
124128
const shouldKeyboardOffsetBottomSafeAreaPadding = shouldKeyboardOffsetBottomSafeAreaPaddingProp ?? isUsingEdgeToEdgeMode;
@@ -210,6 +214,9 @@ function ScreenWrapperContainer({
210214
{...panResponder.panHandlers}
211215
testID={testID}
212216
fsClass={forwardedFSClass}
217+
tabIndex={-1}
218+
accessibilityElementsHidden={shouldHideFromAccessibility}
219+
aria-hidden={shouldHideFromAccessibility}
213220
>
214221
<View
215222
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access

src/components/ScreenWrapper/index.tsx

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {useFocusEffect, useIsFocused, useNavigation, usePreventRemove} from '@react-navigation/native';
22
import {isSingleNewDotEntrySelector} from '@selectors/HybridApp';
33
import type {ReactNode} from 'react';
4-
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
5-
import type {StyleProp, ViewStyle} from 'react-native';
4+
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
5+
import type {StyleProp, View, ViewStyle} from 'react-native';
66
import {DeviceEventEmitter, Keyboard} from 'react-native';
77
import type {EdgeInsets} from 'react-native-safe-area-context';
88
import CustomDevMenu from '@components/CustomDevMenu';
@@ -16,7 +16,10 @@ import useOnyx from '@hooks/useOnyx';
1616
import useResponsiveLayout from '@hooks/useResponsiveLayout';
1717
import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
1818
import useThemeStyles from '@hooks/useThemeStyles';
19+
import {isMobile} from '@libs/Browser';
1920
import type {ForwardedFSClassProps} from '@libs/Fullstory/types';
21+
import getPlatform from '@libs/getPlatform';
22+
import mergeRefs from '@libs/mergeRefs';
2023
import NarrowPaneContext from '@libs/Navigation/AppNavigator/Navigators/NarrowPaneContext';
2124
import Navigation from '@libs/Navigation/Navigation';
2225
import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types';
@@ -104,10 +107,13 @@ function ScreenWrapper({
104107
const navigationFallback = useNavigation<PlatformStackNavigationProp<RootNavigatorParamList>>();
105108
const navigation = navigationProp ?? navigationFallback;
106109
const isFocused = useIsFocused();
110+
const screenWrapperRef = useRef<View | HTMLElement>(null);
111+
const mergedScreenWrapperRef = mergeRefs(screenWrapperRef, ref);
107112

108113
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout for a case where we want to show the offline indicator only on small screens
109114
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
110115
const {isSmallScreenWidth} = useResponsiveLayout();
116+
const shouldMoveAccessibilityFocus = getPlatform() === CONST.PLATFORM.WEB && isMobile();
111117

112118
const styles = useThemeStyles();
113119
const {isDevelopment} = useEnvironment();
@@ -220,6 +226,44 @@ function ScreenWrapper({
220226
// eslint-disable-next-line react-hooks/exhaustive-deps
221227
}, []);
222228

229+
useEffect(() => {
230+
if (!shouldMoveAccessibilityFocus || !didScreenTransitionEnd || !isFocused) {
231+
return;
232+
}
233+
234+
if (typeof document === 'undefined') {
235+
return;
236+
}
237+
238+
const element = screenWrapperRef.current;
239+
if (!element || !('contains' in element) || !('querySelectorAll' in element)) {
240+
return;
241+
}
242+
243+
const activeElement = document.activeElement;
244+
if (activeElement && element.contains(activeElement)) {
245+
return;
246+
}
247+
248+
const focusTargets = element.querySelectorAll<HTMLElement>('button, [href], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])');
249+
for (const focusTarget of focusTargets) {
250+
const isDisabledTarget = focusTarget.matches(':disabled') || focusTarget.getAttribute('aria-disabled')?.toLowerCase() === 'true';
251+
if (isDisabledTarget || focusTarget.getAttribute('aria-hidden') === 'true') {
252+
continue;
253+
}
254+
255+
if (focusTarget === activeElement) {
256+
return;
257+
}
258+
259+
focusTarget.focus();
260+
const focusedElement = document.activeElement;
261+
if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) {
262+
return;
263+
}
264+
}
265+
}, [didScreenTransitionEnd, isFocused, shouldMoveAccessibilityFocus]);
266+
223267
useFocusEffect(
224268
useCallback(() => {
225269
// On iOS, the transitionEnd event doesn't trigger some times. As such, we need to set a timeout
@@ -255,7 +299,7 @@ function ScreenWrapper({
255299
return (
256300
<FocusTrapForScreen focusTrapSettings={focusTrapSettings}>
257301
<ScreenWrapperContainer
258-
ref={ref}
302+
ref={mergedScreenWrapperRef}
259303
style={[styles.flex1, style]}
260304
bottomContent={bottomContent}
261305
didScreenTransitionEnd={didScreenTransitionEnd}

src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,12 @@ function createModalStackNavigator<ParamList extends ParamListBase>(screens: Scr
144144

145145
return (
146146
// This container is necessary to hide card translation during transition. Without it the user would see un-clipped cards.
147-
<View style={[styles.modalStackNavigatorContainer, styles.modalStackNavigatorContainerWidth(isSmallScreenWidth)]}>
147+
<View
148+
style={[styles.modalStackNavigatorContainer, styles.modalStackNavigatorContainerWidth(isSmallScreenWidth)]}
149+
accessibilityViewIsModal={isSmallScreenWidth}
150+
aria-modal={isSmallScreenWidth || undefined}
151+
role={isSmallScreenWidth ? 'dialog' : undefined}
152+
>
148153
<ModalStackNavigator.Navigator>
149154
{Object.keys(screens as Required<Screens>).map((name) => (
150155
<ModalStackNavigator.Screen

tests/ui/SearchPageTest.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ describe('SearchPageNarrow', () => {
113113
expect(screen.getByTestId('SearchPageNarrow')).toBeTruthy();
114114

115115
// Initially, there are two NavigationTabBars on screen: one from TopLevelNavigationTabBar and one from SearchPageNarrow.
116-
let navigationTabBars = screen.getAllByTestId('NavigationTabBar');
116+
let navigationTabBars = screen.getAllByTestId('NavigationTabBar', {includeHiddenElements: true});
117117
expect(navigationTabBars).toHaveLength(2);
118118

119119
const searchAutocompleteInput = await screen.findByTestId('search-autocomplete-text-input');
@@ -126,12 +126,12 @@ describe('SearchPageNarrow', () => {
126126
});
127127

128128
await waitFor(() => {
129-
navigationTabBars = screen.getAllByTestId('NavigationTabBar');
129+
navigationTabBars = screen.getAllByTestId('NavigationTabBar', {includeHiddenElements: true});
130130
expect(navigationTabBars).toHaveLength(1);
131131
});
132132

133133
await waitFor(() => {
134-
const topLevelNavigationTabBar = screen.getByTestId('TopLevelNavigationTabBar');
134+
const topLevelNavigationTabBar = screen.getByTestId('TopLevelNavigationTabBar', {includeHiddenElements: true});
135135
expect(topLevelNavigationTabBar).toHaveStyle({pointerEvents: 'none', opacity: 0});
136136
});
137137

@@ -142,12 +142,12 @@ describe('SearchPageNarrow', () => {
142142
});
143143

144144
await waitFor(() => {
145-
navigationTabBars = screen.getAllByTestId('NavigationTabBar');
145+
navigationTabBars = screen.getAllByTestId('NavigationTabBar', {includeHiddenElements: true});
146146
expect(navigationTabBars).toHaveLength(2);
147147
});
148148

149149
await waitFor(() => {
150-
const topLevelNavigationTabBar = screen.getByTestId('TopLevelNavigationTabBar');
150+
const topLevelNavigationTabBar = screen.getByTestId('TopLevelNavigationTabBar', {includeHiddenElements: true});
151151
expect(topLevelNavigationTabBar).toHaveStyle({pointerEvents: 'auto', opacity: 1});
152152
});
153153
});

0 commit comments

Comments
 (0)