diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 3d5751b53dd45..db535e1e829e3 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,8 +17,10 @@ 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 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'; @@ -75,7 +79,10 @@ function BaseModal({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + 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 @@ -92,6 +99,7 @@ function BaseModal({ const shouldCallHideModalOnUnmount = useRef(false); const hideModalCallbackRef = useRef<(callHideCallback: boolean) => void>(undefined); + const dismissButtonRef = useRef(null); const wasVisible = usePrevious(isVisible); @@ -176,8 +184,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 +269,38 @@ function BaseModal({ ], ); + 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({ shouldAddBottomSafeAreaMargin, @@ -343,7 +383,7 @@ function BaseModal({ onSwipeComplete={onClose} swipeDirection={swipeDirection} shouldPreventScrollOnFocus={shouldPreventScrollOnFocus} - initialFocus={initialFocus} + initialFocus={initialFocusTarget} swipeThreshold={swipeThreshold} isVisible={isVisible} backdropColor={theme.overlay} @@ -375,6 +415,19 @@ function BaseModal({ ref={ref} fsClass={forwardedFSClass} > + {shouldShowBottomDockedDismissButton && ( + + + + )} {children} {!keyboardStateContextValue?.isKeyboardActive && } diff --git a/src/languages/de.ts b/src/languages/de.ts index 8a523e3dd095b..56825aa2ce5eb 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1652,6 +1652,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'Modal-Hintergrund', + dismissDialog: 'Dialog schließen', }, nextStep: { message: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 54a7e93017809..cdae660345a6c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1667,6 +1667,7 @@ const translations = { }, modal: { backdropLabel: 'Modal Backdrop', + dismissDialog: 'Dismiss dialog', }, nextStep: { message: { diff --git a/src/languages/es.ts b/src/languages/es.ts index bf122d5fd91cc..0bf44350e1d1e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1411,6 +1411,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'Fondo del Modal', + dismissDialog: 'Cerrar diálogo', }, nextStep: { message: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 86beb4b90af75..da0dc09da7315 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1659,6 +1659,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 6156fde86c84e..32390756e2648 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1649,6 +1649,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 ed5c33b782e36..b14affb691e83 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1644,6 +1644,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'モーダルの背景', + dismissDialog: 'ダイアログを閉じる', }, nextStep: { message: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index dfd9f84e69d1f..7cd7ab1bbfe07 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1649,6 +1649,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 e4571465b7193..f78f027085729 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1648,6 +1648,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 386483d74de0c..8e1e83e999269 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1646,6 +1646,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 d27e74d5f0c79..48a7e5920f824 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1620,6 +1620,7 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: '模态背景', + dismissDialog: '关闭对话框', }, 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 37fc2d231459f..540b7b2d3347f 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,