Skip to content

Commit 1276520

Browse files
authored
Merge pull request Expensify#85221 from TaduJR/fix-Screen-Reader-Many-Pages-There-is-no-dialog-role-and-title-announced
fix: Screen Reader: Many Pages: There is no dialog role and title announced
2 parents cde448a + 068aa27 commit 1276520

11 files changed

Lines changed: 603 additions & 227 deletions

File tree

src/CONST/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6081,6 +6081,8 @@ const CONST = {
60816081
NAVIGATION: 'navigation',
60826082
/** Use for Tooltips */
60836083
TOOLTIP: 'tooltip',
6084+
/** Use for dialog/modal elements */
6085+
DIALOG: 'dialog',
60846086
/** Use for data table containers. */
60856087
TABLE: 'table',
60866088
/** Use for table rows. */
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import React, {createContext, useContext, useRef} from 'react';
2+
import type {View} from 'react-native';
3+
4+
type LabelEntry = {id: number; text: string};
5+
6+
type DialogLabelData = {
7+
containerRef: React.RefObject<View | null>;
8+
isInsideDialog: boolean;
9+
};
10+
11+
type DialogLabelActions = {
12+
pushLabel: (text: string) => number;
13+
popLabel: (id: number) => void;
14+
claimInitialFocus: () => boolean;
15+
};
16+
17+
const DialogLabelDataContext = createContext<DialogLabelData>({
18+
containerRef: {current: null},
19+
isInsideDialog: false,
20+
});
21+
22+
const DialogLabelActionsContext = createContext<DialogLabelActions>({
23+
pushLabel: () => 0,
24+
popLabel: () => {},
25+
claimInitialFocus: () => false,
26+
});
27+
28+
type DialogLabelProviderProps = {
29+
children: React.ReactNode;
30+
containerRef: React.RefObject<View | null>;
31+
};
32+
33+
function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) {
34+
const nextIdRef = useRef(0);
35+
const labelStackRef = useRef<LabelEntry[]>([]);
36+
const initialFocusClaimedRef = useRef(false);
37+
38+
const updateContainerLabel = () => {
39+
const top = labelStackRef.current.at(-1);
40+
const node = containerRef.current as unknown as HTMLElement | null;
41+
if (!node || typeof node.setAttribute !== 'function') {
42+
return;
43+
}
44+
if (top?.text) {
45+
node.setAttribute('aria-label', top.text);
46+
} else {
47+
node.removeAttribute('aria-label');
48+
}
49+
};
50+
51+
const pushLabel = (text: string): number => {
52+
const id = nextIdRef.current++;
53+
labelStackRef.current = [...labelStackRef.current, {id, text}];
54+
initialFocusClaimedRef.current = false;
55+
updateContainerLabel();
56+
return id;
57+
};
58+
59+
const popLabel = (id: number) => {
60+
labelStackRef.current = labelStackRef.current.filter((entry) => entry.id !== id);
61+
updateContainerLabel();
62+
};
63+
64+
const claimInitialFocus = (): boolean => {
65+
if (initialFocusClaimedRef.current) {
66+
return false;
67+
}
68+
initialFocusClaimedRef.current = true;
69+
return true;
70+
};
71+
72+
const data: DialogLabelData = {
73+
containerRef,
74+
isInsideDialog: true,
75+
};
76+
77+
const actions: DialogLabelActions = {
78+
pushLabel,
79+
popLabel,
80+
claimInitialFocus,
81+
};
82+
83+
return (
84+
<DialogLabelDataContext.Provider value={data}>
85+
<DialogLabelActionsContext.Provider value={actions}>{children}</DialogLabelActionsContext.Provider>
86+
</DialogLabelDataContext.Provider>
87+
);
88+
}
89+
90+
function useDialogLabelData(): DialogLabelData {
91+
return useContext(DialogLabelDataContext);
92+
}
93+
94+
function useDialogLabelActions(): DialogLabelActions {
95+
return useContext(DialogLabelActionsContext);
96+
}
97+
98+
export {DialogLabelProvider, useDialogLabelData, useDialogLabelActions};

src/components/Header.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type {ReactNode} from 'react';
22
import React, {useMemo} from 'react';
33
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
44
import {Linking, View} from 'react-native';
5+
import useDialogContainerFocus from '@hooks/useDialogContainerFocus';
6+
import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration';
57
import useThemeStyles from '@hooks/useThemeStyles';
68
import CONST from '@src/CONST';
79
import EnvironmentBadge from './EnvironmentBadge';
@@ -32,10 +34,27 @@ type HeaderProps = {
3234

3335
/** Line number for the title */
3436
numberOfTitleLines?: number;
37+
38+
/** Whether this is the screen-level header (registers dialog label and focus). Only HeaderWithBackButton should set this. */
39+
isScreenHeader?: boolean;
3540
};
3641

