Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/components/ScreenWrapper/ScreenWrapperContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ type ScreenWrapperContainerProps = ForwardedFSClassProps &
*/
isFocused?: boolean;

/** Whether this screen should be hidden from accessibility tree */
shouldHideFromAccessibility?: boolean;

/** Reference to the outer element */
ref?: ForwardedRef<View>;
}>;
Expand All @@ -109,6 +112,7 @@ function ScreenWrapperContainer({
includePaddingTop = true,
includeSafeAreaPaddingBottom = false,
isFocused = true,
shouldHideFromAccessibility = false,
ref,
forwardedFSClass,
}: ScreenWrapperContainerProps) {
Expand Down Expand Up @@ -212,6 +216,9 @@ function ScreenWrapperContainer({
{...panResponder.panHandlers}
testID={testID}
fsClass={forwardedFSClass}
tabIndex={-1}
accessibilityElementsHidden={shouldHideFromAccessibility}
aria-hidden={shouldHideFromAccessibility}
>
<View
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
Expand Down
68 changes: 64 additions & 4 deletions src/components/ScreenWrapper/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,7 +16,10 @@ import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useThemeStyles from '@hooks/useThemeStyles';
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';
Expand All @@ -32,6 +35,9 @@ import type {ScreenWrapperOfflineIndicatorsProps} from './ScreenWrapperOfflineIn
import ScreenWrapperOfflineIndicators from './ScreenWrapperOfflineIndicators';
import ScreenWrapperStatusContext from './ScreenWrapperStatusContext';

const FOCUSABLE_ELEMENTS_SELECTOR = 'button, [href], [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])';
const PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE = 'data-programmatic-focus';

type ScreenWrapperChildrenProps = {
insets: EdgeInsets;
safeAreaPaddingBottomStyle?: {
Expand Down Expand Up @@ -104,10 +110,12 @@ function ScreenWrapper({
const navigationFallback = useNavigation<PlatformStackNavigationProp<RootNavigatorParamList>>();
const navigation = navigationProp ?? navigationFallback;
const isFocused = useIsFocused();
const screenWrapperRef = useRef<View | HTMLElement>(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 {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();

const styles = useThemeStyles();
const {isDevelopment} = useEnvironment();
Expand All @@ -133,6 +141,9 @@ function ScreenWrapper({
// This context allows us to disable the safe area padding offsetting the offline indicator in scrollable components like 'ScrollView', 'SelectionList' or 'FormProvider'.
// This is useful e.g. for the RightModalNavigator, where we want to avoid the safe area padding offsetting the offline indicator because we only show the offline indicator on small screens.
const {isInNarrowPane} = useContext(NarrowPaneContext);
const isMobileWebNarrowLayout = getPlatform() === CONST.PLATFORM.WEB && isMobile() && shouldUseNarrowLayout;
const shouldMoveAccessibilityFocus = isMobileWebNarrowLayout && isInNarrowPane;
const shouldHideFromAccessibility = isMobileWebNarrowLayout && !isFocused;
const {addSafeAreaPadding, showOnSmallScreens, showOnWideScreens, originalValues} = useContext(ScreenWrapperOfflineIndicatorContext);
const offlineIndicatorContextValue = useMemo(() => {
const newAddSafeAreaPadding = isInNarrowPane ? isSmallScreenWidth : addSafeAreaPadding;
Expand Down Expand Up @@ -177,6 +188,54 @@ function ScreenWrapper({
closeReactNativeApp({shouldSetNVP: false, isTrackingGPS: false});
});

useEffect(() => {
Comment thread
marufsharifi marked this conversation as resolved.
Outdated
if (!shouldMoveAccessibilityFocus || !didScreenTransitionEnd || !isFocused) {
return;
}

if (typeof document === 'undefined') {
return;
}

const element = screenWrapperRef.current;
if (!element || !('contains' in element) || !('querySelectorAll' in element)) {
return;
}

const activeElement = document.activeElement;
if (activeElement && element.contains(activeElement)) {
return;
Comment thread
marufsharifi marked this conversation as resolved.
Outdated
}

const focusTargets = element.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS_SELECTOR);
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;
}

const removeProgrammaticFocusAttr = () => {
focusTarget.removeAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE);
};

focusTarget.setAttribute(PROGRAMMATIC_FOCUS_DATA_ATTRIBUTE, 'true');
focusTarget.addEventListener('blur', removeProgrammaticFocusAttr, {once: true});
focusTarget.focus();

const focusedElement = document.activeElement;
if (focusedElement === focusTarget || (focusedElement && focusTarget.contains(focusedElement))) {
return;
}

focusTarget.removeEventListener('blur', removeProgrammaticFocusAttr);
removeProgrammaticFocusAttr();
}
}, [didScreenTransitionEnd, isFocused, shouldMoveAccessibilityFocus]);

useEffect(() => {
// On iOS, the transitionEnd event doesn't trigger some times. As such, we need to set a timeout
const timeout = setTimeout(() => {
Expand Down Expand Up @@ -255,7 +314,7 @@ function ScreenWrapper({
return (
<FocusTrapForScreen focusTrapSettings={focusTrapSettings}>
<ScreenWrapperContainer
ref={ref}
ref={mergedScreenWrapperRef}
style={[styles.flex1, style]}
bottomContent={bottomContent}
didScreenTransitionEnd={didScreenTransitionEnd}
Expand All @@ -264,6 +323,7 @@ function ScreenWrapper({
includePaddingTop={includePaddingTop}
includeSafeAreaPaddingBottom={includeSafeAreaPaddingBottom}
isFocused={isFocused}
shouldHideFromAccessibility={shouldHideFromAccessibility}
// eslint-disable-next-line react/jsx-props-no-spreading
{...restContainerProps}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,12 @@ function createModalStackNavigator<ParamList extends ParamListBase>(screens: Scr

return (
// This container is necessary to hide card translation during transition. Without it the user would see un-clipped cards.
<View style={[styles.modalStackNavigatorContainer, styles.modalStackNavigatorContainerWidth(isSmallScreenWidth)]}>
<View
style={[styles.modalStackNavigatorContainer, styles.modalStackNavigatorContainerWidth(isSmallScreenWidth)]}
accessibilityViewIsModal={isSmallScreenWidth}
aria-modal={isSmallScreenWidth || undefined}
role={isSmallScreenWidth ? 'dialog' : undefined}
>
<ModalStackNavigator.Navigator>
{Object.keys(screens as Required<Screens>).map((name) => (
<ModalStackNavigator.Screen
Expand Down
12 changes: 6 additions & 6 deletions tests/ui/SearchPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ describe('SearchPageNarrow', () => {
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 = screen.getByTestId('search-autocomplete-text-input');
const searchAutocompleteInput = screen.getByTestId('search-autocomplete-text-input', {includeHiddenElements: true});
expect(searchAutocompleteInput).toBeTruthy();

// When the search input is focused, the NavigationTabBar from SearchPageNarrow will unmount, and the one from TopLevelNavigationTabBar will be hidden.
Expand All @@ -130,12 +130,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});
});

Expand All @@ -146,12 +146,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});
});
});
Expand Down
8 changes: 4 additions & 4 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@
-webkit-user-select: none !important;
-webkit-touch-callout: none !important;
}
:focus-visible {
:focus-visible:not([data-programmatic-focus="true"]) {
outline: 0;
box-shadow: inset 0px 0px 0px 1px #5AB0FF;
}
:focus-visible[data-inner-box-shadow-element]{
:focus-visible:not([data-programmatic-focus="true"])[data-inner-box-shadow-element]{
overflow: hidden;
}
:focus-visible[data-inner-box-shadow-element]::before {
:focus-visible:not([data-programmatic-focus="true"])[data-inner-box-shadow-element]::before {
content: '';
position: absolute;
top: 0;
Expand All @@ -79,7 +79,7 @@
pointer-events: none;
z-index: 1000;
}
:focus[data-focusvisible-polyfill] {
:focus[data-focusvisible-polyfill]:not([data-programmatic-focus="true"]) {
outline: 0;
box-shadow: inset 0px 0px 0px 1px #5AB0FF;
}
Expand Down
Loading