From 932b5c9f37578e766fdc957a7eb3319cc7947b77 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 5 Feb 2026 18:43:09 +0430 Subject: [PATCH 1/4] fix(a11y): allow bottom sheet to close via screen reader without selection --- src/components/Modal/BaseModal.tsx | 24 +++++++++++++++++++++--- src/languages/en.ts | 1 + src/libs/Accessibility/index.ts | 22 +++++++++++++++++++++- src/styles/index.ts | 10 ++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 3d5751b53dd45..0f64942a5ed09 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -1,12 +1,14 @@ import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; +import type {GestureResponderEvent, LayoutChangeEvent} from 'react-native'; // Animated required for side panel navigation // eslint-disable-next-line no-restricted-imports import {Animated, DeviceEventEmitter, View} from 'react-native'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import NavigationBar from '@components/NavigationBar'; +import {PressableWithoutFeedback} from '@components/Pressable'; import ScreenWrapperOfflineIndicatorContext from '@components/ScreenWrapper/ScreenWrapperOfflineIndicatorContext'; import useKeyboardState from '@hooks/useKeyboardState'; +import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; @@ -15,6 +17,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import Accessibility from '@libs/Accessibility'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import {canUseTouchScreen as canUseTouchScreenCheck} from '@libs/DeviceCapabilities'; import NarrowPaneContext from '@libs/Navigation/AppNavigator/Navigators/NarrowPaneContext'; @@ -75,6 +78,8 @@ function BaseModal({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const isScreenReaderEnabled = Accessibility.useScreenReaderStatus(); const {windowWidth, windowHeight} = useWindowDimensions(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct modal width const canUseTouchScreen = canUseTouchScreenCheck(); @@ -176,8 +181,8 @@ function BaseModal({ onModalShow(); }, [onModalShow, shouldSetModalVisibility, type]); - const handleBackdropPress = (e?: KeyboardEvent) => { - if (e?.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { + const handleBackdropPress = (e?: KeyboardEvent | GestureResponderEvent) => { + if (e && 'key' in e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { return; } @@ -261,6 +266,8 @@ function BaseModal({ ], ); + const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onClose ?? onBackdropPress) && isScreenReaderEnabled; + const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ shouldAddBottomSafeAreaMargin, @@ -375,6 +382,17 @@ function BaseModal({ ref={ref} fsClass={forwardedFSClass} > + {shouldShowBottomDockedDismissButton && ( + + + + )} {children} {!keyboardStateContextValue?.isKeyboardActive && } diff --git a/src/languages/en.ts b/src/languages/en.ts index f9fb678b0d265..6e6828680c54c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1681,6 +1681,7 @@ const translations = { }, modal: { backdropLabel: 'Modal Backdrop', + dismissDialog: 'Dismiss dialog', }, nextStep: { message: { diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 20f840633e3cf..133076ac0369f 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -8,9 +8,29 @@ type HitSlop = {x: number; y: number}; const useScreenReaderStatus = (): boolean => { const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); useEffect(() => { - const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', setIsScreenReaderEnabled); + let isMounted = true; + const isScreenReaderEnabledAsync = AccessibilityInfo.isScreenReaderEnabled; + if (isScreenReaderEnabledAsync) { + isScreenReaderEnabledAsync() + .then((enabled) => { + if (!isMounted) { + return; + } + + setIsScreenReaderEnabled(enabled); + }) + .catch(() => {}); + } + const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { + if (!isMounted) { + return; + } + + setIsScreenReaderEnabled(enabled); + }); return () => { + isMounted = false; subscription?.remove(); }; }, []); diff --git a/src/styles/index.ts b/src/styles/index.ts index e2fbc09857221..5896b24b40b96 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3575,6 +3575,16 @@ const staticStyles = (theme: ThemeColors) => backgroundColor: theme.overlay, }, + bottomDockedModalDismissButton: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: variables.iconSizeXSmall, + backgroundColor: 'transparent', + zIndex: 1, + }, + invisibleOverlay: { backgroundColor: theme.transparent, zIndex: 1000, From 7fad99587fa93b4bbd951fe437112e344ce3ad99 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 5 Feb 2026 18:43:17 +0430 Subject: [PATCH 2/4] added translation --- src/languages/es.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/languages/es.ts b/src/languages/es.ts index 00394f29f8ac1..87b6940f8298b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1403,6 +1403,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'Fondo del Modal', + dismissDialog: 'Cerrar diálogo', }, nextStep: { message: { From 61b9e63e8596e529fc52913cdacd5c814fd039e9 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sun, 8 Feb 2026 11:45:38 +0430 Subject: [PATCH 3/4] fixed for android web --- src/components/Modal/BaseModal.tsx | 39 ++++++++++++++++++++++++++++-- src/languages/de.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/Accessibility/index.ts | 20 ++++++--------- 10 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 0f64942a5ed09..6c95ef75d2347 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -20,6 +20,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import Accessibility from '@libs/Accessibility'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import {canUseTouchScreen as canUseTouchScreenCheck} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import NarrowPaneContext from '@libs/Navigation/AppNavigator/Navigators/NarrowPaneContext'; import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; import Navigation from '@libs/Navigation/Navigation'; @@ -81,6 +82,7 @@ function BaseModal({ const {translate} = useLocalize(); const isScreenReaderEnabled = Accessibility.useScreenReaderStatus(); const {windowWidth, windowHeight} = useWindowDimensions(); + const isWeb = getPlatform() === CONST.PLATFORM.WEB; // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct modal width const canUseTouchScreen = canUseTouchScreenCheck(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -97,6 +99,7 @@ function BaseModal({ const shouldCallHideModalOnUnmount = useRef(false); const hideModalCallbackRef = useRef<(callHideCallback: boolean) => void>(undefined); + const dismissButtonRef = useRef(null); const wasVisible = usePrevious(isVisible); @@ -266,7 +269,38 @@ function BaseModal({ ], ); - const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onClose ?? onBackdropPress) && isScreenReaderEnabled; + const shouldShowBottomDockedDismissButton = + isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose) && isScreenReaderEnabled; + + const initialFocusTarget = useMemo(() => { + if (!isWeb || !shouldShowBottomDockedDismissButton) { + return initialFocus; + } + return () => dismissButtonRef.current ?? document.body; + }, [initialFocus, isWeb, shouldShowBottomDockedDismissButton]); + + useEffect(() => { + if (!isWeb || !isVisible || !shouldShowBottomDockedDismissButton) { + return; + } + + let retries = 0; + const focusDismissButton = () => { + const target = dismissButtonRef.current; + if (target && 'focus' in target && typeof target.focus === 'function') { + target.focus(); + return; + } + + if (retries >= 5) { + return; + } + retries++; + requestAnimationFrame(focusDismissButton); + }; + + requestAnimationFrame(focusDismissButton); + }, [isWeb, isVisible, shouldShowBottomDockedDismissButton]); const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ @@ -350,7 +384,7 @@ function BaseModal({ onSwipeComplete={onClose} swipeDirection={swipeDirection} shouldPreventScrollOnFocus={shouldPreventScrollOnFocus} - initialFocus={initialFocus} + initialFocus={initialFocusTarget} swipeThreshold={swipeThreshold} isVisible={isVisible} backdropColor={theme.overlay} @@ -384,6 +418,7 @@ function BaseModal({ > {shouldShowBottomDockedDismissButton && ( = { }, modal: { backdropLabel: 'Modal-Hintergrund', + dismissDialog: 'Dialog schließen', }, nextStep: { message: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index f57faeb36eea0..87be52588b081 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1673,6 +1673,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'Arrière-plan de la fenêtre modale', + dismissDialog: 'Fermer la boîte de dialogue', }, nextStep: { message: { diff --git a/src/languages/it.ts b/src/languages/it.ts index bf7f14b92a6fa..04ca9945a2465 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1663,6 +1663,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'Sfondo modale', + dismissDialog: 'Chiudi finestra di dialogo', }, nextStep: { message: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a10e24c176aee..fdef26f1b8e69 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1658,6 +1658,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'モーダルの背景', + dismissDialog: 'ダイアログを閉じる', }, nextStep: { message: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 031394a1ad03f..6e639ffe3a329 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1663,6 +1663,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'Modale achtergrond', + dismissDialog: 'Dialoog sluiten', }, nextStep: { message: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9c63aa6aac40f..9db2ad2f8fc89 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1662,6 +1662,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'Tło modalu', + dismissDialog: 'Zamknij okno dialogowe', }, nextStep: { message: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 41fe3d44d5050..f2e511004e6da 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1660,6 +1660,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'Pano de fundo do modal', + dismissDialog: 'Fechar diálogo', }, nextStep: { message: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index fd07640497fee..89c842c96a6d3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1634,6 +1634,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: '模态背景', + dismissDialog: '关闭对话框', }, nextStep: { message: { diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 133076ac0369f..f22e07830530f 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -9,23 +9,19 @@ const useScreenReaderStatus = (): boolean => { const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); useEffect(() => { let isMounted = true; - const isScreenReaderEnabledAsync = AccessibilityInfo.isScreenReaderEnabled; - if (isScreenReaderEnabledAsync) { - isScreenReaderEnabledAsync() - .then((enabled) => { - if (!isMounted) { - return; - } + AccessibilityInfo.isScreenReaderEnabled() + .then((enabled) => { + if (!isMounted) { + return; + } + setIsScreenReaderEnabled(enabled); + }) + .catch(() => {}); - setIsScreenReaderEnabled(enabled); - }) - .catch(() => {}); - } const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { if (!isMounted) { return; } - setIsScreenReaderEnabled(enabled); }); From c02c0a7e8133950729d7590d4dce2fcac6a5ea27 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sun, 8 Feb 2026 12:17:54 +0430 Subject: [PATCH 4/4] Fix CI lint/prettier and avoid perf test regression --- src/components/Modal/BaseModal.tsx | 4 ++-- src/libs/Accessibility/index.ts | 20 ++++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 6c95ef75d2347..db535e1e829e3 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -269,8 +269,7 @@ function BaseModal({ ], ); - const shouldShowBottomDockedDismissButton = - isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose) && isScreenReaderEnabled; + const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose) && isScreenReaderEnabled; const initialFocusTarget = useMemo(() => { if (!isWeb || !shouldShowBottomDockedDismissButton) { @@ -422,6 +421,7 @@ function BaseModal({ onPress={handleBackdropPress} accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={translate('modal.dismissDialog')} + sentryLabel="Modal-DismissDialog" style={styles.bottomDockedModalDismissButton} shouldUseAutoHitSlop > diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index f22e07830530f..133076ac0369f 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -9,19 +9,23 @@ const useScreenReaderStatus = (): boolean => { const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); useEffect(() => { let isMounted = true; - AccessibilityInfo.isScreenReaderEnabled() - .then((enabled) => { - if (!isMounted) { - return; - } - setIsScreenReaderEnabled(enabled); - }) - .catch(() => {}); + const isScreenReaderEnabledAsync = AccessibilityInfo.isScreenReaderEnabled; + if (isScreenReaderEnabledAsync) { + isScreenReaderEnabledAsync() + .then((enabled) => { + if (!isMounted) { + return; + } + setIsScreenReaderEnabled(enabled); + }) + .catch(() => {}); + } const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => { if (!isMounted) { return; } + setIsScreenReaderEnabled(enabled); });