37-
function Header({title = '', subtitle = '', textStyles = [], style, containerStyles = [], shouldShowEnvironmentBadge = false, subTitleLink = '', numberOfTitleLines = 2}: HeaderProps) {
42+
function Header({
43+
title = '',
44+
subtitle = '',
45+
textStyles = [],
46+
style,
47+
containerStyles = [],
48+
shouldShowEnvironmentBadge = false,
49+
subTitleLink = '',
50+
numberOfTitleLines = 2,
51+
isScreenHeader = false,
52+
}: HeaderProps) {
3853
const styles = useThemeStyles();
54+
const {isTransitionReady, claimInitialFocus, containerRef} = useDialogLabelRegistration(isScreenHeader ? title : '');
55+
56+
useDialogContainerFocus(containerRef, isTransitionReady, claimInitialFocus);
57+
3958
const renderedSubtitle = useMemo(
4059
() => (
4160
<>

src/components/HeaderWithBackButton/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import SearchButton from '@components/Search/SearchRouter/SearchButton';
1212
import SidePanelButton from '@components/SidePanel/SidePanelButton';
1313
import ThreeDotsMenu from '@components/ThreeDotsMenu';
1414
import Tooltip from '@components/Tooltip';
15+
import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration';
1516
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
1617
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
1718
import useLocalize from '@hooks/useLocalize';
@@ -79,6 +80,9 @@ function HeaderWithBackButton({
7980
shouldMinimizeMenuButton = false,
8081
openParentReportInCurrentTab = false,
8182
}: HeaderWithBackButtonProps) {
83+
// Avatar-header routes skip Header, so register the dialog label here.
84+
useDialogLabelRegistration(shouldShowReportAvatarWithDisplay ? (report?.reportName ?? '') : '');
85+
8286
const icons = useMemoizedLazyExpensifyIcons(['Download', 'Rotate', 'BackArrow', 'Close']);
8387
const theme = useTheme();
8488
const styles = useThemeStyles();
@@ -148,6 +152,7 @@ function HeaderWithBackButton({
148152
textStyles={[titleColor ? StyleUtils.getTextColorStyle(titleColor) : {}, shouldUseHeadlineHeader && styles.textHeadlineH2]}
149153
subTitleLink={subTitleLink}
150154
numberOfTitleLines={1}
155+
isScreenHeader
151156
/>
152157
);
153158
}, [
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type UseDialogContainerFocus from './types';
2+
3+
/**
4+
* No-op on native — dialog focus is only needed for web screen readers.
5+
*/
6+
const useDialogContainerFocus: UseDialogContainerFocus = () => {};
7+
8+
export default useDialogContainerFocus;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {useEffect} from 'react';
2+
import {InteractionManager} from 'react-native';
3+
import type UseDialogContainerFocus from './types';
4+
5+
const FOCUSABLE_SELECTOR = 'button, [href], input, textarea, select, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])';
6+
7+
/** Focuses the first interactive element inside the dialog after the RHP transition for screen reader announcement. */
8+
const useDialogContainerFocus: UseDialogContainerFocus = (ref, isReady, claimInitialFocus) => {
9+
useEffect(() => {
10+
if (!isReady || !claimInitialFocus?.()) {
11+
return;
12+
}
13+
let cancelled = false;
14+
let frameId: number;
15+
// Deferred past useAutoFocusInput's InteractionManager + Promise chain.
16+
// eslint-disable-next-line @typescript-eslint/no-deprecated
17+
const interactionHandle = InteractionManager.runAfterInteractions(() => {
18+
if (cancelled) {
19+
return;
20+
}
21+
frameId = requestAnimationFrame(() => {
22+
if (cancelled || (document.activeElement && document.activeElement !== document.body)) {
23+
return;
24+
}
25+
const container = ref.current as unknown as HTMLElement | null;
26+
const targets = container?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
27+
const target = targets && Array.from(targets).find((el) => !el.closest('[aria-hidden="true"]'));
28+
target?.focus({preventScroll: true});
29+
});
30+
});
31+
return () => {
32+
cancelled = true;
33+
interactionHandle.cancel();
34+
cancelAnimationFrame(frameId);
35+
};
36+
}, [isReady, ref, claimInitialFocus]);
37+
};
38+
39+
export default useDialogContainerFocus;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type {RefObject} from 'react';
2+
import type {View} from 'react-native';
3+
4+
type UseDialogContainerFocus = (ref: RefObject<View | null>, isReady: boolean, claimInitialFocus?: () => boolean) => void;
5+
6+
export default UseDialogContainerFocus;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type {ReactNode} from 'react';
2+
import {useContext, useEffect} from 'react';
3+
import {useDialogLabelActions, useDialogLabelData} from '@components/DialogLabelContext';
4+
import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext';
5+
6+
/** Registers and manages a dialog label in the DialogLabelContext for the lifetime of the calling component. */
7+
function useDialogLabelRegistration(title: ReactNode) {
8+
const {isInsideDialog, containerRef} = useDialogLabelData();
9+
const {pushLabel, popLabel, claimInitialFocus} = useDialogLabelActions();
10+
const screenWrapperStatus = useContext(ScreenWrapperStatusContext);
11+
12+
useEffect(() => {
13+
if (!isInsideDialog || typeof title !== 'string' || !title) {
14+
return;
15+
}
16+
const id = pushLabel(title);
17+
return () => popLabel(id);
18+
}, [isInsideDialog, title, pushLabel, popLabel]);
19+
20+
const isTransitionReady = !!isInsideDialog && !!screenWrapperStatus?.didScreenTransitionEnd;
21+
22+
return {isTransitionReady, claimInitialFocus, containerRef};
23+
}
24+
25+
export default useDialogLabelRegistration;

src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.side
3232
return (
3333
<Animated.View
3434
id="BaseOverlay"
35+
aria-hidden
3536
style={[styles.pFixed, styles.t0, styles.b0, styles.overlayBackground, styles.overlayStyles({progress: progress ?? current.progress, positionLeftValue, positionRightValue})]}
3637
>
3738
<View style={[styles.flex1, styles.flexColumn]}>

0 commit comments

Comments
 (0)