From 932b5c9f37578e766fdc957a7eb3319cc7947b77 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 5 Feb 2026 18:43:09 +0430 Subject: [PATCH 01/36] 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 3d5751b53dd4..0f64942a5ed0 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 f9fb678b0d26..6e6828680c54 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 20f840633e3c..133076ac0369 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 e2fbc0985722..5896b24b40b9 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 02/36] 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 00394f29f8ac..87b6940f8298 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 03/36] 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 0f64942a5ed0..6c95ef75d234 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 f57faeb36eea..87be52588b08 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 bf7f14b92a6f..04ca9945a246 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 a10e24c176ae..fdef26f1b8e6 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 031394a1ad03..6e639ffe3a32 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 9c63aa6aac40..9db2ad2f8fc8 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 41fe3d44d505..f2e511004e6d 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 fd07640497fe..89c842c96a6d 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 133076ac0369..f22e07830530 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 04/36] 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 6c95ef75d234..db535e1e829e 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 f22e07830530..133076ac0369 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); }); From df543ffc6ff0e584ed0650adced699326db769ad Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 13 Feb 2026 16:05:19 +0430 Subject: [PATCH 05/36] applied ai feedbacks --- src/components/Modal/BaseModal.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 8b59650d790f..3230362af21f 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -30,6 +30,8 @@ import ModalContext from './ModalContext'; import ReanimatedModal from './ReanimatedModal'; import type BaseModalProps from './types'; +const MAX_DISMISS_BUTTON_FOCUS_RETRIES = 5; + function BaseModal({ isVisible, onClose, @@ -284,6 +286,7 @@ function BaseModal({ } let retries = 0; + let frameID: number | undefined; const focusDismissButton = () => { const target = dismissButtonRef.current; if (target && 'focus' in target && typeof target.focus === 'function') { @@ -291,14 +294,21 @@ function BaseModal({ return; } - if (retries >= 5) { + if (retries >= MAX_DISMISS_BUTTON_FOCUS_RETRIES) { return; } retries++; - requestAnimationFrame(focusDismissButton); + frameID = requestAnimationFrame(focusDismissButton); }; - requestAnimationFrame(focusDismissButton); + frameID = requestAnimationFrame(focusDismissButton); + + return () => { + if (frameID === undefined) { + return; + } + cancelAnimationFrame(frameID); + }; }, [isWeb, isVisible, shouldShowBottomDockedDismissButton]); const modalPaddingStyles = useMemo(() => { From e800de4bcf3b71a7478ee6aeecd23cdf3e8670ed Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 4 Mar 2026 11:27:22 +0430 Subject: [PATCH 06/36] fix(a11y): focus first menu item on open and add SR dismiss target for bottom-docked modal --- src/components/FocusableMenuItem.tsx | 9 +- src/components/Modal/BaseModal.tsx | 70 +++++---------- src/components/PopoverMenu.tsx | 126 ++++++++++++++++++++++++++- src/languages/de.ts | 1 - src/languages/en.ts | 1 - src/languages/es.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 | 22 +---- src/styles/index.ts | 2 +- 15 files changed, 151 insertions(+), 88 deletions(-) diff --git a/src/components/FocusableMenuItem.tsx b/src/components/FocusableMenuItem.tsx index 19172555a93b..5267daecc21d 100644 --- a/src/components/FocusableMenuItem.tsx +++ b/src/components/FocusableMenuItem.tsx @@ -1,20 +1,21 @@ import React, {useRef} from 'react'; import type {View} from 'react-native'; import useSyncFocus from '@hooks/useSyncFocus'; +import mergeRefs from '@libs/mergeRefs'; import type {MenuItemProps} from './MenuItem'; import MenuItem from './MenuItem'; -function FocusableMenuItem(props: MenuItemProps) { - const ref = useRef(null); +function FocusableMenuItem({ref: forwardedRef, ...props}: MenuItemProps) { + const internalRef = useRef(null); // Sync focus on an item - useSyncFocus(ref, !!props.focused); + useSyncFocus(internalRef, !!props.focused); return ( ); } diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 3230362af21f..fe9b0e819255 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} fr 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 {AccessibilityInfo, Animated, DeviceEventEmitter, View} from 'react-native'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import NavigationBar from '@components/NavigationBar'; import {PressableWithoutFeedback} from '@components/Pressable'; @@ -20,7 +20,6 @@ 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'; @@ -30,8 +29,6 @@ import ModalContext from './ModalContext'; import ReanimatedModal from './ReanimatedModal'; import type BaseModalProps from './types'; -const MAX_DISMISS_BUTTON_FOCUS_RETRIES = 5; - function BaseModal({ isVisible, onClose, @@ -82,9 +79,9 @@ function BaseModal({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const isScreenReaderEnabled = Accessibility.useScreenReaderStatus(); + const isScreenReaderEnabledFromHook = Accessibility.useScreenReaderStatus(); + const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(isScreenReaderEnabledFromHook); 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 @@ -101,10 +98,26 @@ function BaseModal({ const shouldCallHideModalOnUnmount = useRef(false); const hideModalCallbackRef = useRef<(callHideCallback: boolean) => void>(undefined); - const dismissButtonRef = useRef(null); const wasVisible = usePrevious(isVisible); + useEffect(() => { + setIsScreenReaderEnabled(isScreenReaderEnabledFromHook); + }, [isScreenReaderEnabledFromHook]); + + useEffect(() => { + const isScreenReaderEnabledAsync = AccessibilityInfo.isScreenReaderEnabled; + if (!isScreenReaderEnabledAsync) { + return; + } + + isScreenReaderEnabledAsync() + .then((enabled) => { + setIsScreenReaderEnabled(enabled); + }) + .catch(() => {}); + }, []); + const uniqueModalId = useMemo(() => modalId ?? ComposerFocusManager.getId(), [modalId]); const saveFocusState = useCallback(() => { if (shouldEnableNewFocusManagement) { @@ -273,44 +286,6 @@ 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; - let frameID: number | undefined; - const focusDismissButton = () => { - const target = dismissButtonRef.current; - if (target && 'focus' in target && typeof target.focus === 'function') { - target.focus(); - return; - } - - if (retries >= MAX_DISMISS_BUTTON_FOCUS_RETRIES) { - return; - } - retries++; - frameID = requestAnimationFrame(focusDismissButton); - }; - - frameID = requestAnimationFrame(focusDismissButton); - - return () => { - if (frameID === undefined) { - return; - } - cancelAnimationFrame(frameID); - }; - }, [isWeb, isVisible, shouldShowBottomDockedDismissButton]); - const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ shouldAddBottomSafeAreaMargin, @@ -393,7 +368,7 @@ function BaseModal({ onSwipeComplete={onClose} swipeDirection={swipeDirection} shouldPreventScrollOnFocus={shouldPreventScrollOnFocus} - initialFocus={initialFocusTarget} + initialFocus={initialFocus} swipeThreshold={swipeThreshold} isVisible={isVisible} backdropColor={theme.overlay} @@ -427,10 +402,9 @@ function BaseModal({ > {shouldShowBottomDockedDismissButton && ( & { badgeStyle?: StyleProp; }; +const MAX_FIRST_MENU_ITEM_FOCUS_RETRIES = 5; + const renderWithConditionalWrapper = (shouldUseScrollView: boolean, contentContainerStyle: StyleProp, children: ReactNode): React.JSX.Element => { if (shouldUseScrollView) { return {children}; @@ -308,10 +311,23 @@ function BasePopoverMenu({ const [enteredSubMenuIndexes, setEnteredSubMenuIndexes] = useState(CONST.EMPTY_ARRAY); const platform = getPlatform(); const isWeb = platform === CONST.PLATFORM.WEB; + const isAndroid = platform === CONST.PLATFORM.ANDROID; + const isScreenReaderEnabled = Accessibility.useScreenReaderStatus(); + const firstMenuItemRef = useRef(null); + const isVisibleRef = useRef(isVisible); + const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: currentMenuItemsFocusedIndex, maxIndex: currentMenuItems.length - 1, isActive: isVisible}); const expensifyIcons = useMemoizedLazyExpensifyIcons(['BackArrow', 'ReceiptScan', 'MoneyCircle']); const prevMenuItems = usePrevious(menuItems); + useEffect(() => { + isVisibleRef.current = isVisible; + if (isVisible) { + return; + } + hasFocusedFirstItemOnCurrentOpenRef.current = false; + }, [isVisible]); + const selectItem = (index: number, event?: GestureResponderEvent | KeyboardEvent) => { const selectedItem = currentMenuItems.at(index); if (!selectedItem) { @@ -393,6 +409,7 @@ function BasePopoverMenu({ selectItem(menuIndex, event)} @@ -468,6 +485,107 @@ function BasePopoverMenu({ // can cause the parent view to scroll when the space bar is pressed. useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SPACE, keyboardShortcutSpaceCallback, {isActive: isWeb && isVisible, shouldPreventDefault: false}); + const focusFirstMenuItem = useCallback(() => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return false; + } + const focusTarget = () => { + const target = firstMenuItemRef.current; + if (!target) { + return false; + } + + if (isWeb) { + if ('focus' in target && typeof target.focus === 'function') { + target.focus(); + } + hasFocusedFirstItemOnCurrentOpenRef.current = true; + return true; + } + + const sendAccessibilityEvent = AccessibilityInfo.sendAccessibilityEvent; + if (sendAccessibilityEvent) { + if (isAndroid) { + sendAccessibilityEvent(target, 'viewHoverEnter'); + } + sendAccessibilityEvent(target, 'focus'); + hasFocusedFirstItemOnCurrentOpenRef.current = true; + return true; + } + + if ('focus' in target && typeof target.focus === 'function') { + target.focus(); + hasFocusedFirstItemOnCurrentOpenRef.current = true; + return true; + } + + return false; + }; + + return focusTarget(); + }, [isAndroid, isWeb]); + + const scheduleFocusFirstMenuItem = useCallback(() => { + const focusFirstMenuItemWithRetries = (retries = MAX_FIRST_MENU_ITEM_FOCUS_RETRIES) => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + if (focusFirstMenuItem()) { + return; + } + + if (retries <= 0) { + return; + } + + requestAnimationFrame(() => focusFirstMenuItemWithRetries(retries - 1)); + }; + + if (isWeb) { + requestAnimationFrame(() => focusFirstMenuItemWithRetries()); + return; + } + + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => focusFirstMenuItemWithRetries()); + }); + }, [focusFirstMenuItem, isWeb]); + + const handleModalShow = useCallback(() => { + onModalShow?.(); + + if (!isSmallScreenWidth) { + return; + } + + if (isScreenReaderEnabled) { + scheduleFocusFirstMenuItem(); + return; + } + + const isScreenReaderEnabledAsync = AccessibilityInfo.isScreenReaderEnabled; + if (!isScreenReaderEnabledAsync) { + return; + } + + isScreenReaderEnabledAsync() + .then((enabled) => { + if (!enabled || !isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + scheduleFocusFirstMenuItem(); + }) + .catch(() => {}); + }, [isScreenReaderEnabled, isSmallScreenWidth, onModalShow, scheduleFocusFirstMenuItem]); + + useEffect(() => { + if (!isVisible || !isSmallScreenWidth || !isScreenReaderEnabled || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + scheduleFocusFirstMenuItem(); + }, [isVisible, isSmallScreenWidth, isScreenReaderEnabled, scheduleFocusFirstMenuItem]); + const handleModalHide = () => { onModalHide?.(); const keyPath = buildKeyPathFromIndexPath(menuItems, enteredSubMenuIndexes); @@ -582,7 +700,7 @@ function BasePopoverMenu({ }} isVisible={isVisible} onModalHide={handleModalHide} - onModalShow={onModalShow} + onModalShow={handleModalShow} animationIn={animationIn} animationOut={animationOut} animationInDelay={animationInDelay} diff --git a/src/languages/de.ts b/src/languages/de.ts index 49fc229ddd75..87aeda4b9cf0 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1667,7 +1667,6 @@ 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 83a7f5a93e86..3071cc93a34e 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1688,7 +1688,6 @@ const translations = { }, modal: { backdropLabel: 'Modal Backdrop', - dismissDialog: 'Dismiss dialog', }, nextStep: { message: { diff --git a/src/languages/es.ts b/src/languages/es.ts index a1b031ca0fe1..be37b20ac138 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1517,7 +1517,6 @@ 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 0253fba92692..8b272af443ae 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1673,7 +1673,6 @@ 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 6dbe09e05bcc..47062d49a649 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1664,7 +1664,6 @@ 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 f2dfacd9791e..dc4dc18eb2c7 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1653,7 +1653,6 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: 'モーダルの背景', - dismissDialog: 'ダイアログを閉じる', }, nextStep: { message: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d66993c810f8..343e5b846cd4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1661,7 +1661,6 @@ 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 2b629209d24b..e780d643ea9f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1662,7 +1662,6 @@ 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 5070b3c4c92e..f66ac076c26f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1658,7 +1658,6 @@ 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 ac462a2ee527..994ad21c357e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1630,7 +1630,6 @@ const translations: TranslationDeepObject = { }, modal: { backdropLabel: '模态背景', - dismissDialog: '关闭对话框', }, nextStep: { message: { diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 133076ac0369..20f840633e3c 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -8,29 +8,9 @@ type HitSlop = {x: number; y: number}; const useScreenReaderStatus = (): boolean => { const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); useEffect(() => { - 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); - }); + const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', setIsScreenReaderEnabled); return () => { - isMounted = false; subscription?.remove(); }; }, []); diff --git a/src/styles/index.ts b/src/styles/index.ts index cdef51fa591e..9b798d3ec08e 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3587,7 +3587,7 @@ const staticStyles = (theme: ThemeColors) => left: 0, right: 0, height: variables.iconSizeXSmall, - backgroundColor: 'transparent', + backgroundColor: theme.transparent, zIndex: 1, }, From ce736f04a40c6ec77f98946c2a174688adf37bec Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 4 Mar 2026 22:54:50 +0430 Subject: [PATCH 07/36] fixed to focus the first item of the bottom docked once opened in ios --- src/components/Modal/BaseModal.tsx | 26 ++----------- src/components/PopoverMenu.tsx | 37 +++++++------------ src/libs/Accessibility/index.ts | 13 +++++++ .../moveAccessibilityFocus/index.native.ts | 11 ++++++ 4 files changed, 41 insertions(+), 46 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index fe9b0e819255..b059677f199f 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} fr import type {GestureResponderEvent, LayoutChangeEvent} from 'react-native'; // Animated required for side panel navigation // eslint-disable-next-line no-restricted-imports -import {AccessibilityInfo, Animated, DeviceEventEmitter, View} from 'react-native'; +import {Animated, DeviceEventEmitter, View} from 'react-native'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import NavigationBar from '@components/NavigationBar'; import {PressableWithoutFeedback} from '@components/Pressable'; @@ -17,7 +17,6 @@ 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'; @@ -79,8 +78,6 @@ function BaseModal({ const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const isScreenReaderEnabledFromHook = Accessibility.useScreenReaderStatus(); - const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(isScreenReaderEnabledFromHook); const {windowWidth, windowHeight} = useWindowDimensions(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct modal width const canUseTouchScreen = canUseTouchScreenCheck(); @@ -101,23 +98,6 @@ function BaseModal({ const wasVisible = usePrevious(isVisible); - useEffect(() => { - setIsScreenReaderEnabled(isScreenReaderEnabledFromHook); - }, [isScreenReaderEnabledFromHook]); - - useEffect(() => { - const isScreenReaderEnabledAsync = AccessibilityInfo.isScreenReaderEnabled; - if (!isScreenReaderEnabledAsync) { - return; - } - - isScreenReaderEnabledAsync() - .then((enabled) => { - setIsScreenReaderEnabled(enabled); - }) - .catch(() => {}); - }, []); - const uniqueModalId = useMemo(() => modalId ?? ComposerFocusManager.getId(), [modalId]); const saveFocusState = useCallback(() => { if (shouldEnableNewFocusManagement) { @@ -284,7 +264,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); const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ @@ -400,6 +380,7 @@ function BaseModal({ ref={ref} fsClass={forwardedFSClass} > + {children} {shouldShowBottomDockedDismissButton && ( )} - {children} {!keyboardStateContextValue?.isKeyboardActive && } diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index d04b776f7e8f..fd20a70565f5 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -2,7 +2,7 @@ import {deepEqual} from 'fast-equals'; import type {ReactNode, RefObject} from 'react'; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import {AccessibilityInfo, InteractionManager, StyleSheet, View} from 'react-native'; +import {AccessibilityInfo, findNodeHandle, InteractionManager, StyleSheet, View} from 'react-native'; import type {GestureResponderEvent, LayoutChangeEvent, View as RNView, StyleProp, TextStyle, ViewStyle} from 'react-native'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -312,7 +312,6 @@ function BasePopoverMenu({ const platform = getPlatform(); const isWeb = platform === CONST.PLATFORM.WEB; const isAndroid = platform === CONST.PLATFORM.ANDROID; - const isScreenReaderEnabled = Accessibility.useScreenReaderStatus(); const firstMenuItemRef = useRef(null); const isVisibleRef = useRef(isVisible); const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); @@ -507,8 +506,17 @@ function BasePopoverMenu({ if (sendAccessibilityEvent) { if (isAndroid) { sendAccessibilityEvent(target, 'viewHoverEnter'); + sendAccessibilityEvent(target, 'focus'); + hasFocusedFirstItemOnCurrentOpenRef.current = true; + return true; } sendAccessibilityEvent(target, 'focus'); + } + + const nodeHandle = findNodeHandle(target); + const setAccessibilityFocus = typeof AccessibilityInfo.setAccessibilityFocus === 'function' ? AccessibilityInfo.setAccessibilityFocus : undefined; // @ts-expect-error - not typed in RN + if (nodeHandle && setAccessibilityFocus) { + setTimeout(() => setAccessibilityFocus(nodeHandle), 100); hasFocusedFirstItemOnCurrentOpenRef.current = true; return true; } @@ -559,32 +567,15 @@ function BasePopoverMenu({ return; } - if (isScreenReaderEnabled) { - scheduleFocusFirstMenuItem(); - return; - } - - const isScreenReaderEnabledAsync = AccessibilityInfo.isScreenReaderEnabled; - if (!isScreenReaderEnabledAsync) { - return; - } - - isScreenReaderEnabledAsync() - .then((enabled) => { - if (!enabled || !isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { - return; - } - scheduleFocusFirstMenuItem(); - }) - .catch(() => {}); - }, [isScreenReaderEnabled, isSmallScreenWidth, onModalShow, scheduleFocusFirstMenuItem]); + scheduleFocusFirstMenuItem(); + }, [isSmallScreenWidth, onModalShow, scheduleFocusFirstMenuItem]); useEffect(() => { - if (!isVisible || !isSmallScreenWidth || !isScreenReaderEnabled || hasFocusedFirstItemOnCurrentOpenRef.current) { + if (!isVisible || !isSmallScreenWidth || hasFocusedFirstItemOnCurrentOpenRef.current) { return; } scheduleFocusFirstMenuItem(); - }, [isVisible, isSmallScreenWidth, isScreenReaderEnabled, scheduleFocusFirstMenuItem]); + }, [isVisible, isSmallScreenWidth, scheduleFocusFirstMenuItem]); const handleModalHide = () => { onModalHide?.(); diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 20f840633e3c..1907374334c2 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -8,9 +8,22 @@ type HitSlop = {x: number; y: number}; const useScreenReaderStatus = (): boolean => { const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); useEffect(() => { + let isMounted = true; + const isScreenReaderEnabledAsync = AccessibilityInfo.isScreenReaderEnabled; + if (isScreenReaderEnabledAsync) { + isScreenReaderEnabledAsync() + .then((enabled) => { + if (!isMounted) { + return; + } + setIsScreenReaderEnabled(enabled); + }) + .catch(() => {}); + } const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', setIsScreenReaderEnabled); return () => { + isMounted = false; subscription?.remove(); }; }, []); diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts index 71c249cf1d10..571f0d079414 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts @@ -1,4 +1,5 @@ import {AccessibilityInfo} from 'react-native'; +import findNodeHandle from '@src/utils/findNodeHandle'; import type MoveAccessibilityFocus from './types'; const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { @@ -6,6 +7,16 @@ const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { return; } + // iOS uses setAccessibilityFocus with a native node handle. + const nodeHandle = typeof ref === 'number' ? ref : findNodeHandle(ref); + if (nodeHandle) { + if (typeof AccessibilityInfo.setAccessibilityFocus === 'function') { + AccessibilityInfo.setAccessibilityFocus(nodeHandle); + return; + } + } + + // Android uses sendAccessibilityEvent. AccessibilityInfo.sendAccessibilityEvent(ref, 'focus'); }; From 4f8b7aa07a15e4b1e3070fe85bdefe4cae36627f Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 4 Mar 2026 23:11:39 +0430 Subject: [PATCH 08/36] fixed on ios - web --- src/components/Modal/BaseModal.tsx | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index b059677f199f..d16af4afd5e4 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -19,6 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; 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'; @@ -88,8 +89,11 @@ function BaseModal({ const sidePanelAnimatedStyle = shouldApplySidePanelOffset && !isSmallScreenWidth ? {transform: [{translateX: Animated.multiply(sidePanelOffset.current, -1)}]} : undefined; const keyboardStateContextValue = useKeyboardState(); + const isWeb = getPlatform() === CONST.PLATFORM.WEB; + const [modalOverlapsWithTopSafeArea, setModalOverlapsWithTopSafeArea] = useState(false); const [modalHeight, setModalHeight] = useState(0); + const dismissRef = useRef(null); const insets = useSafeAreaInsets(); @@ -380,8 +384,22 @@ function BaseModal({ ref={ref} fsClass={forwardedFSClass} > + {isWeb && shouldShowBottomDockedDismissButton && ( + + + + )} {children} - {shouldShowBottomDockedDismissButton && ( + {!isWeb && shouldShowBottomDockedDismissButton && ( Date: Fri, 6 Mar 2026 17:17:12 +0430 Subject: [PATCH 09/36] fixed type and lint errors. --- src/components/PopoverMenu.tsx | 9 ++++----- src/libs/Accessibility/index.ts | 13 ------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index fd20a70565f5..1fac792a0163 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -2,7 +2,7 @@ import {deepEqual} from 'fast-equals'; import type {ReactNode, RefObject} from 'react'; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import {AccessibilityInfo, findNodeHandle, InteractionManager, StyleSheet, View} from 'react-native'; +import {AccessibilityInfo, findNodeHandle, StyleSheet, View} from 'react-native'; import type {GestureResponderEvent, LayoutChangeEvent, View as RNView, StyleProp, TextStyle, ViewStyle} from 'react-native'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -13,7 +13,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import Accessibility from '@libs/Accessibility'; import {isSafari} from '@libs/Browser'; import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; @@ -514,7 +513,7 @@ function BasePopoverMenu({ } const nodeHandle = findNodeHandle(target); - const setAccessibilityFocus = typeof AccessibilityInfo.setAccessibilityFocus === 'function' ? AccessibilityInfo.setAccessibilityFocus : undefined; // @ts-expect-error - not typed in RN + const setAccessibilityFocus = typeof AccessibilityInfo.setAccessibilityFocus === 'function' ? AccessibilityInfo.setAccessibilityFocus : undefined; if (nodeHandle && setAccessibilityFocus) { setTimeout(() => setAccessibilityFocus(nodeHandle), 100); hasFocusedFirstItemOnCurrentOpenRef.current = true; @@ -555,9 +554,9 @@ function BasePopoverMenu({ return; } - InteractionManager.runAfterInteractions(() => { + setTimeout(() => { requestAnimationFrame(() => focusFirstMenuItemWithRetries()); - }); + }, 0); }, [focusFirstMenuItem, isWeb]); const handleModalShow = useCallback(() => { diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts index 1907374334c2..20f840633e3c 100644 --- a/src/libs/Accessibility/index.ts +++ b/src/libs/Accessibility/index.ts @@ -8,22 +8,9 @@ type HitSlop = {x: number; y: number}; const useScreenReaderStatus = (): boolean => { const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); useEffect(() => { - let isMounted = true; - const isScreenReaderEnabledAsync = AccessibilityInfo.isScreenReaderEnabled; - if (isScreenReaderEnabledAsync) { - isScreenReaderEnabledAsync() - .then((enabled) => { - if (!isMounted) { - return; - } - setIsScreenReaderEnabled(enabled); - }) - .catch(() => {}); - } const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', setIsScreenReaderEnabled); return () => { - isMounted = false; subscription?.remove(); }; }, []); From e6b75b31ce0a6c142b3c030a619b11cb2db40716 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 6 Mar 2026 17:45:54 +0430 Subject: [PATCH 10/36] Fix accessibility focus typing and lint issues --- src/components/PopoverMenu.tsx | 25 ++++--------------- .../moveAccessibilityFocus/index.native.ts | 20 ++++++--------- .../moveAccessibilityFocus/types.ts | 10 +++++--- 3 files changed, 20 insertions(+), 35 deletions(-) diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 1fac792a0163..daa99f666956 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -2,7 +2,7 @@ import {deepEqual} from 'fast-equals'; import type {ReactNode, RefObject} from 'react'; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import {AccessibilityInfo, findNodeHandle, StyleSheet, View} from 'react-native'; +import {AccessibilityInfo, StyleSheet, View} from 'react-native'; import type {GestureResponderEvent, LayoutChangeEvent, View as RNView, StyleProp, TextStyle, ViewStyle} from 'react-native'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -13,6 +13,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import Accessibility from '@libs/Accessibility'; import {isSafari} from '@libs/Browser'; import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; @@ -505,28 +506,12 @@ function BasePopoverMenu({ if (sendAccessibilityEvent) { if (isAndroid) { sendAccessibilityEvent(target, 'viewHoverEnter'); - sendAccessibilityEvent(target, 'focus'); - hasFocusedFirstItemOnCurrentOpenRef.current = true; - return true; } - sendAccessibilityEvent(target, 'focus'); } - const nodeHandle = findNodeHandle(target); - const setAccessibilityFocus = typeof AccessibilityInfo.setAccessibilityFocus === 'function' ? AccessibilityInfo.setAccessibilityFocus : undefined; - if (nodeHandle && setAccessibilityFocus) { - setTimeout(() => setAccessibilityFocus(nodeHandle), 100); - hasFocusedFirstItemOnCurrentOpenRef.current = true; - return true; - } - - if ('focus' in target && typeof target.focus === 'function') { - target.focus(); - hasFocusedFirstItemOnCurrentOpenRef.current = true; - return true; - } - - return false; + Accessibility.moveAccessibilityFocus(firstMenuItemRef); + hasFocusedFirstItemOnCurrentOpenRef.current = true; + return true; }; return focusTarget(); diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts index 571f0d079414..ac36ddec4fcd 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts @@ -1,23 +1,19 @@ import {AccessibilityInfo} from 'react-native'; -import findNodeHandle from '@src/utils/findNodeHandle'; +import type {NativeMethods} from 'react-native'; import type MoveAccessibilityFocus from './types'; const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { - if (!ref) { + const focusTarget = ref && 'current' in ref ? ref.current : ref; + + if (!focusTarget) { return; } - // iOS uses setAccessibilityFocus with a native node handle. - const nodeHandle = typeof ref === 'number' ? ref : findNodeHandle(ref); - if (nodeHandle) { - if (typeof AccessibilityInfo.setAccessibilityFocus === 'function') { - AccessibilityInfo.setAccessibilityFocus(nodeHandle); - return; - } - } + AccessibilityInfo.sendAccessibilityEvent(focusTarget as NativeMethods, 'focus'); - // Android uses sendAccessibilityEvent. - AccessibilityInfo.sendAccessibilityEvent(ref, 'focus'); + if ('focus' in focusTarget && typeof focusTarget.focus === 'function') { + focusTarget.focus(); + } }; export default moveAccessibilityFocus; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/types.ts b/src/libs/Accessibility/moveAccessibilityFocus/types.ts index 6756bdd6f773..140eb87d19d6 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/types.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/types.ts @@ -1,6 +1,10 @@ -import type {ElementRef, RefObject} from 'react'; -import type {HostComponent} from 'react-native'; +import type {RefObject} from 'react'; +import type {NativeMethods} from 'react-native'; -type MoveAccessibilityFocus = (ref?: ElementRef> & RefObject) => void; +type AccessibilityFocusable = NativeMethods | HTMLOrSVGElement; + +type AccessibilityFocusableRef = RefObject; + +type MoveAccessibilityFocus = (ref?: AccessibilityFocusable | AccessibilityFocusableRef) => void; export default MoveAccessibilityFocus; From 671dc39ccbf12420ecac5d7f7ce4ab8ee698e7ae Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 6 Mar 2026 18:15:45 +0430 Subject: [PATCH 11/36] fix(a11y): delay iOS dismiss accessibility until menu item focus --- src/components/Modal/BaseModal.tsx | 35 ++++++- src/components/PopoverMenu.tsx | 97 ++++++++++++------- .../moveAccessibilityFocus/index.ts | 9 +- 3 files changed, 105 insertions(+), 36 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index d16af4afd5e4..4ad7245dbaa4 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} fr 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 {Animated, DeviceEventEmitter, InteractionManager, View} from 'react-native'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import NavigationBar from '@components/NavigationBar'; import {PressableWithoutFeedback} from '@components/Pressable'; @@ -90,9 +90,11 @@ function BaseModal({ const keyboardStateContextValue = useKeyboardState(); const isWeb = getPlatform() === CONST.PLATFORM.WEB; + const isNativeIOS = getPlatform() === CONST.PLATFORM.IOS; const [modalOverlapsWithTopSafeArea, setModalOverlapsWithTopSafeArea] = useState(false); const [modalHeight, setModalHeight] = useState(0); + const [isBottomDockedDismissAccessible, setIsBottomDockedDismissAccessible] = useState(true); const dismissRef = useRef(null); const insets = useSafeAreaInsets(); @@ -269,6 +271,34 @@ function BaseModal({ ); const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose); + const shouldDelayBottomDockedDismissAccessibility = isNativeIOS && shouldShowBottomDockedDismissButton; + + useEffect(() => { + if (!shouldDelayBottomDockedDismissAccessibility) { + setIsBottomDockedDismissAccessible(true); + return; + } + + if (!isVisible) { + setIsBottomDockedDismissAccessible(false); + return; + } + + setIsBottomDockedDismissAccessible(false); + let animationFrameID = 0; + const interactionHandle = InteractionManager.runAfterInteractions(() => { + animationFrameID = requestAnimationFrame(() => { + setIsBottomDockedDismissAccessible(true); + }); + }); + + return () => { + if (animationFrameID) { + cancelAnimationFrame(animationFrameID); + } + interactionHandle.cancel(); + }; + }, [isVisible, shouldDelayBottomDockedDismissAccessibility]); const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ @@ -402,8 +432,11 @@ function BaseModal({ {!isWeb && shouldShowBottomDockedDismissButton && ( (null); const isVisibleRef = useRef(isVisible); const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); @@ -484,46 +485,55 @@ function BasePopoverMenu({ // can cause the parent view to scroll when the space bar is pressed. useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SPACE, keyboardShortcutSpaceCallback, {isActive: isWeb && isVisible, shouldPreventDefault: false}); - const focusFirstMenuItem = useCallback(() => { + const focusFirstMenuItemOnWeb = useCallback(() => { if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { return false; } - const focusTarget = () => { - const target = firstMenuItemRef.current; - if (!target) { - return false; - } + const target = firstMenuItemRef.current; + if (!target || !('focus' in target) || typeof target.focus !== 'function') { + return false; + } - if (isWeb) { - if ('focus' in target && typeof target.focus === 'function') { - target.focus(); - } - hasFocusedFirstItemOnCurrentOpenRef.current = true; - return true; - } + target.focus(); + hasFocusedFirstItemOnCurrentOpenRef.current = true; + return true; + }, []); - const sendAccessibilityEvent = AccessibilityInfo.sendAccessibilityEvent; - if (sendAccessibilityEvent) { - if (isAndroid) { - sendAccessibilityEvent(target, 'viewHoverEnter'); - } - } + const focusFirstMenuItemOnNative = useCallback(() => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return false; + } - Accessibility.moveAccessibilityFocus(firstMenuItemRef); - hasFocusedFirstItemOnCurrentOpenRef.current = true; - return true; - }; + const target = firstMenuItemRef.current; + if (!target) { + return false; + } - return focusTarget(); - }, [isAndroid, isWeb]); + const sendAccessibilityEvent = AccessibilityInfo.sendAccessibilityEvent; + if (sendAccessibilityEvent && isAndroid) { + sendAccessibilityEvent(target, 'viewHoverEnter'); + } - const scheduleFocusFirstMenuItem = useCallback(() => { + Accessibility.moveAccessibilityFocus(firstMenuItemRef); + hasFocusedFirstItemOnCurrentOpenRef.current = true; + return true; + }, [isAndroid]); + + const focusFirstMenuItem = useCallback(() => { + if (isWeb) { + return focusFirstMenuItemOnWeb(); + } + + return focusFirstMenuItemOnNative(); + }, [focusFirstMenuItemOnNative, focusFirstMenuItemOnWeb, isWeb]); + + const scheduleFocusFirstMenuItemOnWeb = useCallback(() => { const focusFirstMenuItemWithRetries = (retries = MAX_FIRST_MENU_ITEM_FOCUS_RETRIES) => { if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { return; } - if (focusFirstMenuItem()) { + if (focusFirstMenuItemOnWeb()) { return; } @@ -534,15 +544,36 @@ function BasePopoverMenu({ requestAnimationFrame(() => focusFirstMenuItemWithRetries(retries - 1)); }; + requestAnimationFrame(() => focusFirstMenuItemWithRetries()); + }, [focusFirstMenuItemOnWeb]); + + const scheduleFocusFirstMenuItemOnNative = useCallback(() => { + const focusTarget = () => { + requestAnimationFrame(() => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + focusFirstMenuItem(); + }); + }; + + if (isIOS) { + InteractionManager.runAfterInteractions(focusTarget); + return; + } + + setTimeout(focusTarget, 0); + }, [focusFirstMenuItem, isIOS]); + + const scheduleFocusFirstMenuItem = useCallback(() => { if (isWeb) { - requestAnimationFrame(() => focusFirstMenuItemWithRetries()); + scheduleFocusFirstMenuItemOnWeb(); return; } - setTimeout(() => { - requestAnimationFrame(() => focusFirstMenuItemWithRetries()); - }, 0); - }, [focusFirstMenuItem, isWeb]); + scheduleFocusFirstMenuItemOnNative(); + }, [isWeb, scheduleFocusFirstMenuItemOnNative, scheduleFocusFirstMenuItemOnWeb]); const handleModalShow = useCallback(() => { onModalShow?.(); diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.ts b/src/libs/Accessibility/moveAccessibilityFocus/index.ts index cafe1a216db3..884c02aed85a 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.ts @@ -1,10 +1,15 @@ import type MoveAccessibilityFocus from './types'; const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { - if (!ref?.current) { + const focusTarget = ref && 'current' in ref ? ref.current : ref; + + if (!focusTarget) { return; } - ref.current.focus(); + + if ('focus' in focusTarget && typeof focusTarget.focus === 'function') { + focusTarget.focus(); + } }; export default moveAccessibilityFocus; From 05e7161dbf39f8f1e1db55a67e19a9eea924b3fd Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sat, 7 Mar 2026 14:30:54 +0430 Subject: [PATCH 12/36] fix(lint): replace deprecated runAfterInteractions usage --- src/components/Modal/BaseModal.tsx | 11 ++++++----- src/components/PopoverMenu.tsx | 11 +++-------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 4ad7245dbaa4..9466c7a66d82 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} fr import type {GestureResponderEvent, LayoutChangeEvent} from 'react-native'; // Animated required for side panel navigation // eslint-disable-next-line no-restricted-imports -import {Animated, DeviceEventEmitter, InteractionManager, View} from 'react-native'; +import {Animated, DeviceEventEmitter, View} from 'react-native'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import NavigationBar from '@components/NavigationBar'; import {PressableWithoutFeedback} from '@components/Pressable'; @@ -272,6 +272,7 @@ function BaseModal({ const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose); const shouldDelayBottomDockedDismissAccessibility = isNativeIOS && shouldShowBottomDockedDismissButton; + const dismissAccessibilityDelay = (animationInDelay ?? 0) + (animationInTiming ?? CONST.MODAL.ANIMATION_TIMING.DEFAULT_IN); useEffect(() => { if (!shouldDelayBottomDockedDismissAccessibility) { @@ -286,19 +287,19 @@ function BaseModal({ setIsBottomDockedDismissAccessible(false); let animationFrameID = 0; - const interactionHandle = InteractionManager.runAfterInteractions(() => { + const timeoutID = setTimeout(() => { animationFrameID = requestAnimationFrame(() => { setIsBottomDockedDismissAccessible(true); }); - }); + }, dismissAccessibilityDelay); return () => { if (animationFrameID) { cancelAnimationFrame(animationFrameID); } - interactionHandle.cancel(); + clearTimeout(timeoutID); }; - }, [isVisible, shouldDelayBottomDockedDismissAccessibility]); + }, [dismissAccessibilityDelay, isVisible, shouldDelayBottomDockedDismissAccessibility]); const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 667669832f82..477e62851892 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -2,7 +2,7 @@ import {deepEqual} from 'fast-equals'; import type {ReactNode, RefObject} from 'react'; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import {AccessibilityInfo, InteractionManager, StyleSheet, View} from 'react-native'; +import {AccessibilityInfo, StyleSheet, View} from 'react-native'; import type {GestureResponderEvent, LayoutChangeEvent, View as RNView, StyleProp, TextStyle, ViewStyle} from 'react-native'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -558,13 +558,8 @@ function BasePopoverMenu({ }); }; - if (isIOS) { - InteractionManager.runAfterInteractions(focusTarget); - return; - } - - setTimeout(focusTarget, 0); - }, [focusFirstMenuItem, isIOS]); + setTimeout(focusTarget, isIOS ? (animationInDelay ?? 0) + animationInTiming : 0); + }, [animationInDelay, animationInTiming, focusFirstMenuItem, isIOS]); const scheduleFocusFirstMenuItem = useCallback(() => { if (isWeb) { From 054369ad9372b7f6b0bbc7b3ab9608bea76655b9 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sat, 7 Mar 2026 19:24:42 +0430 Subject: [PATCH 13/36] fix(a11y): gate iOS dismiss until first popover item focuses --- src/components/Modal/BaseModal.tsx | 40 ++++-------------------------- src/components/Modal/types.ts | 6 +++++ src/components/PopoverMenu.tsx | 30 +++++++++++++++++++--- 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 9466c7a66d82..46d2d6e397af 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -72,6 +72,7 @@ function BaseModal({ forwardedFSClass = CONST.FULLSTORY.CLASS.UNMASK, ref, shouldDisplayBelowModals = false, + isBottomDockedDismissAccessible, }: BaseModalProps) { // When the `enableEdgeToEdgeBottomSafeAreaPadding` prop is explicitly set, we enable edge-to-edge mode. const isUsingEdgeToEdgeMode = enableEdgeToEdgeBottomSafeAreaPadding !== undefined; @@ -94,8 +95,6 @@ function BaseModal({ const [modalOverlapsWithTopSafeArea, setModalOverlapsWithTopSafeArea] = useState(false); const [modalHeight, setModalHeight] = useState(0); - const [isBottomDockedDismissAccessible, setIsBottomDockedDismissAccessible] = useState(true); - const dismissRef = useRef(null); const insets = useSafeAreaInsets(); @@ -271,35 +270,7 @@ function BaseModal({ ); const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose); - const shouldDelayBottomDockedDismissAccessibility = isNativeIOS && shouldShowBottomDockedDismissButton; - const dismissAccessibilityDelay = (animationInDelay ?? 0) + (animationInTiming ?? CONST.MODAL.ANIMATION_TIMING.DEFAULT_IN); - - useEffect(() => { - if (!shouldDelayBottomDockedDismissAccessibility) { - setIsBottomDockedDismissAccessible(true); - return; - } - - if (!isVisible) { - setIsBottomDockedDismissAccessible(false); - return; - } - - setIsBottomDockedDismissAccessible(false); - let animationFrameID = 0; - const timeoutID = setTimeout(() => { - animationFrameID = requestAnimationFrame(() => { - setIsBottomDockedDismissAccessible(true); - }); - }, dismissAccessibilityDelay); - - return () => { - if (animationFrameID) { - cancelAnimationFrame(animationFrameID); - } - clearTimeout(timeoutID); - }; - }, [dismissAccessibilityDelay, isVisible, shouldDelayBottomDockedDismissAccessibility]); + const isNativeIOSBottomDockedDismissAccessible = !isNativeIOS || !shouldShowBottomDockedDismissButton || isBottomDockedDismissAccessible === true; const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ @@ -420,7 +391,6 @@ function BaseModal({ onPress={handleBackdropPress} accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.dismiss')} - ref={dismissRef} tabIndex={0} sentryLabel="Modal-DismissDialog" style={styles.bottomDockedModalDismissButton} @@ -433,11 +403,11 @@ function BaseModal({ {!isWeb && shouldShowBottomDockedDismissButton && ( & * Whether the modal should display under the side panel. */ shouldDisplayBelowModals?: boolean; + + /** + * Internal accessibility handshake for bottom-docked popovers on iOS. + * When false, the dismiss control stays hidden from accessibility until the first actionable item is focused. + */ + isBottomDockedDismissAccessible?: boolean; }; export default BaseModalProps; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 477e62851892..16f2ad4571a0 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -14,7 +14,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Accessibility from '@libs/Accessibility'; -import {isSafari} from '@libs/Browser'; +import {isMobileSafari, isSafari} from '@libs/Browser'; import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; import {close} from '@userActions/Modal'; @@ -313,9 +313,11 @@ function BasePopoverMenu({ const isWeb = platform === CONST.PLATFORM.WEB; const isAndroid = platform === CONST.PLATFORM.ANDROID; const isIOS = platform === CONST.PLATFORM.IOS; + const shouldCoordinateDismissAccessibility = isIOS || isMobileSafari(); const firstMenuItemRef = useRef(null); const isVisibleRef = useRef(isVisible); const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); + const [isBottomDockedDismissAccessible, setIsBottomDockedDismissAccessible] = useState(!shouldCoordinateDismissAccessibility); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: currentMenuItemsFocusedIndex, maxIndex: currentMenuItems.length - 1, isActive: isVisible}); const expensifyIcons = useMemoizedLazyExpensifyIcons(['BackArrow', 'ReceiptScan', 'MoneyCircle']); const prevMenuItems = usePrevious(menuItems); @@ -323,10 +325,23 @@ function BasePopoverMenu({ useEffect(() => { isVisibleRef.current = isVisible; if (isVisible) { + if (shouldCoordinateDismissAccessibility) { + setIsBottomDockedDismissAccessible(false); + } return; } hasFocusedFirstItemOnCurrentOpenRef.current = false; - }, [isVisible]); + if (shouldCoordinateDismissAccessibility) { + setIsBottomDockedDismissAccessible(false); + } + }, [isVisible, shouldCoordinateDismissAccessibility]); + + useEffect(() => { + if (shouldCoordinateDismissAccessibility) { + return; + } + setIsBottomDockedDismissAccessible(true); + }, [shouldCoordinateDismissAccessibility]); const selectItem = (index: number, event?: GestureResponderEvent | KeyboardEvent) => { const selectedItem = currentMenuItems.at(index); @@ -496,8 +511,11 @@ function BasePopoverMenu({ target.focus(); hasFocusedFirstItemOnCurrentOpenRef.current = true; + if (shouldCoordinateDismissAccessibility) { + setIsBottomDockedDismissAccessible(true); + } return true; - }, []); + }, [shouldCoordinateDismissAccessibility]); const focusFirstMenuItemOnNative = useCallback(() => { if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { @@ -516,8 +534,11 @@ function BasePopoverMenu({ Accessibility.moveAccessibilityFocus(firstMenuItemRef); hasFocusedFirstItemOnCurrentOpenRef.current = true; + if (shouldCoordinateDismissAccessibility) { + setIsBottomDockedDismissAccessible(true); + } return true; - }, [isAndroid]); + }, [isAndroid, shouldCoordinateDismissAccessibility]); const focusFirstMenuItem = useCallback(() => { if (isWeb) { @@ -713,6 +734,7 @@ function BasePopoverMenu({ shouldSetModalVisibility={shouldSetModalVisibility} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} restoreFocusType={restoreFocusType} + isBottomDockedDismissAccessible={shouldCoordinateDismissAccessibility ? isBottomDockedDismissAccessible : undefined} innerContainerStyle={{...styles.pv0, ...innerContainerStyle}} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} testID={testID} From f6a1939097b2bb222b13526eb37dc02151bc624b Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sat, 14 Mar 2026 09:25:56 +0430 Subject: [PATCH 14/36] Refactor PopoverMenu focus handling; clarify bottom-docked dismiss a11y naming --- src/components/Modal/BaseModal.tsx | 10 ++--- src/components/Modal/types.ts | 5 ++- src/components/PopoverMenu.tsx | 60 +++++++++++++++--------------- 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 46d2d6e397af..469f35830ac2 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -72,7 +72,7 @@ function BaseModal({ forwardedFSClass = CONST.FULLSTORY.CLASS.UNMASK, ref, shouldDisplayBelowModals = false, - isBottomDockedDismissAccessible, + shouldEnableBottomDockedDismissAccessibility, }: BaseModalProps) { // When the `enableEdgeToEdgeBottomSafeAreaPadding` prop is explicitly set, we enable edge-to-edge mode. const isUsingEdgeToEdgeMode = enableEdgeToEdgeBottomSafeAreaPadding !== undefined; @@ -270,7 +270,7 @@ function BaseModal({ ); const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose); - const isNativeIOSBottomDockedDismissAccessible = !isNativeIOS || !shouldShowBottomDockedDismissButton || isBottomDockedDismissAccessible === true; + const shouldHideBottomDockedDismissFromAccessibility = isNativeIOS && shouldShowBottomDockedDismissButton && shouldEnableBottomDockedDismissAccessibility === false; const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ @@ -403,11 +403,11 @@ function BaseModal({ {!isWeb && shouldShowBottomDockedDismissButton && ( & /** * Internal accessibility handshake for bottom-docked popovers on iOS. - * When false, the dismiss control stays hidden from accessibility until the first actionable item is focused. + * When `false`, the dismiss control stays hidden from accessibility until the first actionable item is focused. + * When `true` or `undefined`, the dismiss control is exposed to accessibility. */ - isBottomDockedDismissAccessible?: boolean; + shouldEnableBottomDockedDismissAccessibility?: boolean; }; export default BaseModalProps; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 95abfc90fd94..4be33adf7f87 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -316,11 +316,11 @@ function BasePopoverMenu({ const isWeb = platform === CONST.PLATFORM.WEB; const isAndroid = platform === CONST.PLATFORM.ANDROID; const isIOS = platform === CONST.PLATFORM.IOS; - const shouldCoordinateDismissAccessibility = isIOS || isMobileSafari(); + const shouldDeferDismissButtonAccessibility = isIOS || isMobileSafari(); const firstMenuItemRef = useRef(null); const isVisibleRef = useRef(isVisible); const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); - const [isBottomDockedDismissAccessible, setIsBottomDockedDismissAccessible] = useState(!shouldCoordinateDismissAccessibility); + const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(!shouldDeferDismissButtonAccessibility); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: currentMenuItemsFocusedIndex, maxIndex: currentMenuItems.length - 1, isActive: isVisible}); const expensifyIcons = useMemoizedLazyExpensifyIcons(['BackArrow', 'ReceiptScan', 'MoneyCircle']); const prevMenuItems = usePrevious(menuItems); @@ -328,23 +328,23 @@ function BasePopoverMenu({ useEffect(() => { isVisibleRef.current = isVisible; if (isVisible) { - if (shouldCoordinateDismissAccessibility) { - setIsBottomDockedDismissAccessible(false); + if (shouldDeferDismissButtonAccessibility) { + setShouldEnableBottomDockedDismissAccessibility(false); } return; } hasFocusedFirstItemOnCurrentOpenRef.current = false; - if (shouldCoordinateDismissAccessibility) { - setIsBottomDockedDismissAccessible(false); + if (shouldDeferDismissButtonAccessibility) { + setShouldEnableBottomDockedDismissAccessibility(false); } - }, [isVisible, shouldCoordinateDismissAccessibility]); + }, [isVisible, shouldDeferDismissButtonAccessibility]); useEffect(() => { - if (shouldCoordinateDismissAccessibility) { + if (shouldDeferDismissButtonAccessibility) { return; } - setIsBottomDockedDismissAccessible(true); - }, [shouldCoordinateDismissAccessibility]); + setShouldEnableBottomDockedDismissAccessibility(true); + }, [shouldDeferDismissButtonAccessibility]); const selectItem = (index: number, event?: GestureResponderEvent | KeyboardEvent) => { const selectedItem = currentMenuItems.at(index); @@ -514,29 +514,34 @@ function BasePopoverMenu({ // can cause the parent view to scroll when the space bar is pressed. useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SPACE, keyboardShortcutSpaceCallback, {isActive: isWeb && isVisible, shouldPreventDefault: false}); - const focusFirstMenuItemOnWeb = useCallback(() => { + const getFirstMenuItemTarget = useCallback(() => { if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { - return false; + return null; + } + + return firstMenuItemRef.current; + }, []); + + const markFirstMenuItemFocused = useCallback(() => { + hasFocusedFirstItemOnCurrentOpenRef.current = true; + if (shouldDeferDismissButtonAccessibility) { + setShouldEnableBottomDockedDismissAccessibility(true); } - const target = firstMenuItemRef.current; + }, [shouldDeferDismissButtonAccessibility]); + + const focusFirstMenuItemOnWeb = useCallback(() => { + const target = getFirstMenuItemTarget(); if (!target || !('focus' in target) || typeof target.focus !== 'function') { return false; } target.focus(); - hasFocusedFirstItemOnCurrentOpenRef.current = true; - if (shouldCoordinateDismissAccessibility) { - setIsBottomDockedDismissAccessible(true); - } + markFirstMenuItemFocused(); return true; - }, [shouldCoordinateDismissAccessibility]); + }, [getFirstMenuItemTarget, markFirstMenuItemFocused]); const focusFirstMenuItemOnNative = useCallback(() => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { - return false; - } - - const target = firstMenuItemRef.current; + const target = getFirstMenuItemTarget(); if (!target) { return false; } @@ -547,12 +552,9 @@ function BasePopoverMenu({ } Accessibility.moveAccessibilityFocus(firstMenuItemRef); - hasFocusedFirstItemOnCurrentOpenRef.current = true; - if (shouldCoordinateDismissAccessibility) { - setIsBottomDockedDismissAccessible(true); - } + markFirstMenuItemFocused(); return true; - }, [isAndroid, shouldCoordinateDismissAccessibility]); + }, [getFirstMenuItemTarget, isAndroid, markFirstMenuItemFocused]); const focusFirstMenuItem = useCallback(() => { if (isWeb) { @@ -748,7 +750,7 @@ function BasePopoverMenu({ shouldSetModalVisibility={shouldSetModalVisibility} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} restoreFocusType={restoreFocusType} - isBottomDockedDismissAccessible={shouldCoordinateDismissAccessibility ? isBottomDockedDismissAccessible : undefined} + shouldEnableBottomDockedDismissAccessibility={shouldDeferDismissButtonAccessibility ? shouldEnableBottomDockedDismissAccessibility : undefined} innerContainerStyle={{...styles.pv0, ...innerContainerStyle}} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} testID={testID} From 2d3bfbc66d5d58eb9689f5d42150bb2f1cef57d5 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sun, 15 Mar 2026 13:37:55 +0430 Subject: [PATCH 15/36] fix(a11y): focus first popover item before enabling dismiss on iOS --- src/components/Modal/BaseModal.tsx | 2 ++ src/components/Modal/types.ts | 2 +- src/components/PopoverMenu.tsx | 28 ++++++++++++++-------------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 469f35830ac2..51636ebc79d1 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -270,6 +270,8 @@ function BaseModal({ ); const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose); + // The explicit accessibility-tree hiding is only needed on native iOS. Other platforms keep + // the expected swipe order with the existing first-item focus handoff. const shouldHideBottomDockedDismissFromAccessibility = isNativeIOS && shouldShowBottomDockedDismissButton && shouldEnableBottomDockedDismissAccessibility === false; const modalPaddingStyles = useMemo(() => { diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 47a50bc1d4d0..cd8bcebc43b2 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -126,7 +126,7 @@ type BaseModalProps = Partial & shouldDisplayBelowModals?: boolean; /** - * Internal accessibility handshake for bottom-docked popovers on iOS. + * Internal accessibility handshake for bottom-docked popovers on native iOS. * When `false`, the dismiss control stays hidden from accessibility until the first actionable item is focused. * When `true` or `undefined`, the dismiss control is exposed to accessibility. */ diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 4be33adf7f87..d5ffbaa7b9e4 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -14,7 +14,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Accessibility from '@libs/Accessibility'; -import {isMobileSafari, isSafari} from '@libs/Browser'; +import {isSafari} from '@libs/Browser'; import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; import {close} from '@userActions/Modal'; @@ -316,7 +316,9 @@ function BasePopoverMenu({ const isWeb = platform === CONST.PLATFORM.WEB; const isAndroid = platform === CONST.PLATFORM.ANDROID; const isIOS = platform === CONST.PLATFORM.IOS; - const shouldDeferDismissButtonAccessibility = isIOS || isMobileSafari(); + // Native iOS can announce the dismiss control before the first menu item unless it is gated + // until the first item receives accessibility focus. + const shouldDeferDismissButtonAccessibility = isIOS; const firstMenuItemRef = useRef(null); const isVisibleRef = useRef(isVisible); const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); @@ -325,26 +327,24 @@ function BasePopoverMenu({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['BackArrow', 'ReceiptScan', 'MoneyCircle']); const prevMenuItems = usePrevious(menuItems); - useEffect(() => { - isVisibleRef.current = isVisible; - if (isVisible) { - if (shouldDeferDismissButtonAccessibility) { - setShouldEnableBottomDockedDismissAccessibility(false); - } - return; - } + const markFirstMenuItemUnfocused = useCallback(() => { hasFocusedFirstItemOnCurrentOpenRef.current = false; if (shouldDeferDismissButtonAccessibility) { setShouldEnableBottomDockedDismissAccessibility(false); } - }, [isVisible, shouldDeferDismissButtonAccessibility]); + }, [shouldDeferDismissButtonAccessibility]); useEffect(() => { - if (shouldDeferDismissButtonAccessibility) { + // Reset the dismiss accessibility gating for each modal close before the next open. + isVisibleRef.current = isVisible; + if (isVisible) { + if (shouldDeferDismissButtonAccessibility) { + setShouldEnableBottomDockedDismissAccessibility(false); + } return; } - setShouldEnableBottomDockedDismissAccessibility(true); - }, [shouldDeferDismissButtonAccessibility]); + markFirstMenuItemUnfocused(); + }, [isVisible, markFirstMenuItemUnfocused, shouldDeferDismissButtonAccessibility]); const selectItem = (index: number, event?: GestureResponderEvent | KeyboardEvent) => { const selectedItem = currentMenuItems.at(index); From 6fcf6db5265467afe17a3e2dfd40497bad1bb164 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 16 Mar 2026 13:55:29 +0430 Subject: [PATCH 16/36] refactor(a11y): remove redundant dismiss accessibility reset on popover open --- src/components/PopoverMenu.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index d5ffbaa7b9e4..788f1c295f60 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -335,12 +335,9 @@ function BasePopoverMenu({ }, [shouldDeferDismissButtonAccessibility]); useEffect(() => { - // Reset the dismiss accessibility gating for each modal close before the next open. + // Reset the dismiss accessibility gating when the modal closes. isVisibleRef.current = isVisible; if (isVisible) { - if (shouldDeferDismissButtonAccessibility) { - setShouldEnableBottomDockedDismissAccessibility(false); - } return; } markFirstMenuItemUnfocused(); From 675f0478ea66efe81e02214411e560fa99f5fa79 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 17 Mar 2026 14:29:28 +0430 Subject: [PATCH 17/36] refactor(a11y): remove redundant native iOS guard in BaseModal --- src/components/Modal/BaseModal.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 51636ebc79d1..fe9967da96f2 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -91,7 +91,6 @@ function BaseModal({ const keyboardStateContextValue = useKeyboardState(); const isWeb = getPlatform() === CONST.PLATFORM.WEB; - const isNativeIOS = getPlatform() === CONST.PLATFORM.IOS; const [modalOverlapsWithTopSafeArea, setModalOverlapsWithTopSafeArea] = useState(false); const [modalHeight, setModalHeight] = useState(0); @@ -270,9 +269,8 @@ function BaseModal({ ); const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose); - // The explicit accessibility-tree hiding is only needed on native iOS. Other platforms keep - // the expected swipe order with the existing first-item focus handoff. - const shouldHideBottomDockedDismissFromAccessibility = isNativeIOS && shouldShowBottomDockedDismissButton && shouldEnableBottomDockedDismissAccessibility === false; + // PopoverMenu only passes this internal accessibility handshake on the native iOS path. + const shouldHideBottomDockedDismissFromAccessibility = shouldShowBottomDockedDismissButton && shouldEnableBottomDockedDismissAccessibility === false; const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ From e5014bd3b85702a0e0ff7871a65bba31617dad24 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 17 Mar 2026 18:04:53 +0430 Subject: [PATCH 18/36] refactor(a11y): simplify popover focus effect dependencies --- src/components/PopoverMenu.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 788f1c295f60..252d500c5580 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -341,7 +341,7 @@ function BasePopoverMenu({ return; } markFirstMenuItemUnfocused(); - }, [isVisible, markFirstMenuItemUnfocused, shouldDeferDismissButtonAccessibility]); + }, [isVisible]); const selectItem = (index: number, event?: GestureResponderEvent | KeyboardEvent) => { const selectedItem = currentMenuItems.at(index); @@ -567,7 +567,7 @@ function BasePopoverMenu({ return; } - if (focusFirstMenuItemOnWeb()) { + if (focusFirstMenuItem()) { return; } @@ -579,7 +579,7 @@ function BasePopoverMenu({ }; requestAnimationFrame(() => focusFirstMenuItemWithRetries()); - }, [focusFirstMenuItemOnWeb]); + }, [focusFirstMenuItem]); const scheduleFocusFirstMenuItemOnNative = useCallback(() => { const focusTarget = () => { From cccac982d7d36ea7b54176613516277c61d8a929 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 24 Mar 2026 08:13:03 +0430 Subject: [PATCH 19/36] fixed type failure. --- src/libs/Accessibility/moveAccessibilityFocus/index.native.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts index ac36ddec4fcd..86125a76aea7 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts @@ -1,5 +1,5 @@ import {AccessibilityInfo} from 'react-native'; -import type {NativeMethods} from 'react-native'; +import type {ReactNativeElement} from 'react-native'; import type MoveAccessibilityFocus from './types'; const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { @@ -9,7 +9,7 @@ const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { return; } - AccessibilityInfo.sendAccessibilityEvent(focusTarget as NativeMethods, 'focus'); + AccessibilityInfo.sendAccessibilityEvent(focusTarget as ReactNativeElement, 'focus'); if ('focus' in focusTarget && typeof focusTarget.focus === 'function') { focusTarget.focus(); From 1c8032985fb57b5a27f74165bccdf25a0be7edf5 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 26 Mar 2026 11:34:27 +0430 Subject: [PATCH 20/36] fix(a11y): focus first FAB menu item when opening action menu --- .../FABFirstItemRefContext.tsx | 7 + .../FABFocusableMenuItem.tsx | 5 +- .../FABPopoverContent/FABPopoverMenu.tsx | 199 +++++++++++++++--- 3 files changed, 182 insertions(+), 29 deletions(-) create mode 100644 src/pages/inbox/sidebar/FABPopoverContent/FABFirstItemRefContext.tsx diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABFirstItemRefContext.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABFirstItemRefContext.tsx new file mode 100644 index 000000000000..a8a3e458fba0 --- /dev/null +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABFirstItemRefContext.tsx @@ -0,0 +1,7 @@ +import {createContext} from 'react'; +import type {RefObject} from 'react'; +import type {View} from 'react-native'; + +const FABFirstItemRefContext = createContext>({current: null}); + +export default FABFirstItemRefContext; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx index 1e5515bc4314..50c421b42984 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, {useContext} from 'react'; import FocusableMenuItem from '@components/FocusableMenuItem'; import type {MenuItemProps} from '@components/MenuItem'; import CONST from '@src/CONST'; +import FABFirstItemRefContext from './FABFirstItemRefContext'; import useFABMenuItem from './useFABMenuItem'; type FABFocusableMenuItemProps = Omit & { @@ -13,6 +14,7 @@ type FABFocusableMenuItemProps = Omit setFocusedIndex(itemIndex)} wrapperStyle={wrapperStyle} diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index 831037122180..3405cbe97139 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -1,16 +1,20 @@ -import React, {useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {RefObject} from 'react'; -import {View} from 'react-native'; +import {AccessibilityInfo, View} from 'react-native'; +import type {View as RNView} from 'react-native'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import Accessibility from '@libs/Accessibility'; import {close} from '@libs/actions/Modal'; import {isSafari} from '@libs/Browser'; +import getPlatform from '@libs/getPlatform'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import CONST from '@src/CONST'; +import FABFirstItemRefContext from './FABFirstItemRefContext'; import {FABMenuContext} from './FABMenuContext'; const FAB_ITEM_ORDER = [ @@ -25,6 +29,8 @@ const FAB_ITEM_ORDER = [ CONST.FAB_MENU_ITEM_IDS.QUICK_ACTION, ] as const; +const MAX_FIRST_MENU_ITEM_FOCUS_RETRIES = 5; + type FABPopoverMenuProps = { isVisible: boolean; onClose: () => void; @@ -40,8 +46,17 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio const {shouldUseNarrowLayout} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const anchorPosition = styles.createMenuPositionSidebar(windowHeight); + const platform = getPlatform(); + const isWeb = platform === CONST.PLATFORM.WEB; + const isAndroid = platform === CONST.PLATFORM.ANDROID; + const isIOS = platform === CONST.PLATFORM.IOS; + const shouldDeferDismissButtonAccessibility = isIOS; const [registeredSet, setRegisteredSet] = useState>(new Set()); + const firstItemRef = useRef(null); + const isVisibleRef = useRef(isVisible); + const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); + const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(!shouldDeferDismissButtonAccessibility); const registeredItems = FAB_ITEM_ORDER.filter((id) => registeredSet.has(id)); const itemCount = registeredItems.length; @@ -74,6 +89,21 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio isActive: isVisible, }); + const markFirstMenuItemUnfocused = useCallback(() => { + hasFocusedFirstItemOnCurrentOpenRef.current = false; + if (shouldDeferDismissButtonAccessibility) { + setShouldEnableBottomDockedDismissAccessibility(false); + } + }, [shouldDeferDismissButtonAccessibility]); + + useEffect(() => { + isVisibleRef.current = isVisible; + if (isVisible) { + return; + } + markFirstMenuItemUnfocused(); + }, [isVisible]); + const handleClose = () => { setFocusedIndex(-1); onClose(); @@ -91,6 +121,115 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio setFocusedIndex(-1); }; + const getFirstMenuItemTarget = useCallback(() => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return null; + } + + return firstItemRef.current; + }, []); + + const markFirstMenuItemFocused = useCallback(() => { + hasFocusedFirstItemOnCurrentOpenRef.current = true; + if (shouldDeferDismissButtonAccessibility) { + setShouldEnableBottomDockedDismissAccessibility(true); + } + }, [shouldDeferDismissButtonAccessibility]); + + const focusFirstMenuItemOnWeb = useCallback(() => { + const target = getFirstMenuItemTarget(); + if (!target || !('focus' in target) || typeof target.focus !== 'function') { + return false; + } + + target.focus(); + markFirstMenuItemFocused(); + return true; + }, [getFirstMenuItemTarget, markFirstMenuItemFocused]); + + const focusFirstMenuItemOnNative = useCallback(() => { + const target = getFirstMenuItemTarget(); + if (!target) { + return false; + } + + const sendAccessibilityEvent = AccessibilityInfo.sendAccessibilityEvent; + if (sendAccessibilityEvent && isAndroid) { + sendAccessibilityEvent(target, 'viewHoverEnter'); + } + + Accessibility.moveAccessibilityFocus(firstItemRef); + markFirstMenuItemFocused(); + return true; + }, [getFirstMenuItemTarget, isAndroid, markFirstMenuItemFocused]); + + const focusFirstMenuItem = useCallback(() => { + if (isWeb) { + return focusFirstMenuItemOnWeb(); + } + + return focusFirstMenuItemOnNative(); + }, [focusFirstMenuItemOnNative, focusFirstMenuItemOnWeb, isWeb]); + + const scheduleFocusFirstMenuItemOnWeb = useCallback(() => { + const focusFirstMenuItemWithRetries = (retries = MAX_FIRST_MENU_ITEM_FOCUS_RETRIES) => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + if (focusFirstMenuItem()) { + return; + } + + if (retries <= 0) { + return; + } + + requestAnimationFrame(() => focusFirstMenuItemWithRetries(retries - 1)); + }; + + requestAnimationFrame(() => focusFirstMenuItemWithRetries()); + }, [focusFirstMenuItem]); + + const scheduleFocusFirstMenuItemOnNative = useCallback(() => { + const focusTarget = () => { + requestAnimationFrame(() => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + focusFirstMenuItem(); + }); + }; + + setTimeout(focusTarget, isIOS ? (animationInTiming ?? 0) : 0); + }, [animationInTiming, focusFirstMenuItem, isIOS]); + + const scheduleFocusFirstMenuItem = useCallback(() => { + if (isWeb) { + scheduleFocusFirstMenuItemOnWeb(); + return; + } + + scheduleFocusFirstMenuItemOnNative(); + }, [isWeb, scheduleFocusFirstMenuItemOnNative, scheduleFocusFirstMenuItemOnWeb]); + + const handleModalShow = useCallback(() => { + if (!shouldUseNarrowLayout) { + return; + } + + scheduleFocusFirstMenuItem(); + }, [scheduleFocusFirstMenuItem, shouldUseNarrowLayout]); + + useEffect(() => { + if (!isVisible || !shouldUseNarrowLayout || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + scheduleFocusFirstMenuItem(); + }, [isVisible, scheduleFocusFirstMenuItem, shouldUseNarrowLayout]); + return ( - - + - - {children} - - - + + + {children} + + + + ); } From cd07d0be6d638f9b678700bb976868da686ff17f Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 26 Mar 2026 13:56:22 +0430 Subject: [PATCH 21/36] fix(a11y): harden initial focus for FAB action menu on iOS --- .../FABPopoverContent/FABPopoverMenu.tsx | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index 3405cbe97139..0f47c696af5f 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -30,6 +30,7 @@ const FAB_ITEM_ORDER = [ ] as const; const MAX_FIRST_MENU_ITEM_FOCUS_RETRIES = 5; +const FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS = 50; type FABPopoverMenuProps = { isVisible: boolean; @@ -56,6 +57,7 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio const firstItemRef = useRef(null); const isVisibleRef = useRef(isVisible); const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); + const firstMenuItemFocusRetryTimeoutRef = useRef | null>(null); const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(!shouldDeferDismissButtonAccessibility); const registeredItems = FAB_ITEM_ORDER.filter((id) => registeredSet.has(id)); @@ -89,12 +91,22 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio isActive: isVisible, }); + const clearScheduledFirstMenuItemFocus = useCallback(() => { + if (!firstMenuItemFocusRetryTimeoutRef.current) { + return; + } + + clearTimeout(firstMenuItemFocusRetryTimeoutRef.current); + firstMenuItemFocusRetryTimeoutRef.current = null; + }, []); + const markFirstMenuItemUnfocused = useCallback(() => { + clearScheduledFirstMenuItemFocus(); hasFocusedFirstItemOnCurrentOpenRef.current = false; if (shouldDeferDismissButtonAccessibility) { setShouldEnableBottomDockedDismissAccessibility(false); } - }, [shouldDeferDismissButtonAccessibility]); + }, [clearScheduledFirstMenuItemFocus, shouldDeferDismissButtonAccessibility]); useEffect(() => { isVisibleRef.current = isVisible; @@ -104,6 +116,13 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio markFirstMenuItemUnfocused(); }, [isVisible]); + useEffect( + () => () => { + clearScheduledFirstMenuItemFocus(); + }, + [clearScheduledFirstMenuItemFocus], + ); + const handleClose = () => { setFocusedIndex(-1); onClose(); @@ -130,11 +149,12 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio }, []); const markFirstMenuItemFocused = useCallback(() => { + clearScheduledFirstMenuItemFocus(); hasFocusedFirstItemOnCurrentOpenRef.current = true; if (shouldDeferDismissButtonAccessibility) { setShouldEnableBottomDockedDismissAccessibility(true); } - }, [shouldDeferDismissButtonAccessibility]); + }, [clearScheduledFirstMenuItemFocus, shouldDeferDismissButtonAccessibility]); const focusFirstMenuItemOnWeb = useCallback(() => { const target = getFirstMenuItemTarget(); @@ -159,9 +179,11 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio } Accessibility.moveAccessibilityFocus(firstItemRef); - markFirstMenuItemFocused(); + if (!shouldDeferDismissButtonAccessibility) { + markFirstMenuItemFocused(); + } return true; - }, [getFirstMenuItemTarget, isAndroid, markFirstMenuItemFocused]); + }, [getFirstMenuItemTarget, isAndroid, markFirstMenuItemFocused, shouldDeferDismissButtonAccessibility]); const focusFirstMenuItem = useCallback(() => { if (isWeb) { @@ -192,18 +214,36 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio }, [focusFirstMenuItem]); const scheduleFocusFirstMenuItemOnNative = useCallback(() => { - const focusTarget = () => { + const focusFirstMenuItemWithRetries = (retries = MAX_FIRST_MENU_ITEM_FOCUS_RETRIES) => { requestAnimationFrame(() => { if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { return; } focusFirstMenuItem(); + + if (!shouldDeferDismissButtonAccessibility || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + if (retries <= 0) { + markFirstMenuItemFocused(); + return; + } + + firstMenuItemFocusRetryTimeoutRef.current = setTimeout(() => { + firstMenuItemFocusRetryTimeoutRef.current = null; + focusFirstMenuItemWithRetries(retries - 1); + }, FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS); }); }; - setTimeout(focusTarget, isIOS ? (animationInTiming ?? 0) : 0); - }, [animationInTiming, focusFirstMenuItem, isIOS]); + clearScheduledFirstMenuItemFocus(); + firstMenuItemFocusRetryTimeoutRef.current = setTimeout(() => { + firstMenuItemFocusRetryTimeoutRef.current = null; + focusFirstMenuItemWithRetries(); + }, isIOS ? (animationInTiming ?? 0) : 0); + }, [animationInTiming, clearScheduledFirstMenuItemFocus, focusFirstMenuItem, isIOS, markFirstMenuItemFocused, shouldDeferDismissButtonAccessibility]); const scheduleFocusFirstMenuItem = useCallback(() => { if (isWeb) { @@ -230,6 +270,14 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio scheduleFocusFirstMenuItem(); }, [isVisible, scheduleFocusFirstMenuItem, shouldUseNarrowLayout]); + useEffect(() => { + if (!isVisible || !shouldDeferDismissButtonAccessibility || focusedIndex !== 0 || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + markFirstMenuItemFocused(); + }, [focusedIndex, isVisible, markFirstMenuItemFocused, shouldDeferDismissButtonAccessibility]); + return ( Date: Thu, 26 Mar 2026 14:09:55 +0430 Subject: [PATCH 22/36] format FAB popover menu with prettier --- .../sidebar/FABPopoverContent/FABPopoverMenu.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index 0f47c696af5f..74b1240f98ec 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -239,10 +239,13 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio }; clearScheduledFirstMenuItemFocus(); - firstMenuItemFocusRetryTimeoutRef.current = setTimeout(() => { - firstMenuItemFocusRetryTimeoutRef.current = null; - focusFirstMenuItemWithRetries(); - }, isIOS ? (animationInTiming ?? 0) : 0); + firstMenuItemFocusRetryTimeoutRef.current = setTimeout( + () => { + firstMenuItemFocusRetryTimeoutRef.current = null; + focusFirstMenuItemWithRetries(); + }, + isIOS ? (animationInTiming ?? 0) : 0, + ); }, [animationInTiming, clearScheduledFirstMenuItemFocus, focusFirstMenuItem, isIOS, markFirstMenuItemFocused, shouldDeferDismissButtonAccessibility]); const scheduleFocusFirstMenuItem = useCallback(() => { From 255ffc013438eee5399220ee6494eaa763f41938 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 31 Mar 2026 09:42:09 +0430 Subject: [PATCH 23/36] refactor(a11y): extract bottom-docked dismiss accessibility hook --- src/components/PopoverMenu.tsx | 160 ++---------- .../useBottomDockedDismissAccessibility.ts | 242 ++++++++++++++++++ .../FABPopoverContent/FABPopoverMenu.tsx | 204 +-------------- 3 files changed, 269 insertions(+), 337 deletions(-) create mode 100644 src/hooks/useBottomDockedDismissAccessibility.ts diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 63ce38750e86..255344e78aa0 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -1,10 +1,11 @@ /* eslint-disable react/jsx-props-no-spreading */ import {deepEqual} from 'fast-equals'; import type {ReactNode, RefObject} from 'react'; -import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import {AccessibilityInfo, StyleSheet, View} from 'react-native'; -import type {GestureResponderEvent, LayoutChangeEvent, View as RNView, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; +import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import useBottomDockedDismissAccessibility from '@hooks/useBottomDockedDismissAccessibility'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -13,7 +14,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import Accessibility from '@libs/Accessibility'; import {isSafari} from '@libs/Browser'; import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; @@ -185,8 +185,6 @@ type PopoverMenuProps = Partial & { badgeStyle?: StyleProp; }; -const MAX_FIRST_MENU_ITEM_FOCUS_RETRIES = 5; - const renderWithConditionalWrapper = (shouldUseScrollView: boolean, contentContainerStyle: StyleProp, children: ReactNode): React.JSX.Element => { if (shouldUseScrollView) { return {children}; @@ -312,39 +310,23 @@ function BasePopoverMenu({ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); + const isWeb = getPlatform() === CONST.PLATFORM.WEB; const [currentMenuItems, setCurrentMenuItems] = useState(menuItems); const currentMenuItemsFocusedIndex = getSelectedItemIndex(currentMenuItems); const [enteredSubMenuIndexes, setEnteredSubMenuIndexes] = useState(CONST.EMPTY_ARRAY); - const platform = getPlatform(); - const isWeb = platform === CONST.PLATFORM.WEB; - const isAndroid = platform === CONST.PLATFORM.ANDROID; - const isIOS = platform === CONST.PLATFORM.IOS; - // Native iOS can announce the dismiss control before the first menu item unless it is gated - // until the first item receives accessibility focus. - const shouldDeferDismissButtonAccessibility = isIOS; - const firstMenuItemRef = useRef(null); - const isVisibleRef = useRef(isVisible); - const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); - const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(!shouldDeferDismissButtonAccessibility); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: currentMenuItemsFocusedIndex, maxIndex: currentMenuItems.length - 1, isActive: isVisible}); const expensifyIcons = useMemoizedLazyExpensifyIcons(['BackArrow', 'ReceiptScan', 'MoneyCircle']); const prevMenuItems = usePrevious(menuItems); - - const markFirstMenuItemUnfocused = useCallback(() => { - hasFocusedFirstItemOnCurrentOpenRef.current = false; - if (shouldDeferDismissButtonAccessibility) { - setShouldEnableBottomDockedDismissAccessibility(false); - } - }, [shouldDeferDismissButtonAccessibility]); - - useEffect(() => { - // Reset the dismiss accessibility gating when the modal closes. - isVisibleRef.current = isVisible; - if (isVisible) { - return; - } - markFirstMenuItemUnfocused(); - }, [isVisible]); + const { + firstItemRef: firstMenuItemRef, + handleModalShow, + shouldEnableBottomDockedDismissAccessibility, + } = useBottomDockedDismissAccessibility({ + isVisible, + shouldActivate: isSmallScreenWidth, + animationDelayMs: (animationInDelay ?? 0) + animationInTiming, + onModalShow, + }); const selectItem = (index: number, event?: GestureResponderEvent | KeyboardEvent) => { const selectedItem = currentMenuItems.at(index); @@ -514,116 +496,6 @@ function BasePopoverMenu({ // can cause the parent view to scroll when the space bar is pressed. useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.SPACE, keyboardShortcutSpaceCallback, {isActive: isWeb && isVisible, shouldPreventDefault: false}); - const getFirstMenuItemTarget = useCallback(() => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { - return null; - } - - return firstMenuItemRef.current; - }, []); - - const markFirstMenuItemFocused = useCallback(() => { - hasFocusedFirstItemOnCurrentOpenRef.current = true; - if (shouldDeferDismissButtonAccessibility) { - setShouldEnableBottomDockedDismissAccessibility(true); - } - }, [shouldDeferDismissButtonAccessibility]); - - const focusFirstMenuItemOnWeb = useCallback(() => { - const target = getFirstMenuItemTarget(); - if (!target || !('focus' in target) || typeof target.focus !== 'function') { - return false; - } - - target.focus(); - markFirstMenuItemFocused(); - return true; - }, [getFirstMenuItemTarget, markFirstMenuItemFocused]); - - const focusFirstMenuItemOnNative = useCallback(() => { - const target = getFirstMenuItemTarget(); - if (!target) { - return false; - } - - const sendAccessibilityEvent = AccessibilityInfo.sendAccessibilityEvent; - if (sendAccessibilityEvent && isAndroid) { - sendAccessibilityEvent(target, 'viewHoverEnter'); - } - - Accessibility.moveAccessibilityFocus(firstMenuItemRef); - markFirstMenuItemFocused(); - return true; - }, [getFirstMenuItemTarget, isAndroid, markFirstMenuItemFocused]); - - const focusFirstMenuItem = useCallback(() => { - if (isWeb) { - return focusFirstMenuItemOnWeb(); - } - - return focusFirstMenuItemOnNative(); - }, [focusFirstMenuItemOnNative, focusFirstMenuItemOnWeb, isWeb]); - - const scheduleFocusFirstMenuItemOnWeb = useCallback(() => { - const focusFirstMenuItemWithRetries = (retries = MAX_FIRST_MENU_ITEM_FOCUS_RETRIES) => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { - return; - } - - if (focusFirstMenuItem()) { - return; - } - - if (retries <= 0) { - return; - } - - requestAnimationFrame(() => focusFirstMenuItemWithRetries(retries - 1)); - }; - - requestAnimationFrame(() => focusFirstMenuItemWithRetries()); - }, [focusFirstMenuItem]); - - const scheduleFocusFirstMenuItemOnNative = useCallback(() => { - const focusTarget = () => { - requestAnimationFrame(() => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { - return; - } - - focusFirstMenuItem(); - }); - }; - - setTimeout(focusTarget, isIOS ? (animationInDelay ?? 0) + animationInTiming : 0); - }, [animationInDelay, animationInTiming, focusFirstMenuItem, isIOS]); - - const scheduleFocusFirstMenuItem = useCallback(() => { - if (isWeb) { - scheduleFocusFirstMenuItemOnWeb(); - return; - } - - scheduleFocusFirstMenuItemOnNative(); - }, [isWeb, scheduleFocusFirstMenuItemOnNative, scheduleFocusFirstMenuItemOnWeb]); - - const handleModalShow = useCallback(() => { - onModalShow?.(); - - if (!isSmallScreenWidth) { - return; - } - - scheduleFocusFirstMenuItem(); - }, [isSmallScreenWidth, onModalShow, scheduleFocusFirstMenuItem]); - - useEffect(() => { - if (!isVisible || !isSmallScreenWidth || hasFocusedFirstItemOnCurrentOpenRef.current) { - return; - } - scheduleFocusFirstMenuItem(); - }, [isVisible, isSmallScreenWidth, scheduleFocusFirstMenuItem]); - const handleModalHide = () => { onModalHide?.(); const keyPath = buildKeyPathFromIndexPath(menuItems, enteredSubMenuIndexes); @@ -750,7 +622,7 @@ function BasePopoverMenu({ shouldSetModalVisibility={shouldSetModalVisibility} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} restoreFocusType={restoreFocusType} - shouldEnableBottomDockedDismissAccessibility={shouldDeferDismissButtonAccessibility ? shouldEnableBottomDockedDismissAccessibility : undefined} + shouldEnableBottomDockedDismissAccessibility={shouldEnableBottomDockedDismissAccessibility} innerContainerStyle={{...styles.pv0, ...innerContainerStyle}} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} shouldHandleNavigationBack={shouldHandleNavigationBack} diff --git a/src/hooks/useBottomDockedDismissAccessibility.ts b/src/hooks/useBottomDockedDismissAccessibility.ts new file mode 100644 index 000000000000..8a103c33a380 --- /dev/null +++ b/src/hooks/useBottomDockedDismissAccessibility.ts @@ -0,0 +1,242 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import type {RefObject} from 'react'; +import {AccessibilityInfo} from 'react-native'; +import type {View as RNView} from 'react-native'; +import Accessibility from '@libs/Accessibility'; +import getPlatform from '@libs/getPlatform'; +import CONST from '@src/CONST'; + +type UseBottomDockedDismissAccessibilityParams = { + isVisible: boolean; + shouldActivate: boolean; + animationDelayMs: number; + onModalShow?: () => void; + shouldConfirmFirstItemFocus?: boolean; + focusedIndex?: number; + maxFocusRetries?: number; + nativeFocusRetryDelayMs?: number; +}; + +type UseBottomDockedDismissAccessibilityResult = { + firstItemRef: RefObject; + handleModalShow: () => void; + shouldEnableBottomDockedDismissAccessibility?: boolean; +}; + +const DEFAULT_MAX_FIRST_MENU_ITEM_FOCUS_RETRIES = 5; +const DEFAULT_FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS = 50; + +function useBottomDockedDismissAccessibility({ + isVisible, + shouldActivate, + animationDelayMs, + onModalShow, + shouldConfirmFirstItemFocus = false, + focusedIndex, + maxFocusRetries = DEFAULT_MAX_FIRST_MENU_ITEM_FOCUS_RETRIES, + nativeFocusRetryDelayMs = DEFAULT_FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS, +}: UseBottomDockedDismissAccessibilityParams): UseBottomDockedDismissAccessibilityResult { + const platform = getPlatform(); + const isWeb = platform === CONST.PLATFORM.WEB; + const isAndroid = platform === CONST.PLATFORM.ANDROID; + const isIOS = platform === CONST.PLATFORM.IOS; + // Native iOS can announce dismiss before the first item unless it stays hidden until focus lands. + const shouldDeferDismissButtonAccessibility = isIOS; + const firstItemRef = useRef(null); + const isVisibleRef = useRef(isVisible); + const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); + const firstMenuItemFocusRetryTimeoutRef = useRef | null>(null); + const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(!shouldDeferDismissButtonAccessibility); + + const clearScheduledFirstMenuItemFocus = useCallback(() => { + if (!firstMenuItemFocusRetryTimeoutRef.current) { + return; + } + + clearTimeout(firstMenuItemFocusRetryTimeoutRef.current); + firstMenuItemFocusRetryTimeoutRef.current = null; + }, []); + + const markFirstMenuItemUnfocused = useCallback(() => { + clearScheduledFirstMenuItemFocus(); + hasFocusedFirstItemOnCurrentOpenRef.current = false; + if (shouldDeferDismissButtonAccessibility) { + setShouldEnableBottomDockedDismissAccessibility(false); + } + }, [clearScheduledFirstMenuItemFocus, shouldDeferDismissButtonAccessibility]); + + useEffect(() => { + isVisibleRef.current = isVisible; + if (isVisible) { + return; + } + + markFirstMenuItemUnfocused(); + }, [isVisible, markFirstMenuItemUnfocused]); + + useEffect( + () => () => { + clearScheduledFirstMenuItemFocus(); + }, + [clearScheduledFirstMenuItemFocus], + ); + + const getFirstMenuItemTarget = useCallback(() => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return null; + } + + return firstItemRef.current; + }, []); + + const markFirstMenuItemFocused = useCallback(() => { + clearScheduledFirstMenuItemFocus(); + hasFocusedFirstItemOnCurrentOpenRef.current = true; + if (shouldDeferDismissButtonAccessibility) { + setShouldEnableBottomDockedDismissAccessibility(true); + } + }, [clearScheduledFirstMenuItemFocus, shouldDeferDismissButtonAccessibility]); + + const focusFirstMenuItemOnWeb = useCallback(() => { + const target = getFirstMenuItemTarget(); + if (!target || !('focus' in target) || typeof target.focus !== 'function') { + return false; + } + + target.focus(); + markFirstMenuItemFocused(); + return true; + }, [getFirstMenuItemTarget, markFirstMenuItemFocused]); + + const focusFirstMenuItemOnNative = useCallback(() => { + const target = getFirstMenuItemTarget(); + if (!target) { + return false; + } + + const sendAccessibilityEvent = AccessibilityInfo.sendAccessibilityEvent; + if (sendAccessibilityEvent && isAndroid) { + sendAccessibilityEvent(target, 'viewHoverEnter'); + } + + Accessibility.moveAccessibilityFocus(firstItemRef); + if (!shouldDeferDismissButtonAccessibility || !shouldConfirmFirstItemFocus) { + markFirstMenuItemFocused(); + } + return true; + }, [getFirstMenuItemTarget, isAndroid, markFirstMenuItemFocused, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); + + const focusFirstMenuItem = useCallback(() => { + if (isWeb) { + return focusFirstMenuItemOnWeb(); + } + + return focusFirstMenuItemOnNative(); + }, [focusFirstMenuItemOnNative, focusFirstMenuItemOnWeb, isWeb]); + + const scheduleFocusFirstMenuItemOnWeb = useCallback(() => { + const focusFirstMenuItemWithRetries = (retries = maxFocusRetries) => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + if (focusFirstMenuItem()) { + return; + } + + if (retries <= 0) { + return; + } + + requestAnimationFrame(() => focusFirstMenuItemWithRetries(retries - 1)); + }; + + requestAnimationFrame(() => focusFirstMenuItemWithRetries()); + }, [focusFirstMenuItem, maxFocusRetries]); + + const scheduleFocusFirstMenuItemOnNative = useCallback(() => { + const focusFirstMenuItemWithRetries = (retries = maxFocusRetries) => { + requestAnimationFrame(() => { + if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + focusFirstMenuItem(); + + if (!shouldDeferDismissButtonAccessibility || !shouldConfirmFirstItemFocus || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + if (retries <= 0) { + markFirstMenuItemFocused(); + return; + } + + firstMenuItemFocusRetryTimeoutRef.current = setTimeout(() => { + firstMenuItemFocusRetryTimeoutRef.current = null; + focusFirstMenuItemWithRetries(retries - 1); + }, nativeFocusRetryDelayMs); + }); + }; + + clearScheduledFirstMenuItemFocus(); + firstMenuItemFocusRetryTimeoutRef.current = setTimeout( + () => { + firstMenuItemFocusRetryTimeoutRef.current = null; + focusFirstMenuItemWithRetries(); + }, + shouldDeferDismissButtonAccessibility ? animationDelayMs : 0, + ); + }, [ + animationDelayMs, + clearScheduledFirstMenuItemFocus, + focusFirstMenuItem, + markFirstMenuItemFocused, + maxFocusRetries, + nativeFocusRetryDelayMs, + shouldConfirmFirstItemFocus, + shouldDeferDismissButtonAccessibility, + ]); + + const scheduleFocusFirstMenuItem = useCallback(() => { + if (isWeb) { + scheduleFocusFirstMenuItemOnWeb(); + return; + } + + scheduleFocusFirstMenuItemOnNative(); + }, [isWeb, scheduleFocusFirstMenuItemOnNative, scheduleFocusFirstMenuItemOnWeb]); + + const handleModalShow = useCallback(() => { + onModalShow?.(); + if (!shouldActivate) { + return; + } + + scheduleFocusFirstMenuItem(); + }, [onModalShow, scheduleFocusFirstMenuItem, shouldActivate]); + + useEffect(() => { + if (!isVisible || !shouldActivate || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + scheduleFocusFirstMenuItem(); + }, [isVisible, scheduleFocusFirstMenuItem, shouldActivate]); + + useEffect(() => { + if (!isVisible || !shouldDeferDismissButtonAccessibility || !shouldConfirmFirstItemFocus || focusedIndex !== 0 || hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + markFirstMenuItemFocused(); + }, [focusedIndex, isVisible, markFirstMenuItemFocused, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); + + return { + firstItemRef, + handleModalShow, + shouldEnableBottomDockedDismissAccessibility: shouldDeferDismissButtonAccessibility ? shouldEnableBottomDockedDismissAccessibility : undefined, + }; +} + +export default useBottomDockedDismissAccessibility; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index 74b1240f98ec..437e0df902ce 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -1,17 +1,15 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useState} from 'react'; import type {RefObject} from 'react'; -import {AccessibilityInfo, View} from 'react-native'; -import type {View as RNView} from 'react-native'; +import {View} from 'react-native'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import useBottomDockedDismissAccessibility from '@hooks/useBottomDockedDismissAccessibility'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import Accessibility from '@libs/Accessibility'; import {close} from '@libs/actions/Modal'; import {isSafari} from '@libs/Browser'; -import getPlatform from '@libs/getPlatform'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import CONST from '@src/CONST'; import FABFirstItemRefContext from './FABFirstItemRefContext'; @@ -29,9 +27,6 @@ const FAB_ITEM_ORDER = [ CONST.FAB_MENU_ITEM_IDS.QUICK_ACTION, ] as const; -const MAX_FIRST_MENU_ITEM_FOCUS_RETRIES = 5; -const FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS = 50; - type FABPopoverMenuProps = { isVisible: boolean; onClose: () => void; @@ -47,19 +42,8 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio const {shouldUseNarrowLayout} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const anchorPosition = styles.createMenuPositionSidebar(windowHeight); - const platform = getPlatform(); - const isWeb = platform === CONST.PLATFORM.WEB; - const isAndroid = platform === CONST.PLATFORM.ANDROID; - const isIOS = platform === CONST.PLATFORM.IOS; - const shouldDeferDismissButtonAccessibility = isIOS; const [registeredSet, setRegisteredSet] = useState>(new Set()); - const firstItemRef = useRef(null); - const isVisibleRef = useRef(isVisible); - const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); - const firstMenuItemFocusRetryTimeoutRef = useRef | null>(null); - const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(!shouldDeferDismissButtonAccessibility); - const registeredItems = FAB_ITEM_ORDER.filter((id) => registeredSet.has(id)); const itemCount = registeredItems.length; @@ -90,38 +74,13 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio maxIndex: itemCount - 1, isActive: isVisible, }); - - const clearScheduledFirstMenuItemFocus = useCallback(() => { - if (!firstMenuItemFocusRetryTimeoutRef.current) { - return; - } - - clearTimeout(firstMenuItemFocusRetryTimeoutRef.current); - firstMenuItemFocusRetryTimeoutRef.current = null; - }, []); - - const markFirstMenuItemUnfocused = useCallback(() => { - clearScheduledFirstMenuItemFocus(); - hasFocusedFirstItemOnCurrentOpenRef.current = false; - if (shouldDeferDismissButtonAccessibility) { - setShouldEnableBottomDockedDismissAccessibility(false); - } - }, [clearScheduledFirstMenuItemFocus, shouldDeferDismissButtonAccessibility]); - - useEffect(() => { - isVisibleRef.current = isVisible; - if (isVisible) { - return; - } - markFirstMenuItemUnfocused(); - }, [isVisible]); - - useEffect( - () => () => { - clearScheduledFirstMenuItemFocus(); - }, - [clearScheduledFirstMenuItemFocus], - ); + const {firstItemRef, handleModalShow, shouldEnableBottomDockedDismissAccessibility} = useBottomDockedDismissAccessibility({ + isVisible, + shouldActivate: shouldUseNarrowLayout, + animationDelayMs: animationInTiming ?? 0, + shouldConfirmFirstItemFocus: true, + focusedIndex, + }); const handleClose = () => { setFocusedIndex(-1); @@ -140,147 +99,6 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio setFocusedIndex(-1); }; - const getFirstMenuItemTarget = useCallback(() => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { - return null; - } - - return firstItemRef.current; - }, []); - - const markFirstMenuItemFocused = useCallback(() => { - clearScheduledFirstMenuItemFocus(); - hasFocusedFirstItemOnCurrentOpenRef.current = true; - if (shouldDeferDismissButtonAccessibility) { - setShouldEnableBottomDockedDismissAccessibility(true); - } - }, [clearScheduledFirstMenuItemFocus, shouldDeferDismissButtonAccessibility]); - - const focusFirstMenuItemOnWeb = useCallback(() => { - const target = getFirstMenuItemTarget(); - if (!target || !('focus' in target) || typeof target.focus !== 'function') { - return false; - } - - target.focus(); - markFirstMenuItemFocused(); - return true; - }, [getFirstMenuItemTarget, markFirstMenuItemFocused]); - - const focusFirstMenuItemOnNative = useCallback(() => { - const target = getFirstMenuItemTarget(); - if (!target) { - return false; - } - - const sendAccessibilityEvent = AccessibilityInfo.sendAccessibilityEvent; - if (sendAccessibilityEvent && isAndroid) { - sendAccessibilityEvent(target, 'viewHoverEnter'); - } - - Accessibility.moveAccessibilityFocus(firstItemRef); - if (!shouldDeferDismissButtonAccessibility) { - markFirstMenuItemFocused(); - } - return true; - }, [getFirstMenuItemTarget, isAndroid, markFirstMenuItemFocused, shouldDeferDismissButtonAccessibility]); - - const focusFirstMenuItem = useCallback(() => { - if (isWeb) { - return focusFirstMenuItemOnWeb(); - } - - return focusFirstMenuItemOnNative(); - }, [focusFirstMenuItemOnNative, focusFirstMenuItemOnWeb, isWeb]); - - const scheduleFocusFirstMenuItemOnWeb = useCallback(() => { - const focusFirstMenuItemWithRetries = (retries = MAX_FIRST_MENU_ITEM_FOCUS_RETRIES) => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { - return; - } - - if (focusFirstMenuItem()) { - return; - } - - if (retries <= 0) { - return; - } - - requestAnimationFrame(() => focusFirstMenuItemWithRetries(retries - 1)); - }; - - requestAnimationFrame(() => focusFirstMenuItemWithRetries()); - }, [focusFirstMenuItem]); - - const scheduleFocusFirstMenuItemOnNative = useCallback(() => { - const focusFirstMenuItemWithRetries = (retries = MAX_FIRST_MENU_ITEM_FOCUS_RETRIES) => { - requestAnimationFrame(() => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { - return; - } - - focusFirstMenuItem(); - - if (!shouldDeferDismissButtonAccessibility || hasFocusedFirstItemOnCurrentOpenRef.current) { - return; - } - - if (retries <= 0) { - markFirstMenuItemFocused(); - return; - } - - firstMenuItemFocusRetryTimeoutRef.current = setTimeout(() => { - firstMenuItemFocusRetryTimeoutRef.current = null; - focusFirstMenuItemWithRetries(retries - 1); - }, FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS); - }); - }; - - clearScheduledFirstMenuItemFocus(); - firstMenuItemFocusRetryTimeoutRef.current = setTimeout( - () => { - firstMenuItemFocusRetryTimeoutRef.current = null; - focusFirstMenuItemWithRetries(); - }, - isIOS ? (animationInTiming ?? 0) : 0, - ); - }, [animationInTiming, clearScheduledFirstMenuItemFocus, focusFirstMenuItem, isIOS, markFirstMenuItemFocused, shouldDeferDismissButtonAccessibility]); - - const scheduleFocusFirstMenuItem = useCallback(() => { - if (isWeb) { - scheduleFocusFirstMenuItemOnWeb(); - return; - } - - scheduleFocusFirstMenuItemOnNative(); - }, [isWeb, scheduleFocusFirstMenuItemOnNative, scheduleFocusFirstMenuItemOnWeb]); - - const handleModalShow = useCallback(() => { - if (!shouldUseNarrowLayout) { - return; - } - - scheduleFocusFirstMenuItem(); - }, [scheduleFocusFirstMenuItem, shouldUseNarrowLayout]); - - useEffect(() => { - if (!isVisible || !shouldUseNarrowLayout || hasFocusedFirstItemOnCurrentOpenRef.current) { - return; - } - - scheduleFocusFirstMenuItem(); - }, [isVisible, scheduleFocusFirstMenuItem, shouldUseNarrowLayout]); - - useEffect(() => { - if (!isVisible || !shouldDeferDismissButtonAccessibility || focusedIndex !== 0 || hasFocusedFirstItemOnCurrentOpenRef.current) { - return; - } - - markFirstMenuItemFocused(); - }, [focusedIndex, isVisible, markFirstMenuItemFocused, shouldDeferDismissButtonAccessibility]); - return ( Date: Tue, 31 Mar 2026 09:57:39 +0430 Subject: [PATCH 24/36] fix(lint): defer bottom-docked dismiss accessibility state updates --- src/components/PopoverMenu.tsx | 2 +- src/hooks/useBottomDockedDismissAccessibility.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 255344e78aa0..9a78eca8483a 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/jsx-props-no-spreading */ import {deepEqual} from 'fast-equals'; import type {ReactNode, RefObject} from 'react'; -import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from 'react'; +import React, {useCallback, useLayoutEffect, useMemo, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; diff --git a/src/hooks/useBottomDockedDismissAccessibility.ts b/src/hooks/useBottomDockedDismissAccessibility.ts index 8a103c33a380..ccbfffb4aa61 100644 --- a/src/hooks/useBottomDockedDismissAccessibility.ts +++ b/src/hooks/useBottomDockedDismissAccessibility.ts @@ -71,7 +71,11 @@ function useBottomDockedDismissAccessibility({ return; } - markFirstMenuItemUnfocused(); + const animationFrame = requestAnimationFrame(() => { + markFirstMenuItemUnfocused(); + }); + + return () => cancelAnimationFrame(animationFrame); }, [isVisible, markFirstMenuItemUnfocused]); useEffect( @@ -229,7 +233,11 @@ function useBottomDockedDismissAccessibility({ return; } - markFirstMenuItemFocused(); + const animationFrame = requestAnimationFrame(() => { + markFirstMenuItemFocused(); + }); + + return () => cancelAnimationFrame(animationFrame); }, [focusedIndex, isVisible, markFirstMenuItemFocused, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); return { From 1e01e11c3350f627b517e0d6132f0cef950aef85 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 31 Mar 2026 11:04:53 +0430 Subject: [PATCH 25/36] fix(a11y): confirm first popover item focus before enabling dismiss on iOS --- src/components/PopoverMenu.tsx | 5 +++++ src/hooks/useBottomDockedDismissAccessibility.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 9a78eca8483a..94e2b997d62c 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -319,6 +319,7 @@ function BasePopoverMenu({ const prevMenuItems = usePrevious(menuItems); const { firstItemRef: firstMenuItemRef, + handleFirstItemFocus, handleModalShow, shouldEnableBottomDockedDismissAccessibility, } = useBottomDockedDismissAccessibility({ @@ -326,6 +327,7 @@ function BasePopoverMenu({ shouldActivate: isSmallScreenWidth, animationDelayMs: (animationInDelay ?? 0) + animationInTiming, onModalShow, + shouldConfirmFirstItemFocus: true, }); const selectItem = (index: number, event?: GestureResponderEvent | KeyboardEvent) => { @@ -431,6 +433,9 @@ function BasePopoverMenu({ shouldShowRightIcon={!!item.rightIcon} brickRoadIndicator={item.brickRoadIndicator} onFocus={() => { + if (menuIndex === 0) { + handleFirstItemFocus(); + } if (!shouldUpdateFocusedIndex) { return; } diff --git a/src/hooks/useBottomDockedDismissAccessibility.ts b/src/hooks/useBottomDockedDismissAccessibility.ts index ccbfffb4aa61..fa63a0aea6e8 100644 --- a/src/hooks/useBottomDockedDismissAccessibility.ts +++ b/src/hooks/useBottomDockedDismissAccessibility.ts @@ -19,6 +19,7 @@ type UseBottomDockedDismissAccessibilityParams = { type UseBottomDockedDismissAccessibilityResult = { firstItemRef: RefObject; + handleFirstItemFocus: () => void; handleModalShow: () => void; shouldEnableBottomDockedDismissAccessibility?: boolean; }; @@ -240,8 +241,17 @@ function useBottomDockedDismissAccessibility({ return () => cancelAnimationFrame(animationFrame); }, [focusedIndex, isVisible, markFirstMenuItemFocused, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); + const handleFirstItemFocus = useCallback(() => { + if (hasFocusedFirstItemOnCurrentOpenRef.current) { + return; + } + + markFirstMenuItemFocused(); + }, [markFirstMenuItemFocused]); + return { firstItemRef, + handleFirstItemFocus, handleModalShow, shouldEnableBottomDockedDismissAccessibility: shouldDeferDismissButtonAccessibility ? shouldEnableBottomDockedDismissAccessibility : undefined, }; From 3906aacbe2ff2a30bc254c63f8d5d4525cec7adb Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 7 Apr 2026 17:54:25 +0430 Subject: [PATCH 26/36] Fix accessibility focus for report filter popovers --- src/components/Modal/BaseModal.tsx | 1 - .../Modal/ReanimatedModal/Backdrop/index.tsx | 5 +- .../Modal/ReanimatedModal/index.tsx | 2 + src/components/Modal/ReanimatedModal/types.ts | 6 ++ src/components/Modal/types.ts | 6 +- .../FilterDropdowns/DateSelectPopup/index.tsx | 16 +++- .../Search/FilterDropdowns/DropdownButton.tsx | 96 +++++++++++++++++-- .../Search/FilterDropdowns/GroupByPopup.tsx | 16 +++- .../FilterDropdowns/MultiSelectPopup.tsx | 16 +++- .../FilterDropdowns/SingleSelectPopup.tsx | 15 ++- .../FilterDropdowns/UserSelectPopup.tsx | 38 +++++++- .../DatePickerFilterPopup.tsx | 3 +- .../SearchPageHeader/FeedFilterPopup.tsx | 8 +- .../MultiSelectFilterPopup.tsx | 3 +- .../SearchPageHeader/SearchActionsBarWide.tsx | 22 ++--- .../SearchFiltersBarNarrow.tsx | 1 + .../SearchPageHeader/SearchFiltersBarWide.tsx | 1 + .../SearchPageHeader/useSearchActionsBar.tsx | 29 ++++-- .../SearchPageHeader/useSearchFiltersBar.tsx | 29 ++++-- 19 files changed, 258 insertions(+), 55 deletions(-) diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index a04dff9b37c8..9d0681e6c445 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -272,7 +272,6 @@ function BaseModal({ ); const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose); - // PopoverMenu only passes this internal accessibility handshake on the native iOS path. const shouldHideBottomDockedDismissFromAccessibility = shouldShowBottomDockedDismissButton && shouldEnableBottomDockedDismissAccessibility === false; const modalPaddingStyles = useMemo(() => { diff --git a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx index 433317abba31..2df6bcbe8120 100644 --- a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx +++ b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx @@ -15,6 +15,7 @@ function Backdrop({ animationInTiming = CONST.MODAL.ANIMATION_TIMING.DEFAULT_IN, animationOutTiming = CONST.MODAL.ANIMATION_TIMING.DEFAULT_OUT, backdropOpacity = variables.overlayOpacity, + shouldEnableBottomDockedDismissAccessibility = true, }: BackdropProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -35,8 +36,8 @@ function Backdrop({ if (!customBackdrop) { return ( diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index 4ab09749bd9d..a6bf6160816d 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -48,6 +48,7 @@ function ReanimatedModal({ shouldIgnoreBackHandlerDuringTransition = false, shouldEnableNewFocusManagement, shouldReturnFocus, + shouldEnableBottomDockedDismissAccessibility, ...props }: ReanimatedModalProps) { const [isVisibleState, setIsVisibleState] = useState(isVisible); @@ -194,6 +195,7 @@ function ReanimatedModal({ animationOutTiming={animationOutTiming} animationInDelay={animationInDelay} backdropOpacity={backdropOpacity} + shouldEnableBottomDockedDismissAccessibility={shouldEnableBottomDockedDismissAccessibility} /> ); diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts index b3b446b7d312..4aeda36294d7 100644 --- a/src/components/Modal/ReanimatedModal/types.ts +++ b/src/components/Modal/ReanimatedModal/types.ts @@ -148,6 +148,9 @@ type ReanimatedModalProps = ViewProps & */ shouldReturnFocus?: boolean; + /** Whether bottom-docked dismiss affordances should be exposed to accessibility. */ + shouldEnableBottomDockedDismissAccessibility?: boolean; + /** Whether to ignore the back handler during transition */ shouldIgnoreBackHandlerDuringTransition?: boolean; }; @@ -176,6 +179,9 @@ type BackdropProps = { /** Shows backdrop content */ isBackdropVisible: boolean; + + /** Whether bottom-docked dismiss affordances should be exposed to accessibility. */ + shouldEnableBottomDockedDismissAccessibility?: boolean; }; type ContainerProps = { diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index b379acca4125..8b4b9a61bf85 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -126,9 +126,9 @@ type BaseModalProps = Partial & shouldDisplayBelowModals?: boolean; /** - * Internal accessibility handshake for bottom-docked popovers on native iOS. - * When `false`, the dismiss control stays hidden from accessibility until the first actionable item is focused. - * When `true` or `undefined`, the dismiss control is exposed to accessibility. + * Internal accessibility handshake for bottom-docked popovers on native mobile. + * When `false`, dismiss affordances stay hidden from accessibility until the caller enables them. + * When `true` or `undefined`, dismiss affordances are exposed to accessibility. */ shouldEnableBottomDockedDismissAccessibility?: boolean; diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index ac01ef270547..c227cefef197 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -13,6 +13,7 @@ import type {SearchDateValues} from '@libs/SearchQueryUtils'; import {getDateModifierTitle, getDateRangeDisplayValueFromFormValue} from '@libs/SearchQueryUtils'; import type {SearchDateModifier} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import type {ModalHeadingRef} from '../DropdownButton'; import ActionButtons from './ActionButtons'; import SelectedDateModifierHeader from './SelectedDateModifierHeader'; @@ -34,9 +35,12 @@ type DateSelectPopupProps = { /** Function to set the popover width dynamically */ setPopoverWidth?: (width: number | undefined) => void; + + /** Visible heading target for modal initial focus */ + modalHeadingRef?: ModalHeadingRef; }; -function DateSelectPopup({label, value, presets, closeOverlay, onChange, setPopoverWidth}: DateSelectPopupProps) { +function DateSelectPopup({label, value, presets, closeOverlay, onChange, setPopoverWidth, modalHeadingRef}: DateSelectPopupProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -191,7 +195,15 @@ function DateSelectPopup({label, value, presets, closeOverlay, onChange, setPopo return ( - {!selectedDateModifier && {label}} + {!selectedDateModifier && ( + + {label} + + )} extends {ref?: Ref} ? T | null : never; +type ModalHeadingRef = (node: ModalHeadingNode) => void; + type PopoverComponentProps = { isExpanded: boolean; closeOverlay: () => void; setPopoverWidth?: (width: number | undefined) => void; + modalHeadingRef?: ModalHeadingRef; }; type DropdownButtonProps = WithSentryLabel & { @@ -52,16 +57,32 @@ type DropdownButtonProps = WithSentryLabel & { /** Wrapper style for the outer view */ wrapperStyle?: StyleProp; + + /** Internal opt-in to delay dismiss accessibility for bottom-docked filter popovers */ + shouldDelayBottomDockedDismissAccessibility?: boolean; }; const PADDING_MODAL = 8; +const BOTTOM_DOCKED_DISMISS_ACCESSIBILITY_DELAY = 2500; const ANCHOR_ORIGIN = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, }; -function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medium = false, labelStyle, innerStyles, caretWrapperStyle, wrapperStyle, sentryLabel}: DropdownButtonProps) { +function DropdownButton({ + label, + value, + viewportOffsetTop, + PopoverComponent, + medium = false, + labelStyle, + innerStyles, + caretWrapperStyle, + wrapperStyle, + sentryLabel, + shouldDelayBottomDockedDismissAccessibility = false, +}: DropdownButtonProps) { // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHL and narrow layout // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -71,7 +92,10 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi const {windowHeight} = useWindowDimensions(); const triggerRef = useRef(null); const anchorRef = useRef(null); + const modalAccessibilityHeadingElementRef = useRef(null); + const dismissAccessibilityTimeoutRef = useRef | null>(null); const [isOverlayVisible, setIsOverlayVisible] = useState(false); + const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(true); const [customPopoverWidth, setCustomPopoverWidth] = useState(undefined); const {calculatePopoverPosition} = usePopoverPosition(); @@ -81,6 +105,13 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi }); const [willAlertModalBecomeVisible] = useOnyx(ONYXKEYS.MODAL, {selector: willAlertModalBecomeVisibleSelector}); + const isWeb = getPlatform() === CONST.PLATFORM.WEB; + const shouldUseWebModalHeadingFocus = shouldDelayBottomDockedDismissAccessibility && isSmallScreenWidth && isWeb; + const shouldDelayDismissAccessibilityForCurrentLayout = shouldDelayBottomDockedDismissAccessibility && isSmallScreenWidth && !isWeb; + + const handleModalHeadingRef = useCallback((node) => { + modalAccessibilityHeadingElementRef.current = node; + }, []); /** * Toggle the overlay between open & closed @@ -127,8 +158,58 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi }, [isSmallScreenWidth, styles, actualPopoverWidth]); const popoverContent = useMemo(() => { - return PopoverComponent({closeOverlay: toggleOverlay, isExpanded: isOverlayVisible, setPopoverWidth: setCustomPopoverWidth}); - }, [PopoverComponent, toggleOverlay, isOverlayVisible]); + return PopoverComponent({ + closeOverlay: toggleOverlay, + isExpanded: isOverlayVisible, + setPopoverWidth: setCustomPopoverWidth, + modalHeadingRef: shouldUseWebModalHeadingFocus ? handleModalHeadingRef : undefined, + }); + }, [PopoverComponent, toggleOverlay, isOverlayVisible, shouldUseWebModalHeadingFocus, handleModalHeadingRef]); + + const initialFocus = shouldUseWebModalHeadingFocus ? () => modalAccessibilityHeadingElementRef.current as never : undefined; + + const clearDismissAccessibilityTimeout = useCallback(() => { + if (!dismissAccessibilityTimeoutRef.current) { + return; + } + + clearTimeout(dismissAccessibilityTimeoutRef.current); + dismissAccessibilityTimeoutRef.current = null; + }, []); + + const handleModalShow = useCallback(() => { + if (!shouldDelayDismissAccessibilityForCurrentLayout) { + return; + } + + clearDismissAccessibilityTimeout(); + setShouldEnableBottomDockedDismissAccessibility(false); + dismissAccessibilityTimeoutRef.current = setTimeout(() => { + setShouldEnableBottomDockedDismissAccessibility(true); + dismissAccessibilityTimeoutRef.current = null; + }, BOTTOM_DOCKED_DISMISS_ACCESSIBILITY_DELAY); + }, [clearDismissAccessibilityTimeout, shouldDelayDismissAccessibilityForCurrentLayout]); + + useEffect(() => { + if (!shouldDelayDismissAccessibilityForCurrentLayout) { + setShouldEnableBottomDockedDismissAccessibility(true); + return; + } + + if (isOverlayVisible) { + return; + } + + clearDismissAccessibilityTimeout(); + setShouldEnableBottomDockedDismissAccessibility(false); + }, [clearDismissAccessibilityTimeout, isOverlayVisible, shouldDelayDismissAccessibilityForCurrentLayout]); + + useEffect( + () => () => { + clearDismissAccessibilityTimeout(); + }, + [clearDismissAccessibilityTimeout], + ); return ( void; + + /** Visible heading target for modal initial focus */ + modalHeadingRef?: ModalHeadingRef; }; -function GroupByPopup({label, value, sections, style, closeOverlay, onChange}: GroupByPopupProps) { +function GroupByPopup({label, value, sections, style, closeOverlay, onChange, modalHeadingRef}: GroupByPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -87,7 +91,15 @@ function GroupByPopup({label, value, sections, style, closeOverlay, onChange}: G return ( - {isSmallScreenWidth && !!label && {label}} + {isSmallScreenWidth && !!label && ( + + {label} + + )} = { text: string; @@ -46,9 +47,12 @@ type MultiSelectPopupProps = { /** Whether the data for the popover is loading */ loading?: boolean; + + /** Visible heading target for modal initial focus */ + modalHeadingRef?: ModalHeadingRef; }; -function MultiSelectPopup({label, loading, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder}: MultiSelectPopupProps) { +function MultiSelectPopup({label, loading, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder, modalHeadingRef}: MultiSelectPopupProps) { const theme = useTheme(); const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -111,7 +115,15 @@ function MultiSelectPopup({label, loading, value, items, close return ( - {isSmallScreenWidth && {label}} + {isSmallScreenWidth && ( + + {label} + + )} {!!loading && ( diff --git a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx index 7e98978530b4..3f246202a4b8 100644 --- a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx @@ -12,6 +12,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; +import type {ModalHeadingRef} from './DropdownButton'; type SingleSelectItem = { text: string; @@ -47,6 +48,9 @@ type SingleSelectPopupProps = { /** Custom styles for the SelectionList */ selectionListStyle?: SelectionListStyle; + + /** Visible heading target for modal initial focus */ + modalHeadingRef?: ModalHeadingRef; }; function SingleSelectPopup({ @@ -60,6 +64,7 @@ function SingleSelectPopup({ defaultValue, style, selectionListStyle, + modalHeadingRef, }: SingleSelectPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -132,7 +137,15 @@ function SingleSelectPopup({ return ( - {shouldShowLabel && {label}} + {shouldShowLabel && ( + + {label} + + )} | null>(null); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -148,10 +157,17 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele if (debouncedSearchTerm) { return; } - setTotalOptionsCount(selectedOptionsForDisplay.length + availableOptions.personalDetails.length + availableOptions.recentReports.length); + + const animationFrame = requestAnimationFrame(() => { + setTotalOptionsCount(selectedOptionsForDisplay.length + availableOptions.personalDetails.length + availableOptions.recentReports.length); + }); + + return () => cancelAnimationFrame(animationFrame); }, [debouncedSearchTerm, selectedOptionsForDisplay.length, availableOptions.personalDetails.length, availableOptions.recentReports.length]); const shouldShowSearchInput = isSearchable ?? totalOptionsCount >= CONST.STANDARD_LIST_ITEM_LIMIT; + const headingHeightOffset = shouldUseNarrowLayout && !!label ? variables.lineHeightLarge + 8 : 0; + const popoverHeightStyle = styles.getUserSelectionListPopoverHeight(listData.length || 1, windowHeight, shouldUseNarrowLayout, shouldShowSearchInput); const textInputOptions = useMemo( () => @@ -168,7 +184,23 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele ); return ( - + 0 && { + height: popoverHeightStyle.height + headingHeightOffset, + }, + ]} + > + {shouldUseNarrowLayout && !!label && ( + + {label} + + )} ) => void; }; -function DatePickerFilterPopup({closeOverlay, setPopoverWidth, filterKey, value, translationKey, updateFilterForm}: DatePickerFilterPopupProps) { +function DatePickerFilterPopup({closeOverlay, setPopoverWidth, filterKey, value, translationKey, updateFilterForm, modalHeadingRef}: DatePickerFilterPopupProps) { const {translate} = useLocalize(); const onChange = (selectedDates: SearchDateValues) => { const dateFormValues: Record = {}; @@ -34,6 +34,7 @@ function DatePickerFilterPopup({closeOverlay, setPopoverWidth, filterKey, value, closeOverlay={closeOverlay} setPopoverWidth={setPopoverWidth} presets={getDatePresets(filterKey, true)} + modalHeadingRef={modalHeadingRef} /> ); } diff --git a/src/components/Search/SearchPageHeader/FeedFilterPopup.tsx b/src/components/Search/SearchPageHeader/FeedFilterPopup.tsx index 7b9aba95f226..68769c3dbc32 100644 --- a/src/components/Search/SearchPageHeader/FeedFilterPopup.tsx +++ b/src/components/Search/SearchPageHeader/FeedFilterPopup.tsx @@ -1,4 +1,5 @@ import React, {useEffect} from 'react'; +import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/DropdownButton'; import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -6,15 +7,13 @@ import {openSearchCardFiltersPage} from '@libs/actions/Search'; import ONYXKEYS from '@src/ONYXKEYS'; import MultiSelectFilterPopup from './MultiSelectFilterPopup'; -type FeedFilterPopupProps = { - isExpanded: boolean; +type FeedFilterPopupProps = Pick & { items: Array>; value: Array>; - closeOverlay: () => void; onChangeCallback: (items: Array>) => void; }; -function FeedFilterPopup({closeOverlay, items, value, isExpanded, onChangeCallback}: FeedFilterPopupProps) { +function FeedFilterPopup({closeOverlay, items, value, isExpanded, onChangeCallback, modalHeadingRef}: FeedFilterPopupProps) { const {isOffline} = useNetwork(); const [areCardsLoaded] = useOnyx(ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED); @@ -36,6 +35,7 @@ function FeedFilterPopup({closeOverlay, items, value, isExpanded, onChangeCallba translationKey="search.filters.feed" closeOverlay={closeOverlay} onChangeCallback={onChangeCallback} + modalHeadingRef={modalHeadingRef} /> ); } diff --git a/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx b/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx index 50af2ba45032..7b366767db9e 100644 --- a/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx +++ b/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx @@ -14,11 +14,12 @@ type MultiSelectFilterPopupProps = PopoverComponentProps & { isSearchable?: boolean; }; -function MultiSelectFilterPopup({closeOverlay, loading, translationKey, items, value, onChangeCallback, isSearchable}: MultiSelectFilterPopupProps) { +function MultiSelectFilterPopup({closeOverlay, loading, translationKey, items, value, onChangeCallback, isSearchable, modalHeadingRef}: MultiSelectFilterPopupProps) { const {translate} = useLocalize(); return ( ; -function FromDropdown({label, value, PopoverComponent, sentryLabel}: SearchDropdownProps) { +function FromDropdown({value, ...rest}: SearchDropdownProps) { const fromValue = useFilterFromValue(value); return ( ); } -function WorkspaceDropdown({label, value, PopoverComponent, sentryLabel}: SearchDropdownProps) { +function WorkspaceDropdown({value, ...rest}: SearchDropdownProps) { const workspaceValue = useFilterWorkspaceValue(value); return ( ); } -function FeedDropdown({label, value, PopoverComponent, sentryLabel}: SearchDropdownProps) { +function FeedDropdown({value, ...rest}: SearchDropdownProps) { const feedValue = useFilterFeedValue(value); return ( ); } @@ -111,6 +108,7 @@ function SearchActionsBarWide({queryJSON, searchResults, handleSearch, onSort}: label={item.label} value={item.value} PopoverComponent={item.PopoverComponent} + shouldDelayBottomDockedDismissAccessibility sentryLabel={item.sentryLabel} /> ); diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx index cbfc8e23de4f..5d16373ba87b 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx @@ -43,6 +43,7 @@ function SearchFiltersBarNarrow({queryJSON, isMobileSelectionModeEnabled}: Searc label={item.label} value={item.value} PopoverComponent={item.PopoverComponent} + shouldDelayBottomDockedDismissAccessibility sentryLabel={item.sentryLabel} /> ); diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx index 97f2d08a7a19..ce528751f65a 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx @@ -61,6 +61,7 @@ function SearchFiltersBarWide({queryJSON, isMobileSelectionModeEnabled}: SearchF label={item.label} value={item.value} PopoverComponent={item.PopoverComponent} + shouldDelayBottomDockedDismissAccessibility sentryLabel={item.sentryLabel} /> ))} diff --git a/src/components/Search/SearchPageHeader/useSearchActionsBar.tsx b/src/components/Search/SearchPageHeader/useSearchActionsBar.tsx index 85797706855f..19fff85c8737 100644 --- a/src/components/Search/SearchPageHeader/useSearchActionsBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchActionsBar.tsx @@ -83,7 +83,7 @@ function typeOptionsPoliciesSelector(policies: OnyxCollection): OnyxColl return result; } -function GroupCurrencyPopup({updateFilterForm, closeOverlay}: FilterBarPopupProps) { +function GroupCurrencyPopup({updateFilterForm, closeOverlay, modalHeadingRef}: FilterBarPopupProps) { const {translate} = useLocalize(); const {currencyList} = useCurrencyListState(); const {getCurrencySymbol} = useCurrencyListActions(); @@ -95,6 +95,7 @@ function GroupCurrencyPopup({updateFilterForm, closeOverlay}: FilterBarPopupProp return ( ( + PopoverComponent: ({closeOverlay, isExpanded, modalHeadingRef}) => ( ), sentryLabel: CONST.SENTRY_LABEL.SEARCH.FILTER_GROUP_CURRENCY, @@ -277,6 +282,7 @@ function useSearchActionsBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn ( + PopoverComponent: ({closeOverlay, isExpanded, modalHeadingRef}) => ( ), sentryLabel: CONST.SENTRY_LABEL.SEARCH.FILTER_FEED, @@ -333,9 +341,10 @@ function useSearchActionsBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn const withdrawalType = searchAdvancedFiltersForm[filterKey]; const withdrawalTypeOptions = getWithdrawalTypeOptions(translate); const withdrawalTypeValue = withdrawalTypeOptions.find((option) => option.value === withdrawalType) ?? null; - const withdrawalTypeComponent = ({closeOverlay}: PopoverComponentProps) => ( + const withdrawalTypeComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( ( + const userPickerComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( updateFilterForm({from: selectedUsers})} @@ -403,12 +415,13 @@ function useSearchActionsBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn } case FILTER_KEYS.POLICY_ID: return { - PopoverComponent: ({closeOverlay, isExpanded}) => ( + PopoverComponent: ({closeOverlay, isExpanded, modalHeadingRef}) => ( ), sentryLabel: CONST.SENTRY_LABEL.SEARCH.FILTER_WORKSPACE, diff --git a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx index c87f02e5b22e..ff22f9b57895 100644 --- a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx @@ -307,9 +307,10 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn Navigation.navigate(ROUTES.SEARCH_COLUMNS); }; - const typeComponent = ({closeOverlay}: PopoverComponentProps) => ( + const typeComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( ); - const groupByComponent = ({closeOverlay}: PopoverComponentProps) => ( + const groupByComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( ); - const viewComponent = ({closeOverlay}: PopoverComponentProps) => ( + const viewComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( ); - const groupCurrencyComponent = ({closeOverlay}: PopoverComponentProps) => ( + const groupCurrencyComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( ); - const withdrawalTypeComponent = ({closeOverlay}: PopoverComponentProps) => ( + const withdrawalTypeComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( ); - const userPickerComponent = ({closeOverlay}: PopoverComponentProps) => { + const userPickerComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => { const value = searchAdvancedFiltersForm.from ?? []; return ( updateFilterForm({from: selectedUsers})} @@ -474,9 +488,10 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn updateFilterForm({policyID: items.map((item) => item.value)}); }; - const workspaceComponent = ({closeOverlay}: PopoverComponentProps) => ( + const workspaceComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( Date: Tue, 7 Apr 2026 18:08:59 +0430 Subject: [PATCH 27/36] fixed type error --- src/components/Modal/ReanimatedModal/Backdrop/index.tsx | 5 +++-- src/components/Modal/ReanimatedModal/types.ts | 4 ++-- src/components/Modal/types.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx index 2df6bcbe8120..10d85d5d087d 100644 --- a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx +++ b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx @@ -36,8 +36,9 @@ function Backdrop({ if (!customBackdrop) { return ( diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts index 4aeda36294d7..30aa51db1e84 100644 --- a/src/components/Modal/ReanimatedModal/types.ts +++ b/src/components/Modal/ReanimatedModal/types.ts @@ -148,7 +148,7 @@ type ReanimatedModalProps = ViewProps & */ shouldReturnFocus?: boolean; - /** Whether bottom-docked dismiss affordances should be exposed to accessibility. */ + /** Whether bottom-docked dismiss controls should be exposed to accessibility. */ shouldEnableBottomDockedDismissAccessibility?: boolean; /** Whether to ignore the back handler during transition */ @@ -180,7 +180,7 @@ type BackdropProps = { /** Shows backdrop content */ isBackdropVisible: boolean; - /** Whether bottom-docked dismiss affordances should be exposed to accessibility. */ + /** Whether bottom-docked dismiss controls should be exposed to accessibility. */ shouldEnableBottomDockedDismissAccessibility?: boolean; }; diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 8b4b9a61bf85..2e22ce036941 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -127,8 +127,8 @@ type BaseModalProps = Partial & /** * Internal accessibility handshake for bottom-docked popovers on native mobile. - * When `false`, dismiss affordances stay hidden from accessibility until the caller enables them. - * When `true` or `undefined`, dismiss affordances are exposed to accessibility. + * When `false`, dismiss controls stay hidden from accessibility until the caller enables them. + * When `true` or `undefined`, dismiss controls are exposed to accessibility. */ shouldEnableBottomDockedDismissAccessibility?: boolean; From 7e8d595150c9ec0db0f89c18a2e38d0432608bfa Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 8 Apr 2026 10:10:00 +0430 Subject: [PATCH 28/36] Fix lint and type --- .../Modal/ReanimatedModal/Backdrop/index.tsx | 17 ++++- src/components/PopoverMenu.tsx | 2 +- .../FilterDropdowns/DateSelectPopup/index.tsx | 2 +- .../Search/FilterDropdowns/DropdownButton.tsx | 70 ++++++++----------- 4 files changed, 44 insertions(+), 47 deletions(-) diff --git a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx index 10d85d5d087d..4405583ca238 100644 --- a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx +++ b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx @@ -34,11 +34,22 @@ function Backdrop({ ); if (!customBackdrop) { + if (shouldEnableBottomDockedDismissAccessibility) { + return ( + + {BackdropOverlay} + + ); + } + return ( diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index eec46af328dd..7cd48b0bb1e2 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -570,7 +570,7 @@ function BasePopoverMenu({ } return stylesArray; - }, [isSmallScreenWidth, shouldEnableMaxHeight, styles.createMenuContainer, shouldUseScrollView]); + }, [isInLandscapeMode, isSmallScreenWidth, shouldEnableMaxHeight, styles.createMenuContainer, shouldUseScrollView]); const {paddingTop, paddingBottom, paddingVertical, ...restScrollContainerStyle} = (StyleSheet.flatten([styles.pv4, scrollContainerStyle]) as ViewStyle) ?? {}; const { diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index c227cefef197..ce6d7d23e596 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native'; import ScrollView from '@components/ScrollView'; import DatePresetFilterBase from '@components/Search/FilterComponents/DatePresetFilterBase'; import type {SearchDatePresetFilterBaseHandle} from '@components/Search/FilterComponents/DatePresetFilterBase'; +import type {ModalHeadingRef} from '@components/Search/FilterDropdowns/DropdownButton'; import type {SearchDatePreset} from '@components/Search/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -13,7 +14,6 @@ import type {SearchDateValues} from '@libs/SearchQueryUtils'; import {getDateModifierTitle, getDateRangeDisplayValueFromFormValue} from '@libs/SearchQueryUtils'; import type {SearchDateModifier} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import type {ModalHeadingRef} from '../DropdownButton'; import ActionButtons from './ActionButtons'; import SelectedDateModifierHeader from './SelectedDateModifierHeader'; diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index e886bce34533..2d0814b3a98d 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -1,5 +1,5 @@ import {willAlertModalBecomeVisibleSelector} from '@selectors/Modal'; -import type {ComponentProps, ReactNode, Ref} from 'react'; +import type {ComponentProps, ComponentType, Ref} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; @@ -41,7 +41,7 @@ type DropdownButtonProps = WithSentryLabel & { viewportOffsetTop: number; /** The component to render in the popover */ - PopoverComponent: (props: PopoverComponentProps) => ReactNode; + PopoverComponent: ComponentType; /** Whether to use medium size button instead of small */ medium?: boolean; @@ -113,18 +113,31 @@ function DropdownButton({ modalAccessibilityHeadingElementRef.current = node; }, []); + const clearDismissAccessibilityTimeout = useCallback(() => { + if (!dismissAccessibilityTimeoutRef.current) { + return; + } + + clearTimeout(dismissAccessibilityTimeoutRef.current); + dismissAccessibilityTimeoutRef.current = null; + }, []); + + const updateDismissAccessibilityForLayout = useCallback(() => { + clearDismissAccessibilityTimeout(); + setShouldEnableBottomDockedDismissAccessibility(!shouldDelayDismissAccessibilityForCurrentLayout); + }, [clearDismissAccessibilityTimeout, shouldDelayDismissAccessibilityForCurrentLayout]); + /** * Toggle the overlay between open & closed */ const toggleOverlay = useCallback(() => { - setIsOverlayVisible((previousValue) => { - if (!previousValue && willAlertModalBecomeVisible) { - return false; - } + if (!isOverlayVisible && willAlertModalBecomeVisible) { + return; + } - return !previousValue; - }); - }, [willAlertModalBecomeVisible]); + updateDismissAccessibilityForLayout(); + setIsOverlayVisible((previousValue) => !previousValue); + }, [isOverlayVisible, updateDismissAccessibilityForLayout, willAlertModalBecomeVisible]); /** * Calculate popover position and toggle overlay @@ -157,26 +170,8 @@ function DropdownButton({ return {width: actualPopoverWidth}; }, [isSmallScreenWidth, styles, actualPopoverWidth]); - const popoverContent = useMemo(() => { - return PopoverComponent({ - closeOverlay: toggleOverlay, - isExpanded: isOverlayVisible, - setPopoverWidth: setCustomPopoverWidth, - modalHeadingRef: shouldUseWebModalHeadingFocus ? handleModalHeadingRef : undefined, - }); - }, [PopoverComponent, toggleOverlay, isOverlayVisible, shouldUseWebModalHeadingFocus, handleModalHeadingRef]); - const initialFocus = shouldUseWebModalHeadingFocus ? () => modalAccessibilityHeadingElementRef.current as never : undefined; - const clearDismissAccessibilityTimeout = useCallback(() => { - if (!dismissAccessibilityTimeoutRef.current) { - return; - } - - clearTimeout(dismissAccessibilityTimeoutRef.current); - dismissAccessibilityTimeoutRef.current = null; - }, []); - const handleModalShow = useCallback(() => { if (!shouldDelayDismissAccessibilityForCurrentLayout) { return; @@ -190,20 +185,6 @@ function DropdownButton({ }, BOTTOM_DOCKED_DISMISS_ACCESSIBILITY_DELAY); }, [clearDismissAccessibilityTimeout, shouldDelayDismissAccessibilityForCurrentLayout]); - useEffect(() => { - if (!shouldDelayDismissAccessibilityForCurrentLayout) { - setShouldEnableBottomDockedDismissAccessibility(true); - return; - } - - if (isOverlayVisible) { - return; - } - - clearDismissAccessibilityTimeout(); - setShouldEnableBottomDockedDismissAccessibility(false); - }, [clearDismissAccessibilityTimeout, isOverlayVisible, shouldDelayDismissAccessibilityForCurrentLayout]); - useEffect( () => () => { clearDismissAccessibilityTimeout(); @@ -267,7 +248,12 @@ function DropdownButton({ shouldDisplayBelowModals shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={false} > - {popoverContent} + ); From 513496a8e68c93d7d0a627b308b1525db420f40d Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 9 Apr 2026 10:15:04 +0430 Subject: [PATCH 29/36] Scope iOS dismiss deferral to active bottom-docked popovers --- src/hooks/useBottomDockedDismissAccessibility.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useBottomDockedDismissAccessibility.ts b/src/hooks/useBottomDockedDismissAccessibility.ts index fa63a0aea6e8..0737b6eadcb7 100644 --- a/src/hooks/useBottomDockedDismissAccessibility.ts +++ b/src/hooks/useBottomDockedDismissAccessibility.ts @@ -41,8 +41,8 @@ function useBottomDockedDismissAccessibility({ const isWeb = platform === CONST.PLATFORM.WEB; const isAndroid = platform === CONST.PLATFORM.ANDROID; const isIOS = platform === CONST.PLATFORM.IOS; - // Native iOS can announce dismiss before the first item unless it stays hidden until focus lands. - const shouldDeferDismissButtonAccessibility = isIOS; + // Active iOS bottom-docked popovers can announce dismiss before the first item unless it stays hidden until focus lands. + const shouldDeferDismissButtonAccessibility = isIOS && shouldActivate; const firstItemRef = useRef(null); const isVisibleRef = useRef(isVisible); const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); From 6558f4e2826b63394a21500e47f379f1f2e9b887 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sat, 11 Apr 2026 15:02:11 +0430 Subject: [PATCH 30/36] Fix chat create popover accessibility focus on reopen --- .../Search/FilterDropdowns/BasePopup.tsx | 15 ++++- .../MultiSelectFilterPopup.tsx | 3 +- .../AttachmentPickerWithMenuItems.tsx | 64 +++++++++---------- 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/src/components/Search/FilterDropdowns/BasePopup.tsx b/src/components/Search/FilterDropdowns/BasePopup.tsx index 845efe384cc7..4176307d2a11 100644 --- a/src/components/Search/FilterDropdowns/BasePopup.tsx +++ b/src/components/Search/FilterDropdowns/BasePopup.tsx @@ -5,6 +5,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Text from '@components/Text'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {ModalHeadingRef} from './DropdownButton'; import ActionButtons from './ActionButtons'; type BasePopupProps = React.PropsWithChildren & { @@ -15,9 +16,10 @@ type BasePopupProps = React.PropsWithChildren & { onApply: () => void; onReset: () => void; onBackButtonPress?: () => void; + modalHeadingRef?: ModalHeadingRef; }; -function BasePopup({children, label, applySentryLabel, resetSentryLabel, style, onApply, onReset, onBackButtonPress}: BasePopupProps) { +function BasePopup({children, label, applySentryLabel, resetSentryLabel, style, onApply, onReset, onBackButtonPress, modalHeadingRef}: BasePopupProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -32,7 +34,16 @@ function BasePopup({children, label, applySentryLabel, resetSentryLabel, style, onBackButtonPress={onBackButtonPress} /> ) : ( - isSmallScreenWidth && !!label && {label} + isSmallScreenWidth && + !!label && ( + + {label} + + ) )} {children} = { +type MultiSelectFilterPopupProps = Pick & { loading?: boolean; translationKey: TranslationPaths; items: Array>; diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 347472be3fa5..a3da37031996 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -1,6 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; -import React, {Activity, useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import AttachmentPicker from '@components/AttachmentPicker'; @@ -532,39 +532,37 @@ function AttachmentPickerWithMenuItems({ )} - - { - setMenuVisibility(false); - onItemSelected(); - - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === menuItems.length - 1) { - if (isSafari()) { - triggerAttachmentPicker(); - return; - } - close(() => { - triggerAttachmentPicker(); - }); + { + setMenuVisibility(false); + onItemSelected(); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user on Safari. + if (index === menuItems.length - 1) { + if (isSafari()) { + triggerAttachmentPicker(); + return; } - }} - anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - }} - menuItems={menuItems} - anchorRef={actionButtonRef} - /> - + close(() => { + triggerAttachmentPicker(); + }); + } + }} + anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + menuItems={menuItems} + anchorRef={actionButtonRef} + /> ); }} From 194236943b69c8758359d627a9396e215722344a Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sat, 11 Apr 2026 16:10:58 +0430 Subject: [PATCH 31/36] fixed prettier --- src/components/Search/FilterDropdowns/BasePopup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/FilterDropdowns/BasePopup.tsx b/src/components/Search/FilterDropdowns/BasePopup.tsx index 4176307d2a11..9217ec014ccb 100644 --- a/src/components/Search/FilterDropdowns/BasePopup.tsx +++ b/src/components/Search/FilterDropdowns/BasePopup.tsx @@ -5,8 +5,8 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Text from '@components/Text'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ModalHeadingRef} from './DropdownButton'; import ActionButtons from './ActionButtons'; +import type {ModalHeadingRef} from './DropdownButton'; type BasePopupProps = React.PropsWithChildren & { label?: string; From d111530c66331452373077365b336d51e6cc6d33 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 13 Apr 2026 15:33:02 +0430 Subject: [PATCH 32/36] Unify filter modal accessibility handling into shared hook --- .../Search/FilterDropdowns/DropdownButton.tsx | 62 ++--- .../useBottomDockedDismissAccessibility.ts | 247 ++++++++++++------ 2 files changed, 178 insertions(+), 131 deletions(-) diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index 2d0814b3a98d..fa9876eb2f4f 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -1,6 +1,6 @@ import {willAlertModalBecomeVisibleSelector} from '@selectors/Modal'; import type {ComponentProps, ComponentType, Ref} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Button from '@components/Button'; @@ -8,13 +8,13 @@ import CaretWrapper from '@components/CaretWrapper'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import Text from '@components/Text'; import withViewportOffsetTop from '@components/withViewportOffsetTop'; +import useBottomDockedDismissAccessibility from '@hooks/useBottomDockedDismissAccessibility'; import useOnyx from '@hooks/useOnyx'; import usePopoverPosition from '@hooks/usePopoverPosition'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -93,9 +93,7 @@ function DropdownButton({ const triggerRef = useRef(null); const anchorRef = useRef(null); const modalAccessibilityHeadingElementRef = useRef(null); - const dismissAccessibilityTimeoutRef = useRef | null>(null); const [isOverlayVisible, setIsOverlayVisible] = useState(false); - const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(true); const [customPopoverWidth, setCustomPopoverWidth] = useState(undefined); const {calculatePopoverPosition} = usePopoverPosition(); @@ -105,27 +103,22 @@ function DropdownButton({ }); const [willAlertModalBecomeVisible] = useOnyx(ONYXKEYS.MODAL, {selector: willAlertModalBecomeVisibleSelector}); - const isWeb = getPlatform() === CONST.PLATFORM.WEB; - const shouldUseWebModalHeadingFocus = shouldDelayBottomDockedDismissAccessibility && isSmallScreenWidth && isWeb; - const shouldDelayDismissAccessibilityForCurrentLayout = shouldDelayBottomDockedDismissAccessibility && isSmallScreenWidth && !isWeb; + const shouldUseFilterModalAccessibility = shouldDelayBottomDockedDismissAccessibility && isSmallScreenWidth; const handleModalHeadingRef = useCallback((node) => { modalAccessibilityHeadingElementRef.current = node; }, []); - const clearDismissAccessibilityTimeout = useCallback(() => { - if (!dismissAccessibilityTimeoutRef.current) { - return; - } - - clearTimeout(dismissAccessibilityTimeoutRef.current); - dismissAccessibilityTimeoutRef.current = null; - }, []); - - const updateDismissAccessibilityForLayout = useCallback(() => { - clearDismissAccessibilityTimeout(); - setShouldEnableBottomDockedDismissAccessibility(!shouldDelayDismissAccessibilityForCurrentLayout); - }, [clearDismissAccessibilityTimeout, shouldDelayDismissAccessibilityForCurrentLayout]); + const {handleModalShow, initialFocus, shouldEnableBottomDockedDismissAccessibility} = useBottomDockedDismissAccessibility({ + isVisible: isOverlayVisible, + shouldActivate: shouldUseFilterModalAccessibility, + animationDelayMs: 0, + focusTargetRef: modalAccessibilityHeadingElementRef, + webFocusMode: 'initialFocus', + dismissAccessibilityMode: 'timer', + dismissAccessibilityPlatforms: 'native', + dismissAccessibilityDelayMs: BOTTOM_DOCKED_DISMISS_ACCESSIBILITY_DELAY, + }); /** * Toggle the overlay between open & closed @@ -135,9 +128,8 @@ function DropdownButton({ return; } - updateDismissAccessibilityForLayout(); setIsOverlayVisible((previousValue) => !previousValue); - }, [isOverlayVisible, updateDismissAccessibilityForLayout, willAlertModalBecomeVisible]); + }, [isOverlayVisible, willAlertModalBecomeVisible]); /** * Calculate popover position and toggle overlay @@ -170,28 +162,6 @@ function DropdownButton({ return {width: actualPopoverWidth}; }, [isSmallScreenWidth, styles, actualPopoverWidth]); - const initialFocus = shouldUseWebModalHeadingFocus ? () => modalAccessibilityHeadingElementRef.current as never : undefined; - - const handleModalShow = useCallback(() => { - if (!shouldDelayDismissAccessibilityForCurrentLayout) { - return; - } - - clearDismissAccessibilityTimeout(); - setShouldEnableBottomDockedDismissAccessibility(false); - dismissAccessibilityTimeoutRef.current = setTimeout(() => { - setShouldEnableBottomDockedDismissAccessibility(true); - dismissAccessibilityTimeoutRef.current = null; - }, BOTTOM_DOCKED_DISMISS_ACCESSIBILITY_DELAY); - }, [clearDismissAccessibilityTimeout, shouldDelayDismissAccessibilityForCurrentLayout]); - - useEffect( - () => () => { - clearDismissAccessibilityTimeout(); - }, - [clearDismissAccessibilityTimeout], - ); - return ( diff --git a/src/hooks/useBottomDockedDismissAccessibility.ts b/src/hooks/useBottomDockedDismissAccessibility.ts index 0737b6eadcb7..99e8adb8f9bb 100644 --- a/src/hooks/useBottomDockedDismissAccessibility.ts +++ b/src/hooks/useBottomDockedDismissAccessibility.ts @@ -1,11 +1,17 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import type {RefObject} from 'react'; import {AccessibilityInfo} from 'react-native'; -import type {View as RNView} from 'react-native'; +import type {ReactNativeElement, View as RNView} from 'react-native'; +import type BaseModalProps from '@components/Modal/types'; import Accessibility from '@libs/Accessibility'; import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; +type WebFocusMode = 'manual' | 'initialFocus' | 'none'; +type DismissAccessibilityMode = 'none' | 'focusConfirmation' | 'timer'; +type DismissAccessibilityPlatforms = 'ios' | 'native'; +type FocusableElement = {focus: () => void}; + type UseBottomDockedDismissAccessibilityParams = { isVisible: boolean; shouldActivate: boolean; @@ -15,6 +21,11 @@ type UseBottomDockedDismissAccessibilityParams = { focusedIndex?: number; maxFocusRetries?: number; nativeFocusRetryDelayMs?: number; + focusTargetRef?: RefObject; + webFocusMode?: WebFocusMode; + dismissAccessibilityMode?: DismissAccessibilityMode; + dismissAccessibilityPlatforms?: DismissAccessibilityPlatforms; + dismissAccessibilityDelayMs?: number; }; type UseBottomDockedDismissAccessibilityResult = { @@ -22,10 +33,17 @@ type UseBottomDockedDismissAccessibilityResult = { handleFirstItemFocus: () => void; handleModalShow: () => void; shouldEnableBottomDockedDismissAccessibility?: boolean; + initialFocus?: BaseModalProps['initialFocus']; }; const DEFAULT_MAX_FIRST_MENU_ITEM_FOCUS_RETRIES = 5; const DEFAULT_FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS = 50; +const DEFAULT_WEB_FOCUS_MODE: WebFocusMode = 'manual'; +const DEFAULT_DISMISS_ACCESSIBILITY_PLATFORMS: DismissAccessibilityPlatforms = 'ios'; + +function isFocusableElement(target: unknown): target is FocusableElement { + return typeof target === 'object' && target !== null && 'focus' in target && typeof target.focus === 'function'; +} function useBottomDockedDismissAccessibility({ isVisible, @@ -36,35 +54,55 @@ function useBottomDockedDismissAccessibility({ focusedIndex, maxFocusRetries = DEFAULT_MAX_FIRST_MENU_ITEM_FOCUS_RETRIES, nativeFocusRetryDelayMs = DEFAULT_FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS, + focusTargetRef, + webFocusMode = DEFAULT_WEB_FOCUS_MODE, + dismissAccessibilityMode, + dismissAccessibilityPlatforms = DEFAULT_DISMISS_ACCESSIBILITY_PLATFORMS, + dismissAccessibilityDelayMs, }: UseBottomDockedDismissAccessibilityParams): UseBottomDockedDismissAccessibilityResult { const platform = getPlatform(); const isWeb = platform === CONST.PLATFORM.WEB; const isAndroid = platform === CONST.PLATFORM.ANDROID; const isIOS = platform === CONST.PLATFORM.IOS; - // Active iOS bottom-docked popovers can announce dismiss before the first item unless it stays hidden until focus lands. - const shouldDeferDismissButtonAccessibility = isIOS && shouldActivate; + const resolvedDismissAccessibilityMode = dismissAccessibilityMode ?? (shouldConfirmFirstItemFocus ? 'focusConfirmation' : 'none'); + const shouldUseManualWebFocus = isWeb && shouldActivate && webFocusMode === 'manual'; + const shouldUseInitialWebFocus = isWeb && shouldActivate && webFocusMode === 'initialFocus'; + const shouldUseNativeFocusHandoff = !isWeb && shouldActivate && resolvedDismissAccessibilityMode !== 'timer'; + const shouldDeferDismissButtonAccessibility = + shouldActivate && + resolvedDismissAccessibilityMode !== 'none' && + ((dismissAccessibilityPlatforms === 'native' && (isIOS || isAndroid)) || (dismissAccessibilityPlatforms === 'ios' && isIOS)); const firstItemRef = useRef(null); const isVisibleRef = useRef(isVisible); - const hasFocusedFirstItemOnCurrentOpenRef = useRef(false); - const firstMenuItemFocusRetryTimeoutRef = useRef | null>(null); + const hasFocusedTargetOnCurrentOpenRef = useRef(false); + const scheduledFocusRetryTimeoutRef = useRef | null>(null); + const dismissAccessibilityTimeoutRef = useRef | null>(null); const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(!shouldDeferDismissButtonAccessibility); - const clearScheduledFirstMenuItemFocus = useCallback(() => { - if (!firstMenuItemFocusRetryTimeoutRef.current) { + const clearScheduledFocusTarget = useCallback(() => { + if (!scheduledFocusRetryTimeoutRef.current) { return; } - clearTimeout(firstMenuItemFocusRetryTimeoutRef.current); - firstMenuItemFocusRetryTimeoutRef.current = null; + clearTimeout(scheduledFocusRetryTimeoutRef.current); + scheduledFocusRetryTimeoutRef.current = null; }, []); - const markFirstMenuItemUnfocused = useCallback(() => { - clearScheduledFirstMenuItemFocus(); - hasFocusedFirstItemOnCurrentOpenRef.current = false; - if (shouldDeferDismissButtonAccessibility) { - setShouldEnableBottomDockedDismissAccessibility(false); + const clearDismissAccessibilityTimeout = useCallback(() => { + if (!dismissAccessibilityTimeoutRef.current) { + return; } - }, [clearScheduledFirstMenuItemFocus, shouldDeferDismissButtonAccessibility]); + + clearTimeout(dismissAccessibilityTimeoutRef.current); + dismissAccessibilityTimeoutRef.current = null; + }, []); + + const markTargetUnfocused = useCallback(() => { + clearScheduledFocusTarget(); + clearDismissAccessibilityTimeout(); + hasFocusedTargetOnCurrentOpenRef.current = false; + setShouldEnableBottomDockedDismissAccessibility(!shouldDeferDismissButtonAccessibility); + }, [clearDismissAccessibilityTimeout, clearScheduledFocusTarget, shouldDeferDismissButtonAccessibility]); useEffect(() => { isVisibleRef.current = isVisible; @@ -73,79 +111,80 @@ function useBottomDockedDismissAccessibility({ } const animationFrame = requestAnimationFrame(() => { - markFirstMenuItemUnfocused(); + markTargetUnfocused(); }); return () => cancelAnimationFrame(animationFrame); - }, [isVisible, markFirstMenuItemUnfocused]); + }, [isVisible, markTargetUnfocused]); useEffect( () => () => { - clearScheduledFirstMenuItemFocus(); + clearScheduledFocusTarget(); + clearDismissAccessibilityTimeout(); }, - [clearScheduledFirstMenuItemFocus], + [clearDismissAccessibilityTimeout, clearScheduledFocusTarget], ); - const getFirstMenuItemTarget = useCallback(() => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + const getFocusTarget = useCallback(() => { + if (!isVisibleRef.current || hasFocusedTargetOnCurrentOpenRef.current) { return null; } - return firstItemRef.current; - }, []); + return (focusTargetRef ?? firstItemRef).current; + }, [focusTargetRef]); - const markFirstMenuItemFocused = useCallback(() => { - clearScheduledFirstMenuItemFocus(); - hasFocusedFirstItemOnCurrentOpenRef.current = true; - if (shouldDeferDismissButtonAccessibility) { - setShouldEnableBottomDockedDismissAccessibility(true); - } - }, [clearScheduledFirstMenuItemFocus, shouldDeferDismissButtonAccessibility]); + const markTargetFocused = useCallback(() => { + clearScheduledFocusTarget(); + clearDismissAccessibilityTimeout(); + hasFocusedTargetOnCurrentOpenRef.current = true; + setShouldEnableBottomDockedDismissAccessibility(true); + }, [clearDismissAccessibilityTimeout, clearScheduledFocusTarget]); - const focusFirstMenuItemOnWeb = useCallback(() => { - const target = getFirstMenuItemTarget(); - if (!target || !('focus' in target) || typeof target.focus !== 'function') { + const focusTargetOnWeb = useCallback(() => { + const target = getFocusTarget(); + if (!isFocusableElement(target)) { return false; } target.focus(); - markFirstMenuItemFocused(); + markTargetFocused(); return true; - }, [getFirstMenuItemTarget, markFirstMenuItemFocused]); + }, [getFocusTarget, markTargetFocused]); - const focusFirstMenuItemOnNative = useCallback(() => { - const target = getFirstMenuItemTarget(); + const focusTargetOnNative = useCallback(() => { + const target = getFocusTarget(); if (!target) { return false; } + const accessibilityFocusTargetRef = (focusTargetRef ?? firstItemRef) as RefObject; const sendAccessibilityEvent = AccessibilityInfo.sendAccessibilityEvent; if (sendAccessibilityEvent && isAndroid) { - sendAccessibilityEvent(target, 'viewHoverEnter'); + sendAccessibilityEvent(target as ReactNativeElement, 'viewHoverEnter'); } - Accessibility.moveAccessibilityFocus(firstItemRef); - if (!shouldDeferDismissButtonAccessibility || !shouldConfirmFirstItemFocus) { - markFirstMenuItemFocused(); + Accessibility.moveAccessibilityFocus(accessibilityFocusTargetRef); + if (!shouldDeferDismissButtonAccessibility || resolvedDismissAccessibilityMode !== 'focusConfirmation' || !shouldConfirmFirstItemFocus) { + markTargetFocused(); } return true; - }, [getFirstMenuItemTarget, isAndroid, markFirstMenuItemFocused, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); + }, [focusTargetRef, getFocusTarget, isAndroid, markTargetFocused, resolvedDismissAccessibilityMode, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); - const focusFirstMenuItem = useCallback(() => { + const focusTarget = useCallback(() => { if (isWeb) { - return focusFirstMenuItemOnWeb(); + return focusTargetOnWeb(); } - return focusFirstMenuItemOnNative(); - }, [focusFirstMenuItemOnNative, focusFirstMenuItemOnWeb, isWeb]); + return focusTargetOnNative(); + }, [focusTargetOnNative, focusTargetOnWeb, isWeb]); - const scheduleFocusFirstMenuItemOnWeb = useCallback(() => { - const focusFirstMenuItemWithRetries = (retries = maxFocusRetries) => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + const scheduleFocusTargetOnWeb = useCallback(() => { + const focusTargetWithRetries = (retries = maxFocusRetries) => { + if (!isVisibleRef.current || hasFocusedTargetOnCurrentOpenRef.current) { return; } - if (focusFirstMenuItem()) { + if (focusTarget()) { return; } @@ -153,64 +192,87 @@ function useBottomDockedDismissAccessibility({ return; } - requestAnimationFrame(() => focusFirstMenuItemWithRetries(retries - 1)); + requestAnimationFrame(() => focusTargetWithRetries(retries - 1)); }; - requestAnimationFrame(() => focusFirstMenuItemWithRetries()); - }, [focusFirstMenuItem, maxFocusRetries]); + requestAnimationFrame(() => focusTargetWithRetries()); + }, [focusTarget, maxFocusRetries]); - const scheduleFocusFirstMenuItemOnNative = useCallback(() => { - const focusFirstMenuItemWithRetries = (retries = maxFocusRetries) => { + const scheduleFocusTargetOnNative = useCallback(() => { + const focusTargetWithRetries = (retries = maxFocusRetries) => { requestAnimationFrame(() => { - if (!isVisibleRef.current || hasFocusedFirstItemOnCurrentOpenRef.current) { + if (!isVisibleRef.current || hasFocusedTargetOnCurrentOpenRef.current) { return; } - focusFirstMenuItem(); + focusTarget(); - if (!shouldDeferDismissButtonAccessibility || !shouldConfirmFirstItemFocus || hasFocusedFirstItemOnCurrentOpenRef.current) { + if ( + !shouldDeferDismissButtonAccessibility || + resolvedDismissAccessibilityMode !== 'focusConfirmation' || + !shouldConfirmFirstItemFocus || + hasFocusedTargetOnCurrentOpenRef.current + ) { return; } if (retries <= 0) { - markFirstMenuItemFocused(); + markTargetFocused(); return; } - firstMenuItemFocusRetryTimeoutRef.current = setTimeout(() => { - firstMenuItemFocusRetryTimeoutRef.current = null; - focusFirstMenuItemWithRetries(retries - 1); + scheduledFocusRetryTimeoutRef.current = setTimeout(() => { + scheduledFocusRetryTimeoutRef.current = null; + focusTargetWithRetries(retries - 1); }, nativeFocusRetryDelayMs); }); }; - clearScheduledFirstMenuItemFocus(); - firstMenuItemFocusRetryTimeoutRef.current = setTimeout( + clearScheduledFocusTarget(); + scheduledFocusRetryTimeoutRef.current = setTimeout( () => { - firstMenuItemFocusRetryTimeoutRef.current = null; - focusFirstMenuItemWithRetries(); + scheduledFocusRetryTimeoutRef.current = null; + focusTargetWithRetries(); }, shouldDeferDismissButtonAccessibility ? animationDelayMs : 0, ); }, [ animationDelayMs, - clearScheduledFirstMenuItemFocus, - focusFirstMenuItem, - markFirstMenuItemFocused, + clearScheduledFocusTarget, + focusTarget, + markTargetFocused, maxFocusRetries, nativeFocusRetryDelayMs, + resolvedDismissAccessibilityMode, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility, ]); - const scheduleFocusFirstMenuItem = useCallback(() => { - if (isWeb) { - scheduleFocusFirstMenuItemOnWeb(); + const scheduleDismissAccessibilityTimer = useCallback(() => { + if (!shouldDeferDismissButtonAccessibility || resolvedDismissAccessibilityMode !== 'timer') { return; } - scheduleFocusFirstMenuItemOnNative(); - }, [isWeb, scheduleFocusFirstMenuItemOnNative, scheduleFocusFirstMenuItemOnWeb]); + clearDismissAccessibilityTimeout(); + setShouldEnableBottomDockedDismissAccessibility(false); + dismissAccessibilityTimeoutRef.current = setTimeout(() => { + setShouldEnableBottomDockedDismissAccessibility(true); + dismissAccessibilityTimeoutRef.current = null; + }, dismissAccessibilityDelayMs ?? animationDelayMs); + }, [animationDelayMs, clearDismissAccessibilityTimeout, dismissAccessibilityDelayMs, resolvedDismissAccessibilityMode, shouldDeferDismissButtonAccessibility]); + + const shouldScheduleFocusTargetOnOpen = shouldUseManualWebFocus || shouldUseNativeFocusHandoff; + + const scheduleFocusTargetOnOpen = useCallback(() => { + if (shouldUseManualWebFocus) { + scheduleFocusTargetOnWeb(); + return; + } + + if (shouldUseNativeFocusHandoff) { + scheduleFocusTargetOnNative(); + } + }, [scheduleFocusTargetOnNative, scheduleFocusTargetOnWeb, shouldUseManualWebFocus, shouldUseNativeFocusHandoff]); const handleModalShow = useCallback(() => { onModalShow?.(); @@ -218,42 +280,57 @@ function useBottomDockedDismissAccessibility({ return; } - scheduleFocusFirstMenuItem(); - }, [onModalShow, scheduleFocusFirstMenuItem, shouldActivate]); + if (resolvedDismissAccessibilityMode === 'timer') { + scheduleDismissAccessibilityTimer(); + return; + } + + scheduleFocusTargetOnOpen(); + }, [onModalShow, resolvedDismissAccessibilityMode, scheduleDismissAccessibilityTimer, scheduleFocusTargetOnOpen, shouldActivate]); useEffect(() => { - if (!isVisible || !shouldActivate || hasFocusedFirstItemOnCurrentOpenRef.current) { + if (!isVisible || !shouldActivate || hasFocusedTargetOnCurrentOpenRef.current || !shouldScheduleFocusTargetOnOpen) { return; } - scheduleFocusFirstMenuItem(); - }, [isVisible, scheduleFocusFirstMenuItem, shouldActivate]); + scheduleFocusTargetOnOpen(); + }, [isVisible, scheduleFocusTargetOnOpen, shouldActivate, shouldScheduleFocusTargetOnOpen]); useEffect(() => { - if (!isVisible || !shouldDeferDismissButtonAccessibility || !shouldConfirmFirstItemFocus || focusedIndex !== 0 || hasFocusedFirstItemOnCurrentOpenRef.current) { + if ( + !isVisible || + !shouldDeferDismissButtonAccessibility || + resolvedDismissAccessibilityMode !== 'focusConfirmation' || + !shouldConfirmFirstItemFocus || + focusedIndex !== 0 || + hasFocusedTargetOnCurrentOpenRef.current + ) { return; } const animationFrame = requestAnimationFrame(() => { - markFirstMenuItemFocused(); + markTargetFocused(); }); return () => cancelAnimationFrame(animationFrame); - }, [focusedIndex, isVisible, markFirstMenuItemFocused, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); + }, [focusedIndex, isVisible, markTargetFocused, resolvedDismissAccessibilityMode, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); const handleFirstItemFocus = useCallback(() => { - if (hasFocusedFirstItemOnCurrentOpenRef.current) { + if (hasFocusedTargetOnCurrentOpenRef.current) { return; } - markFirstMenuItemFocused(); - }, [markFirstMenuItemFocused]); + markTargetFocused(); + }, [markTargetFocused]); + + const initialFocus = shouldUseInitialWebFocus ? () => (focusTargetRef ?? firstItemRef).current as NonNullable : undefined; return { firstItemRef, handleFirstItemFocus, handleModalShow, shouldEnableBottomDockedDismissAccessibility: shouldDeferDismissButtonAccessibility ? shouldEnableBottomDockedDismissAccessibility : undefined, + initialFocus, }; } From a162ea2943ebf7caac424394f55cfbfacfa8db0a Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sun, 19 Apr 2026 11:52:32 +0430 Subject: [PATCH 33/36] Fix accessibility focus for search display bottom sheets --- src/components/HeaderWithBackButton/index.tsx | 2 + src/components/HeaderWithBackButton/types.ts | 4 ++ .../Search/FilterDropdowns/DisplayPopup.tsx | 41 +++++++++++++++++-- .../Search/FilterDropdowns/DropdownButton.tsx | 16 +++++--- .../SearchDisplayDropdownButton.tsx | 4 +- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 18deb1a7e68e..4276f3b8a494 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -35,6 +35,7 @@ function HeaderWithBackButton({ iconHeight, iconStyles, onBackButtonPress = () => Navigation.goBack(), + backButtonRef, onCloseButtonPress = () => Navigation.dismissModal(), onDownloadButtonPress = () => {}, onRotateButtonPress = () => {}, @@ -244,6 +245,7 @@ function HeaderWithBackButton({ {shouldShowBackButton && ( { if (Keyboard.isVisible()) { Keyboard.dismiss(); diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index c77cb5e8b357..26c8a5b589bc 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -3,6 +3,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ExpensifyIconName} from '@components/Icon/ExpensifyIconLoader'; import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type {PressableRef} from '@components/Pressable/GenericPressable/types'; import type {Action} from '@hooks/useSingleExecution'; import type {StepCounterParams} from '@src/languages/params'; import type {TranslationPaths} from '@src/languages/types'; @@ -61,6 +62,9 @@ type HeaderWithBackButtonProps = Partial & { /** Method to trigger when pressing back button of the header */ onBackButtonPress?: () => void; + /** Reference to the back button element */ + backButtonRef?: PressableRef; + /** Method to trigger when pressing more options button of the header */ onThreeDotsButtonPress?: () => void; diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx index 1c26236f5bdf..20b9a2cc083b 100644 --- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -10,6 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import Accessibility from '@libs/Accessibility'; import {close} from '@libs/actions/Modal'; import Navigation from '@libs/Navigation/Navigation'; import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; @@ -20,6 +21,7 @@ import ROUTES from '@src/ROUTES'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {SearchResults} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ModalAccessibilityTargetRef} from './DropdownButton'; import GroupByPopup from './GroupByPopup'; import GroupCurrencyPopup from './GroupCurrencyPopup'; import SingleSelectPopup from './SingleSelectPopup'; @@ -32,14 +34,19 @@ type DisplayPopupProps = { searchResults: OnyxEntry; closeOverlay: () => void; onSort: () => void; + modalAccessibilityTargetRef?: ModalAccessibilityTargetRef; }; -function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayPopupProps) { +function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort, modalAccessibilityTargetRef}: DisplayPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {shouldUseNarrowLayout, isLargeScreenWidth} = useResponsiveLayout(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {shouldUseNarrowLayout, isLargeScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Columns']); const [searchAdvancedFilters = getEmptyObject()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const firstDisplayItemRef = useRef(null); + const backButtonRef = useRef(null); + const hasInitializedDisplayPopupRef = useRef(false); const [selectedDisplayFilter, setSelectedDisplayFilter] = useState< | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY @@ -56,6 +63,32 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP const view = viewOptions.find((option) => option.value === queryJSON.view) ?? viewOptions.at(0) ?? null; const shouldShowColumnsButton = isLargeScreenWidth && (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE || queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); + const handleFirstDisplayItemRef = useCallback( + (node) => { + firstDisplayItemRef.current = node as View | null; + modalAccessibilityTargetRef?.(node); + }, + [modalAccessibilityTargetRef], + ); + + useEffect(() => { + if (!isSmallScreenWidth) { + return; + } + + if (!hasInitializedDisplayPopupRef.current) { + hasInitializedDisplayPopupRef.current = true; + return; + } + + const focusTargetRef = selectedDisplayFilter ? backButtonRef : firstDisplayItemRef; + const animationFrame = requestAnimationFrame(() => { + Accessibility.moveAccessibilityFocus(focusTargetRef); + }); + + return () => cancelAnimationFrame(animationFrame); + }, [isSmallScreenWidth, selectedDisplayFilter]); + const limitValue = searchAdvancedFilters[CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT]; if (!selectedDisplayFilter) { @@ -74,6 +107,7 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP return ( extends {ref?: Ref} ? T | null : never; type ModalHeadingRef = (node: ModalHeadingNode) => void; +type ModalAccessibilityTargetRef = (node: unknown) => void; type PopoverComponentProps = { isExpanded: boolean; closeOverlay: () => void; setPopoverWidth?: (width: number | undefined) => void; modalHeadingRef?: ModalHeadingRef; + modalAccessibilityTargetRef?: ModalAccessibilityTargetRef; }; type DropdownButtonProps = WithSentryLabel & { @@ -66,7 +68,6 @@ type DropdownButtonProps = WithSentryLabel & { shouldDelayBottomDockedDismissAccessibility?: boolean; }; -const PADDING_MODAL = 8; const BOTTOM_DOCKED_DISMISS_ACCESSIBILITY_DELAY = 2500; const ANCHOR_ORIGIN = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, @@ -96,7 +97,7 @@ function DropdownButton({ const {windowHeight} = useWindowDimensions(); const triggerRef = useRef(null); const anchorRef = useRef(null); - const modalAccessibilityHeadingElementRef = useRef(null); + const modalAccessibilityTargetElementRef = useRef(null); const [isOverlayVisible, setIsOverlayVisible] = useState(false); const [customPopoverWidth, setCustomPopoverWidth] = useState(undefined); const {calculatePopoverPosition} = usePopoverPosition(); @@ -110,14 +111,18 @@ function DropdownButton({ const shouldUseFilterModalAccessibility = shouldDelayBottomDockedDismissAccessibility && isSmallScreenWidth; const handleModalHeadingRef = useCallback((node) => { - modalAccessibilityHeadingElementRef.current = node; + modalAccessibilityTargetElementRef.current = node; + }, []); + + const handleModalAccessibilityTargetRef = useCallback((node) => { + modalAccessibilityTargetElementRef.current = node; }, []); const {handleModalShow, initialFocus, shouldEnableBottomDockedDismissAccessibility} = useBottomDockedDismissAccessibility({ isVisible: isOverlayVisible, shouldActivate: shouldUseFilterModalAccessibility, animationDelayMs: 0, - focusTargetRef: modalAccessibilityHeadingElementRef, + focusTargetRef: modalAccessibilityTargetElementRef, webFocusMode: 'initialFocus', dismissAccessibilityMode: 'timer', dismissAccessibilityPlatforms: 'native', @@ -236,11 +241,12 @@ function DropdownButton({ isExpanded={isOverlayVisible} setPopoverWidth={setCustomPopoverWidth} modalHeadingRef={shouldUseFilterModalAccessibility ? handleModalHeadingRef : undefined} + modalAccessibilityTargetRef={shouldUseFilterModalAccessibility ? handleModalAccessibilityTargetRef : undefined} /> ); } -export type {PopoverComponentProps, DropdownButtonProps, ModalHeadingRef}; +export type {PopoverComponentProps, DropdownButtonProps, ModalAccessibilityTargetRef, ModalHeadingRef}; export default withViewportOffsetTop(DropdownButton); diff --git a/src/components/Search/SearchPageHeader/SearchDisplayDropdownButton.tsx b/src/components/Search/SearchPageHeader/SearchDisplayDropdownButton.tsx index 646916d844ec..5e8a577660c3 100644 --- a/src/components/Search/SearchPageHeader/SearchDisplayDropdownButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchDisplayDropdownButton.tsx @@ -31,12 +31,13 @@ function SearchDisplayDropdownButton({queryJSON, searchResults, onSort}: SearchD return null; } - const displayPopup = ({closeOverlay}: {closeOverlay: () => void}) => ( + const displayPopup: DropdownButtonProps['PopoverComponent'] = ({closeOverlay, modalAccessibilityTargetRef}) => ( ); @@ -66,6 +67,7 @@ function SearchDisplayDropdownButton({queryJSON, searchResults, onSort}: SearchD value={null} PopoverComponent={displayPopup} ButtonComponent={shouldUseNarrowLayout || isMediumScreenWidth ? displayIconButton : undefined} + shouldDelayBottomDockedDismissAccessibility /> ); } From 00d9b65eb65a86e396502b34e4fb17db18e47e91 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 22 Apr 2026 12:01:00 +0430 Subject: [PATCH 34/36] Preserve search popup state across dropdown re-renders --- src/components/Search/FilterDropdowns/DropdownButton.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index f3ff2c8b558d..0bee38ca1c4a 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -98,6 +98,7 @@ function DropdownButton({ const triggerRef = useRef(null); const anchorRef = useRef(null); const modalAccessibilityTargetElementRef = useRef(null); + const [activePopoverComponent, setActivePopoverComponent] = useState(() => PopoverComponent); const [isOverlayVisible, setIsOverlayVisible] = useState(false); const [customPopoverWidth, setCustomPopoverWidth] = useState(undefined); const {calculatePopoverPosition} = usePopoverPosition(); @@ -146,11 +147,12 @@ function DropdownButton({ * Calculate popover position and toggle overlay */ const calculatePopoverPositionAndToggleOverlay = useCallback(() => { + setActivePopoverComponent(() => PopoverComponent); calculatePopoverPosition(anchorRef, ANCHOR_ORIGIN).then((pos) => { setPopoverTriggerPosition({...pos, vertical: pos.vertical}); toggleOverlay(); }); - }, [calculatePopoverPosition, toggleOverlay]); + }, [PopoverComponent, calculatePopoverPosition, toggleOverlay]); /** * When no items are selected, render the label, otherwise, render the * list of selected items as well @@ -172,6 +174,7 @@ function DropdownButton({ } return {width: actualPopoverWidth}; }, [isSmallScreenWidth, styles, actualPopoverWidth]); + const ActivePopoverComponent = activePopoverComponent; return ( - Date: Mon, 27 Apr 2026 16:04:50 +0430 Subject: [PATCH 35/36] fix(a11y): focus dismiss first in bottom-docked modals --- src/components/FocusableMenuItem.tsx | 9 +- src/components/HeaderWithBackButton/index.tsx | 2 - src/components/HeaderWithBackButton/types.ts | 4 - src/components/Modal/BaseModal.tsx | 36 +- .../Modal/ReanimatedModal/Backdrop/index.tsx | 17 +- .../Modal/ReanimatedModal/index.tsx | 2 - src/components/Modal/ReanimatedModal/types.ts | 6 - src/components/Modal/types.ts | 7 - src/components/PopoverMenu.tsx | 25 +- .../Search/FilterDropdowns/BasePopup.tsx | 15 +- .../FilterDropdowns/DateSelectPopup/index.tsx | 16 +- .../Search/FilterDropdowns/DisplayPopup.tsx | 41 +-- .../Search/FilterDropdowns/DropdownButton.tsx | 59 +-- .../FilterDropdowns/FeedSelectPopup.tsx | 8 +- .../Search/FilterDropdowns/GroupByPopup.tsx | 1 - .../FilterDropdowns/MultiSelectPopup.tsx | 7 +- .../FilterDropdowns/SingleSelectPopup.tsx | 8 +- .../FilterDropdowns/UserSelectPopup.tsx | 21 +- .../FilterDropdowns/WorkspaceSelectPopup.tsx | 5 +- .../DatePickerFilterPopup.tsx | 3 +- .../MultiSelectFilterPopup.tsx | 6 +- .../SearchDisplayDropdownButton.tsx | 4 +- .../SearchPageHeader/SearchFilterBar.tsx | 6 - .../SearchPageHeader/useSearchFiltersBar.tsx | 16 +- .../useBottomDockedDismissAccessibility.ts | 337 ------------------ .../moveAccessibilityFocus/index.native.ts | 11 +- .../moveAccessibilityFocus/index.ts | 9 +- .../moveAccessibilityFocus/types.ts | 10 +- .../FABFirstItemRefContext.tsx | 7 - .../FABFocusableMenuItem.tsx | 5 +- .../FABPopoverContent/FABPopoverMenu.tsx | 74 ++-- 31 files changed, 95 insertions(+), 682 deletions(-) delete mode 100644 src/hooks/useBottomDockedDismissAccessibility.ts delete mode 100644 src/pages/inbox/sidebar/FABPopoverContent/FABFirstItemRefContext.tsx diff --git a/src/components/FocusableMenuItem.tsx b/src/components/FocusableMenuItem.tsx index 5267daecc21d..19172555a93b 100644 --- a/src/components/FocusableMenuItem.tsx +++ b/src/components/FocusableMenuItem.tsx @@ -1,21 +1,20 @@ import React, {useRef} from 'react'; import type {View} from 'react-native'; import useSyncFocus from '@hooks/useSyncFocus'; -import mergeRefs from '@libs/mergeRefs'; import type {MenuItemProps} from './MenuItem'; import MenuItem from './MenuItem'; -function FocusableMenuItem({ref: forwardedRef, ...props}: MenuItemProps) { - const internalRef = useRef(null); +function FocusableMenuItem(props: MenuItemProps) { + const ref = useRef(null); // Sync focus on an item - useSyncFocus(internalRef, !!props.focused); + useSyncFocus(ref, !!props.focused); return ( ); } diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 4276f3b8a494..18deb1a7e68e 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -35,7 +35,6 @@ function HeaderWithBackButton({ iconHeight, iconStyles, onBackButtonPress = () => Navigation.goBack(), - backButtonRef, onCloseButtonPress = () => Navigation.dismissModal(), onDownloadButtonPress = () => {}, onRotateButtonPress = () => {}, @@ -245,7 +244,6 @@ function HeaderWithBackButton({ {shouldShowBackButton && ( { if (Keyboard.isVisible()) { Keyboard.dismiss(); diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 26c8a5b589bc..c77cb5e8b357 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -3,7 +3,6 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ExpensifyIconName} from '@components/Icon/ExpensifyIconLoader'; import type {PopoverMenuItem} from '@components/PopoverMenu'; -import type {PressableRef} from '@components/Pressable/GenericPressable/types'; import type {Action} from '@hooks/useSingleExecution'; import type {StepCounterParams} from '@src/languages/params'; import type {TranslationPaths} from '@src/languages/types'; @@ -62,9 +61,6 @@ type HeaderWithBackButtonProps = Partial & { /** Method to trigger when pressing back button of the header */ onBackButtonPress?: () => void; - /** Reference to the back button element */ - backButtonRef?: PressableRef; - /** Method to trigger when pressing more options button of the header */ onThreeDotsButtonPress?: () => void; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index b605901ef14b..e2501020212e 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -20,7 +20,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; 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'; @@ -74,7 +73,6 @@ function BaseModal({ forwardedFSClass = CONST.FULLSTORY.CLASS.UNMASK, ref, shouldDisplayBelowModals = false, - shouldEnableBottomDockedDismissAccessibility, shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode = true, }: BaseModalProps) { // When the `enableEdgeToEdgeBottomSafeAreaPadding` prop is explicitly set, we enable edge-to-edge mode. @@ -93,8 +91,6 @@ function BaseModal({ const sidePanelAnimatedStyle = shouldApplySidePanelOffset && !isSmallScreenWidth ? {transform: [{translateX: Animated.multiply(sidePanelOffset.current, -1)}]} : undefined; const keyboardStateContextValue = useKeyboardState(); - const isWeb = getPlatform() === CONST.PLATFORM.WEB; - const [modalOverlapsWithTopSafeArea, setModalOverlapsWithTopSafeArea] = useState(false); const [modalHeight, setModalHeight] = useState(0); @@ -102,6 +98,7 @@ function BaseModal({ const shouldCallHideModalOnUnmount = useRef(false); const hideModalCallbackRef = useRef<(callHideCallback: boolean) => void>(undefined); + const bottomDockedDismissButtonRef = useRef(null); const wasVisible = usePrevious(isVisible); @@ -184,7 +181,7 @@ function BaseModal({ onModalShow(); }, [onModalShow, shouldSetModalVisibility, type]); - const handleBackdropPress = (e?: KeyboardEvent | GestureResponderEvent) => { + const handleBackdropPress = (e?: GestureResponderEvent | KeyboardEvent) => { if (e && 'key' in e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { return; } @@ -269,9 +266,6 @@ function BaseModal({ ], ); - const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose); - const shouldHideBottomDockedDismissFromAccessibility = shouldShowBottomDockedDismissButton && shouldEnableBottomDockedDismissAccessibility === false; - const modalPaddingStyles = useMemo(() => { const paddings = StyleUtils.getModalPaddingStyles({ shouldAddBottomSafeAreaMargin, @@ -321,6 +315,8 @@ function BaseModal({ const isBottomDockedModalInLandscapeMode = type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && isInLandscapeMode; const shouldWrapChildrenInScrollView = shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode && isBottomDockedModalInLandscapeMode; + const shouldShowBottomDockedDismissButton = isSmallScreenWidth && type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && !!(onBackdropPress ?? onClose); + const modalInitialFocus = shouldShowBottomDockedDismissButton ? () => bottomDockedDismissButtonRef.current : initialFocus; return ( @@ -358,7 +354,7 @@ function BaseModal({ onSwipeComplete={onClose} swipeDirection={swipeDirection} shouldPreventScrollOnFocus={shouldPreventScrollOnFocus} - initialFocus={initialFocus} + initialFocus={modalInitialFocus} swipeThreshold={swipeThreshold} isVisible={isVisible} backdropColor={theme.overlay} @@ -383,7 +379,6 @@ function BaseModal({ type={type} shouldIgnoreBackHandlerDuringTransition={shouldIgnoreBackHandlerDuringTransition} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} - shouldEnableBottomDockedDismissAccessibility={shouldEnableBottomDockedDismissAccessibility} supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']} shouldReturnFocus={shouldReturnFocus} > @@ -400,35 +395,20 @@ function BaseModal({ ref={ref} fsClass={forwardedFSClass} > - {isWeb && shouldShowBottomDockedDismissButton && ( + {shouldShowBottomDockedDismissButton && ( )} {shouldWrapChildrenInScrollView ? {children} : children} - {!isWeb && shouldShowBottomDockedDismissButton && ( - - - - )} {!keyboardStateContextValue?.isKeyboardActive && } diff --git a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx index 4405583ca238..433317abba31 100644 --- a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx +++ b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx @@ -15,7 +15,6 @@ function Backdrop({ animationInTiming = CONST.MODAL.ANIMATION_TIMING.DEFAULT_IN, animationOutTiming = CONST.MODAL.ANIMATION_TIMING.DEFAULT_OUT, backdropOpacity = variables.overlayOpacity, - shouldEnableBottomDockedDismissAccessibility = true, }: BackdropProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -34,22 +33,10 @@ function Backdrop({ ); if (!customBackdrop) { - if (shouldEnableBottomDockedDismissAccessibility) { - return ( - - {BackdropOverlay} - - ); - } - return ( diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index 6a19fabc10df..cbd701225e90 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -50,7 +50,6 @@ function ReanimatedModal({ shouldIgnoreBackHandlerDuringTransition = false, shouldEnableNewFocusManagement, shouldReturnFocus, - shouldEnableBottomDockedDismissAccessibility, ...props }: ReanimatedModalProps) { const [isVisibleState, setIsVisibleState] = useState(isVisible); @@ -212,7 +211,6 @@ function ReanimatedModal({ animationOutTiming={animationOutTiming} animationInDelay={animationInDelay} backdropOpacity={backdropOpacity} - shouldEnableBottomDockedDismissAccessibility={shouldEnableBottomDockedDismissAccessibility} /> ); diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts index 30aa51db1e84..b3b446b7d312 100644 --- a/src/components/Modal/ReanimatedModal/types.ts +++ b/src/components/Modal/ReanimatedModal/types.ts @@ -148,9 +148,6 @@ type ReanimatedModalProps = ViewProps & */ shouldReturnFocus?: boolean; - /** Whether bottom-docked dismiss controls should be exposed to accessibility. */ - shouldEnableBottomDockedDismissAccessibility?: boolean; - /** Whether to ignore the back handler during transition */ shouldIgnoreBackHandlerDuringTransition?: boolean; }; @@ -179,9 +176,6 @@ type BackdropProps = { /** Shows backdrop content */ isBackdropVisible: boolean; - - /** Whether bottom-docked dismiss controls should be exposed to accessibility. */ - shouldEnableBottomDockedDismissAccessibility?: boolean; }; type ContainerProps = { diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 2e22ce036941..e13c7b82020f 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -125,13 +125,6 @@ type BaseModalProps = Partial & */ shouldDisplayBelowModals?: boolean; - /** - * Internal accessibility handshake for bottom-docked popovers on native mobile. - * When `false`, dismiss controls stay hidden from accessibility until the caller enables them. - * When `true` or `undefined`, dismiss controls are exposed to accessibility. - */ - shouldEnableBottomDockedDismissAccessibility?: boolean; - /** * Whether the modal should wrap the children in a scroll view if it is a bottom docked modal in landscape mode. * Defaults to true. diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 7cd48b0bb1e2..39eaa309984e 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -5,7 +5,6 @@ import React, {useCallback, useLayoutEffect, useMemo, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; -import useBottomDockedDismissAccessibility from '@hooks/useBottomDockedDismissAccessibility'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -313,25 +312,14 @@ function BasePopoverMenu({ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); - const isWeb = getPlatform() === CONST.PLATFORM.WEB; const [currentMenuItems, setCurrentMenuItems] = useState(menuItems); const currentMenuItemsFocusedIndex = getSelectedItemIndex(currentMenuItems); const [enteredSubMenuIndexes, setEnteredSubMenuIndexes] = useState(CONST.EMPTY_ARRAY); + const platform = getPlatform(); + const isWeb = platform === CONST.PLATFORM.WEB; const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: currentMenuItemsFocusedIndex, maxIndex: currentMenuItems.length - 1, isActive: isVisible}); const expensifyIcons = useMemoizedLazyExpensifyIcons(['BackArrow', 'ReceiptScan', 'MoneyCircle']); const prevMenuItems = usePrevious(menuItems); - const { - firstItemRef: firstMenuItemRef, - handleFirstItemFocus, - handleModalShow, - shouldEnableBottomDockedDismissAccessibility, - } = useBottomDockedDismissAccessibility({ - isVisible, - shouldActivate: isSmallScreenWidth, - animationDelayMs: (animationInDelay ?? 0) + animationInTiming, - onModalShow, - shouldConfirmFirstItemFocus: true, - }); const selectItem = (index: number, event?: GestureResponderEvent | KeyboardEvent) => { const selectedItem = currentMenuItems.at(index); @@ -425,7 +413,6 @@ function BasePopoverMenu({ > selectItem(menuIndex, event)} @@ -436,9 +423,6 @@ function BasePopoverMenu({ shouldShowRightIcon={!!item.rightIcon} brickRoadIndicator={item.brickRoadIndicator} onFocus={() => { - if (menuIndex === 0) { - handleFirstItemFocus(); - } if (!shouldUpdateFocusedIndex) { return; } @@ -570,7 +554,7 @@ function BasePopoverMenu({ } return stylesArray; - }, [isInLandscapeMode, isSmallScreenWidth, shouldEnableMaxHeight, styles.createMenuContainer, shouldUseScrollView]); + }, [isSmallScreenWidth, shouldEnableMaxHeight, styles.createMenuContainer, shouldUseScrollView]); const {paddingTop, paddingBottom, paddingVertical, ...restScrollContainerStyle} = (StyleSheet.flatten([styles.pv4, scrollContainerStyle]) as ViewStyle) ?? {}; const { @@ -618,7 +602,7 @@ function BasePopoverMenu({ }} isVisible={isVisible} onModalHide={handleModalHide} - onModalShow={handleModalShow} + onModalShow={onModalShow} animationIn={animationIn} animationOut={animationOut} animationInDelay={animationInDelay} @@ -630,7 +614,6 @@ function BasePopoverMenu({ shouldSetModalVisibility={shouldSetModalVisibility} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} restoreFocusType={restoreFocusType} - shouldEnableBottomDockedDismissAccessibility={shouldEnableBottomDockedDismissAccessibility} innerContainerStyle={{...styles.pv0, ...innerContainerStyle}} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} shouldHandleNavigationBack={shouldHandleNavigationBack} diff --git a/src/components/Search/FilterDropdowns/BasePopup.tsx b/src/components/Search/FilterDropdowns/BasePopup.tsx index aed4b0609ba0..8a70f78a6eb2 100644 --- a/src/components/Search/FilterDropdowns/BasePopup.tsx +++ b/src/components/Search/FilterDropdowns/BasePopup.tsx @@ -6,7 +6,6 @@ import Text from '@components/Text'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import ActionButtons from './ActionButtons'; -import type {ModalHeadingRef} from './DropdownButton'; type BasePopupProps = React.PropsWithChildren & { label?: string; @@ -16,10 +15,9 @@ type BasePopupProps = React.PropsWithChildren & { onApply: () => void; onReset: () => void; onBackButtonPress?: () => void; - modalHeadingRef?: ModalHeadingRef; }; -function BasePopup({children, label, applySentryLabel, resetSentryLabel, style, onApply, onReset, onBackButtonPress, modalHeadingRef}: BasePopupProps) { +function BasePopup({children, label, applySentryLabel, resetSentryLabel, style, onApply, onReset, onBackButtonPress}: BasePopupProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -34,16 +32,7 @@ function BasePopup({children, label, applySentryLabel, resetSentryLabel, style, onBackButtonPress={onBackButtonPress} /> ) : ( - isSmallScreenWidth && - !!label && ( - - {label} - - ) + isSmallScreenWidth && !!label && {label} )} {children} void; - - /** Visible heading target for modal initial focus */ - modalHeadingRef?: ModalHeadingRef; }; -function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, setPopoverWidth, modalHeadingRef}: DateSelectPopupProps) { +function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, setPopoverWidth}: DateSelectPopupProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); @@ -193,15 +189,7 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, return ( - {!selectedDateModifier && !!label && ( - - {label} - - )} + {!selectedDateModifier && !!label && {label}} ; closeOverlay: () => void; onSort: () => void; - modalAccessibilityTargetRef?: ModalAccessibilityTargetRef; }; -function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort, modalAccessibilityTargetRef}: DisplayPopupProps) { +function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isLargeScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); + const {isLargeScreenWidth} = useResponsiveLayout(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Columns']); const [searchAdvancedFilters = getEmptyObject()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const firstDisplayItemRef = useRef(null); - const backButtonRef = useRef(null); - const hasInitializedDisplayPopupRef = useRef(false); const [selectedDisplayFilter, setSelectedDisplayFilter] = useState< | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY @@ -63,32 +55,6 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort, modalAcce const view = viewOptions.find((option) => option.value === queryJSON.view) ?? viewOptions.at(0) ?? null; const shouldShowColumnsButton = isLargeScreenWidth && (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE || queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); - const handleFirstDisplayItemRef = useCallback( - (node) => { - firstDisplayItemRef.current = node as View | null; - modalAccessibilityTargetRef?.(node); - }, - [modalAccessibilityTargetRef], - ); - - useEffect(() => { - if (!isSmallScreenWidth) { - return; - } - - if (!hasInitializedDisplayPopupRef.current) { - hasInitializedDisplayPopupRef.current = true; - return; - } - - const focusTargetRef = selectedDisplayFilter ? backButtonRef : firstDisplayItemRef; - const animationFrame = requestAnimationFrame(() => { - Accessibility.moveAccessibilityFocus(focusTargetRef); - }); - - return () => cancelAnimationFrame(animationFrame); - }, [isSmallScreenWidth, selectedDisplayFilter]); - const limitValue = searchAdvancedFilters[CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT]; if (!selectedDisplayFilter) { @@ -107,7 +73,6 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort, modalAcce return ( extends {ref?: Ref} ? T | null : never; -type ModalHeadingRef = (node: ModalHeadingNode) => void; -type ModalAccessibilityTargetRef = (node: unknown) => void; - type PopoverComponentProps = { isExpanded: boolean; closeOverlay: () => void; setPopoverWidth?: (width: number | undefined) => void; - modalHeadingRef?: ModalHeadingRef; - modalAccessibilityTargetRef?: ModalAccessibilityTargetRef; }; type DropdownButtonProps = WithSentryLabel & { @@ -45,7 +38,7 @@ type DropdownButtonProps = WithSentryLabel & { viewportOffsetTop: number; /** The component to render in the popover */ - PopoverComponent: ComponentType; + PopoverComponent: (props: PopoverComponentProps) => ReactNode; ButtonComponent?: React.ComponentType<{onPress: () => void; ref: RefObject}>; @@ -63,12 +56,8 @@ type DropdownButtonProps = WithSentryLabel & { /** Wrapper style for the outer view */ wrapperStyle?: StyleProp; - - /** Internal opt-in to delay dismiss accessibility for bottom-docked filter popovers */ - shouldDelayBottomDockedDismissAccessibility?: boolean; }; -const BOTTOM_DOCKED_DISMISS_ACCESSIBILITY_DELAY = 2500; const ANCHOR_ORIGIN = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, @@ -86,7 +75,6 @@ function DropdownButton({ caretWrapperStyle, wrapperStyle, sentryLabel, - shouldDelayBottomDockedDismissAccessibility = false, }: DropdownButtonProps) { // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHL and narrow layout // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -97,8 +85,6 @@ function DropdownButton({ const {windowHeight} = useWindowDimensions(); const triggerRef = useRef(null); const anchorRef = useRef(null); - const modalAccessibilityTargetElementRef = useRef(null); - const [activePopoverComponent, setActivePopoverComponent] = useState(() => PopoverComponent); const [isOverlayVisible, setIsOverlayVisible] = useState(false); const [customPopoverWidth, setCustomPopoverWidth] = useState(undefined); const {calculatePopoverPosition} = usePopoverPosition(); @@ -109,26 +95,6 @@ function DropdownButton({ }); const [willAlertModalBecomeVisible] = useOnyx(ONYXKEYS.MODAL, {selector: willAlertModalBecomeVisibleSelector}); - const shouldUseFilterModalAccessibility = shouldDelayBottomDockedDismissAccessibility && isSmallScreenWidth; - - const handleModalHeadingRef = useCallback((node) => { - modalAccessibilityTargetElementRef.current = node; - }, []); - - const handleModalAccessibilityTargetRef = useCallback((node) => { - modalAccessibilityTargetElementRef.current = node; - }, []); - - const {handleModalShow, initialFocus, shouldEnableBottomDockedDismissAccessibility} = useBottomDockedDismissAccessibility({ - isVisible: isOverlayVisible, - shouldActivate: shouldUseFilterModalAccessibility, - animationDelayMs: 0, - focusTargetRef: modalAccessibilityTargetElementRef, - webFocusMode: 'initialFocus', - dismissAccessibilityMode: 'timer', - dismissAccessibilityPlatforms: 'native', - dismissAccessibilityDelayMs: BOTTOM_DOCKED_DISMISS_ACCESSIBILITY_DELAY, - }); /** * Toggle the overlay between open & closed @@ -147,12 +113,11 @@ function DropdownButton({ * Calculate popover position and toggle overlay */ const calculatePopoverPositionAndToggleOverlay = useCallback(() => { - setActivePopoverComponent(() => PopoverComponent); calculatePopoverPosition(anchorRef, ANCHOR_ORIGIN).then((pos) => { setPopoverTriggerPosition({...pos, vertical: pos.vertical}); toggleOverlay(); }); - }, [PopoverComponent, calculatePopoverPosition, toggleOverlay]); + }, [calculatePopoverPosition, toggleOverlay]); /** * When no items are selected, render the label, otherwise, render the * list of selected items as well @@ -174,7 +139,10 @@ function DropdownButton({ } return {width: actualPopoverWidth}; }, [isSmallScreenWidth, styles, actualPopoverWidth]); - const ActivePopoverComponent = activePopoverComponent; + + const popoverContent = useMemo(() => { + return PopoverComponent({closeOverlay: toggleOverlay, isExpanded: isOverlayVisible, setPopoverWidth: setCustomPopoverWidth}); + }, [PopoverComponent, toggleOverlay, isOverlayVisible]); return ( - + {popoverContent} ); } -export type {PopoverComponentProps, DropdownButtonProps, ModalAccessibilityTargetRef, ModalHeadingRef}; +export type {PopoverComponentProps, DropdownButtonProps}; export default withViewportOffsetTop(DropdownButton); diff --git a/src/components/Search/FilterDropdowns/FeedSelectPopup.tsx b/src/components/Search/FilterDropdowns/FeedSelectPopup.tsx index 20a6e1c5ea59..3c754bf9e490 100644 --- a/src/components/Search/FilterDropdowns/FeedSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/FeedSelectPopup.tsx @@ -1,5 +1,4 @@ import React, {useEffect} from 'react'; -import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/DropdownButton'; import useFilterFeedData from '@components/Search/hooks/useFilterFeedData'; import MultiSelectFilterPopup from '@components/Search/SearchPageHeader/MultiSelectFilterPopup'; import useNetwork from '@hooks/useNetwork'; @@ -8,11 +7,13 @@ import {openSearchCardFiltersPage} from '@libs/actions/Search'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; -type FeedSelectPopupProps = Pick & { +type FeedSelectPopupProps = { + isExpanded: boolean; + closeOverlay: () => void; updateFilterForm: (values: Partial) => void; }; -function FeedSelectPopup({isExpanded, updateFilterForm, closeOverlay, modalHeadingRef}: FeedSelectPopupProps) { +function FeedSelectPopup({isExpanded, updateFilterForm, closeOverlay}: FeedSelectPopupProps) { const {isOffline} = useNetwork(); const [areCardsLoaded] = useOnyx(ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED); const {feedOptions, feedValue} = useFilterFeedData(); @@ -33,7 +34,6 @@ function FeedSelectPopup({isExpanded, updateFilterForm, closeOverlay, modalHeadi loading={shouldShowLoadingState} translationKey="search.filters.feed" closeOverlay={closeOverlay} - modalHeadingRef={modalHeadingRef} onChangeCallback={(items) => updateFilterForm({feed: items.map((item) => item.value)})} /> ); diff --git a/src/components/Search/FilterDropdowns/GroupByPopup.tsx b/src/components/Search/FilterDropdowns/GroupByPopup.tsx index a3ac5045308b..7257ef459b4f 100644 --- a/src/components/Search/FilterDropdowns/GroupByPopup.tsx +++ b/src/components/Search/FilterDropdowns/GroupByPopup.tsx @@ -89,7 +89,6 @@ function GroupByPopup({value, sections, style, onBackButtonPress, closeOverlay, label={translate('search.display.groupBy')} resetSentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_RESET_SINGLE_SELECT} applySentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_APPLY_SINGLE_SELECT} - style={style} > = { text: string; @@ -48,12 +47,9 @@ type MultiSelectPopupProps = { /** Whether the data for the popover is loading */ loading?: boolean; - - /** Visible heading target for modal initial focus */ - modalHeadingRef?: ModalHeadingRef; }; -function MultiSelectPopup({label, loading, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder, modalHeadingRef}: MultiSelectPopupProps) { +function MultiSelectPopup({label, loading, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder}: MultiSelectPopupProps) { const theme = useTheme(); const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -124,7 +120,6 @@ function MultiSelectPopup({label, loading, value, items, close onApply={applyChanges} resetSentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_RESET_MULTI_SELECT} applySentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_APPLY_MULTI_SELECT} - modalHeadingRef={modalHeadingRef} > {!!loading && ( diff --git a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx index fec2b172d178..4996d4eec95a 100644 --- a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx @@ -11,7 +11,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; import BasePopup from './BasePopup'; -import type {ModalHeadingRef} from './DropdownButton'; type SingleSelectItem = { text: string; @@ -50,9 +49,6 @@ type SingleSelectPopupProps = { /** Custom styles for the SelectionList */ selectionListStyle?: SelectionListStyle; - /** Visible heading target for modal initial focus */ - modalHeadingRef?: ModalHeadingRef; - /** Whether SelectionList of popup should stay mounted when popup is not visible. */ shouldShowList?: boolean; }; @@ -69,7 +65,6 @@ function SingleSelectPopup({ defaultValue, style, selectionListStyle, - modalHeadingRef, shouldShowList = true, }: SingleSelectPopupProps) { const {translate} = useLocalize(); @@ -149,8 +144,7 @@ function SingleSelectPopup({ onBackButtonPress={onBackButtonPress} resetSentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_RESET_SINGLE_SELECT} applySentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_APPLY_SINGLE_SELECT} - style={style} - modalHeadingRef={modalHeadingRef} + style={[style]} > void; @@ -39,12 +38,9 @@ type UserSelectPopupProps = { * Set to true to always show search, or false to never show search regardless of user count. */ isSearchable?: boolean; - - /** Visible heading target for modal initial focus */ - modalHeadingRef?: ModalHeadingRef; }; -function UserSelectPopup({label, value, closeOverlay, onChange, isSearchable, modalHeadingRef}: UserSelectPopupProps) { +function UserSelectPopup({value, label, closeOverlay, onChange, isSearchable}: UserSelectPopupProps) { const selectionListRef = useRef | null>(null); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -156,15 +152,11 @@ function UserSelectPopup({label, value, closeOverlay, onChange, isSearchable, mo if (debouncedSearchTerm) { return; } - - const animationFrame = requestAnimationFrame(() => { - setTotalOptionsCount(selectedOptionsForDisplay.length + availableOptions.personalDetails.length + availableOptions.recentReports.length); - }); - - return () => cancelAnimationFrame(animationFrame); + setTotalOptionsCount(selectedOptionsForDisplay.length + availableOptions.personalDetails.length + availableOptions.recentReports.length); }, [debouncedSearchTerm, selectedOptionsForDisplay.length, availableOptions.personalDetails.length, availableOptions.recentReports.length]); const shouldShowSearchInput = isSearchable ?? totalOptionsCount >= CONST.STANDARD_LIST_ITEM_LIMIT; + const textInputOptions = useMemo( () => shouldShowSearchInput @@ -186,7 +178,6 @@ function UserSelectPopup({label, value, closeOverlay, onChange, isSearchable, mo onApply={applyChanges} resetSentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_RESET_USER} applySentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_APPLY_USER} - modalHeadingRef={modalHeadingRef} > ) => void; closeOverlay: () => void; - modalHeadingRef?: ModalHeadingRef; }; function filterPolicyIDSelector(searchAdvancedFiltersForm: OnyxEntry) { return searchAdvancedFiltersForm?.policyID; } -function WorkspaceSelectPopup({policyIDQuery, updateFilterForm, closeOverlay, modalHeadingRef}: WorkspaceSelectPopupProps) { +function WorkspaceSelectPopup({policyIDQuery, updateFilterForm, closeOverlay}: WorkspaceSelectPopupProps) { const {translate} = useLocalize(); const {workspaces, shouldShowWorkspaceSearchInput} = useAdvancedSearchFilters(); const [policyID] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: filterPolicyIDSelector}); @@ -44,7 +42,6 @@ function WorkspaceSelectPopup({policyIDQuery, updateFilterForm, closeOverlay, mo return ( ) => void; }; -function DatePickerFilterPopup({closeOverlay, setPopoverWidth, filterKey, value, translationKey, updateFilterForm, modalHeadingRef}: DatePickerFilterPopupProps) { +function DatePickerFilterPopup({closeOverlay, setPopoverWidth, filterKey, value, translationKey, updateFilterForm}: DatePickerFilterPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); @@ -40,7 +40,6 @@ function DatePickerFilterPopup({closeOverlay, setPopoverWidth, filterKey, value, closeOverlay={closeOverlay} setPopoverWidth={setPopoverWidth} presets={getDatePresets(filterKey, true)} - modalHeadingRef={modalHeadingRef} style={[styles.getPopoverMaxHeight(windowHeight, isInLandscapeMode)]} /> ); diff --git a/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx b/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx index 1ab62b3b288e..f1f6cbe265c5 100644 --- a/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx +++ b/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx @@ -1,11 +1,10 @@ import React from 'react'; -import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/DropdownButton'; import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import MultiSelectPopup from '@components/Search/FilterDropdowns/MultiSelectPopup'; import useLocalize from '@hooks/useLocalize'; import type {TranslationPaths} from '@src/languages/types'; -type MultiSelectFilterPopupProps = Pick & { +type MultiSelectFilterPopupProps = { loading?: boolean; translationKey: TranslationPaths; items: Array>; @@ -15,12 +14,11 @@ type MultiSelectFilterPopupProps = Pick({closeOverlay, loading, translationKey, items, value, onChangeCallback, isSearchable, modalHeadingRef}: MultiSelectFilterPopupProps) { +function MultiSelectFilterPopup({closeOverlay, loading, translationKey, items, value, onChangeCallback, isSearchable}: MultiSelectFilterPopupProps) { const {translate} = useLocalize(); return ( ( + const displayPopup = ({closeOverlay}: {closeOverlay: () => void}) => ( ); @@ -67,7 +66,6 @@ function SearchDisplayDropdownButton({queryJSON, searchResults, onSort}: SearchD value={null} PopoverComponent={displayPopup} ButtonComponent={shouldUseNarrowLayout || isMediumScreenWidth ? displayIconButton : undefined} - shouldDelayBottomDockedDismissAccessibility /> ); } diff --git a/src/components/Search/SearchPageHeader/SearchFilterBar.tsx b/src/components/Search/SearchPageHeader/SearchFilterBar.tsx index 8f3ff49f65ba..7cea47d9a294 100644 --- a/src/components/Search/SearchPageHeader/SearchFilterBar.tsx +++ b/src/components/Search/SearchPageHeader/SearchFilterBar.tsx @@ -21,7 +21,6 @@ function UserDropdown({label, value, PopoverComponent, sentryLabel}: SearchDropd label={label} value={users ?? []} PopoverComponent={PopoverComponent} - shouldDelayBottomDockedDismissAccessibility sentryLabel={sentryLabel} /> ); @@ -34,7 +33,6 @@ function WorkspaceDropdown({label, value, PopoverComponent, sentryLabel}: Search label={label} value={workspaceValue ?? []} PopoverComponent={PopoverComponent} - shouldDelayBottomDockedDismissAccessibility sentryLabel={sentryLabel} /> ); @@ -59,7 +57,6 @@ function CardDropdown({label, PopoverComponent, sentryLabel}: SearchDropdownProp label={label} value={cardValue} PopoverComponent={PopoverComponent} - shouldDelayBottomDockedDismissAccessibility sentryLabel={sentryLabel} /> ); @@ -72,7 +69,6 @@ function TaxRateDropdown({label, PopoverComponent, sentryLabel}: SearchDropdownP label={label} value={taxRateValue} PopoverComponent={PopoverComponent} - shouldDelayBottomDockedDismissAccessibility sentryLabel={sentryLabel} /> ); @@ -85,7 +81,6 @@ function ReportDropdown({label, value, PopoverComponent, sentryLabel}: SearchDro label={label} value={reportValue} PopoverComponent={PopoverComponent} - shouldDelayBottomDockedDismissAccessibility sentryLabel={sentryLabel} /> ); @@ -115,7 +110,6 @@ function SearchFilterBar({item}: {item: SearchFilter & FilterItem}) { label={item.label} value={item.value} PopoverComponent={item.PopoverComponent} - shouldDelayBottomDockedDismissAccessibility sentryLabel={item.sentryLabel} /> ); diff --git a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx index 96d81e1fab94..8227fe025624 100644 --- a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx @@ -114,7 +114,6 @@ function makeDateFilterItem( isExpanded={props.isExpanded} closeOverlay={props.closeOverlay} setPopoverWidth={props.setPopoverWidth} - modalHeadingRef={props.modalHeadingRef} filterKey={filterKey} value={value} translationKey={translationKey} @@ -255,12 +254,11 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes } case FILTER_KEYS.FEED: { return { - PopoverComponent: ({closeOverlay, isExpanded, modalHeadingRef}) => ( + PopoverComponent: ({closeOverlay, isExpanded}) => ( ), sentryLabel: getFilterSentryLabel(filterKey), @@ -309,10 +307,9 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes const formValue = searchAdvancedFiltersForm[filterKey]; const items = getSingleSelectFilterOptions(filterKey, translate); const value = items.find((option) => option.value === formValue) ?? null; - const singleSelectComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( + const singleSelectComponent = ({closeOverlay}: PopoverComponentProps) => ( formValues.includes(item.value)); - const multiSelectComponent = ({closeOverlay, modalHeadingRef}: PopoverComponentProps) => ( + const multiSelectComponent = ({closeOverlay}: PopoverComponentProps) => ( ( + PopoverComponent: ({closeOverlay}) => ( { const update: Partial = {}; @@ -377,12 +372,11 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes }; case FILTER_KEYS.POLICY_ID: return { - PopoverComponent: ({closeOverlay, modalHeadingRef}) => ( + PopoverComponent: ({closeOverlay}) => ( ), sentryLabel: getFilterSentryLabel(filterKey), diff --git a/src/hooks/useBottomDockedDismissAccessibility.ts b/src/hooks/useBottomDockedDismissAccessibility.ts deleted file mode 100644 index 99e8adb8f9bb..000000000000 --- a/src/hooks/useBottomDockedDismissAccessibility.ts +++ /dev/null @@ -1,337 +0,0 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; -import type {RefObject} from 'react'; -import {AccessibilityInfo} from 'react-native'; -import type {ReactNativeElement, View as RNView} from 'react-native'; -import type BaseModalProps from '@components/Modal/types'; -import Accessibility from '@libs/Accessibility'; -import getPlatform from '@libs/getPlatform'; -import CONST from '@src/CONST'; - -type WebFocusMode = 'manual' | 'initialFocus' | 'none'; -type DismissAccessibilityMode = 'none' | 'focusConfirmation' | 'timer'; -type DismissAccessibilityPlatforms = 'ios' | 'native'; -type FocusableElement = {focus: () => void}; - -type UseBottomDockedDismissAccessibilityParams = { - isVisible: boolean; - shouldActivate: boolean; - animationDelayMs: number; - onModalShow?: () => void; - shouldConfirmFirstItemFocus?: boolean; - focusedIndex?: number; - maxFocusRetries?: number; - nativeFocusRetryDelayMs?: number; - focusTargetRef?: RefObject; - webFocusMode?: WebFocusMode; - dismissAccessibilityMode?: DismissAccessibilityMode; - dismissAccessibilityPlatforms?: DismissAccessibilityPlatforms; - dismissAccessibilityDelayMs?: number; -}; - -type UseBottomDockedDismissAccessibilityResult = { - firstItemRef: RefObject; - handleFirstItemFocus: () => void; - handleModalShow: () => void; - shouldEnableBottomDockedDismissAccessibility?: boolean; - initialFocus?: BaseModalProps['initialFocus']; -}; - -const DEFAULT_MAX_FIRST_MENU_ITEM_FOCUS_RETRIES = 5; -const DEFAULT_FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS = 50; -const DEFAULT_WEB_FOCUS_MODE: WebFocusMode = 'manual'; -const DEFAULT_DISMISS_ACCESSIBILITY_PLATFORMS: DismissAccessibilityPlatforms = 'ios'; - -function isFocusableElement(target: unknown): target is FocusableElement { - return typeof target === 'object' && target !== null && 'focus' in target && typeof target.focus === 'function'; -} - -function useBottomDockedDismissAccessibility({ - isVisible, - shouldActivate, - animationDelayMs, - onModalShow, - shouldConfirmFirstItemFocus = false, - focusedIndex, - maxFocusRetries = DEFAULT_MAX_FIRST_MENU_ITEM_FOCUS_RETRIES, - nativeFocusRetryDelayMs = DEFAULT_FIRST_MENU_ITEM_NATIVE_FOCUS_RETRY_DELAY_MS, - focusTargetRef, - webFocusMode = DEFAULT_WEB_FOCUS_MODE, - dismissAccessibilityMode, - dismissAccessibilityPlatforms = DEFAULT_DISMISS_ACCESSIBILITY_PLATFORMS, - dismissAccessibilityDelayMs, -}: UseBottomDockedDismissAccessibilityParams): UseBottomDockedDismissAccessibilityResult { - const platform = getPlatform(); - const isWeb = platform === CONST.PLATFORM.WEB; - const isAndroid = platform === CONST.PLATFORM.ANDROID; - const isIOS = platform === CONST.PLATFORM.IOS; - const resolvedDismissAccessibilityMode = dismissAccessibilityMode ?? (shouldConfirmFirstItemFocus ? 'focusConfirmation' : 'none'); - const shouldUseManualWebFocus = isWeb && shouldActivate && webFocusMode === 'manual'; - const shouldUseInitialWebFocus = isWeb && shouldActivate && webFocusMode === 'initialFocus'; - const shouldUseNativeFocusHandoff = !isWeb && shouldActivate && resolvedDismissAccessibilityMode !== 'timer'; - const shouldDeferDismissButtonAccessibility = - shouldActivate && - resolvedDismissAccessibilityMode !== 'none' && - ((dismissAccessibilityPlatforms === 'native' && (isIOS || isAndroid)) || (dismissAccessibilityPlatforms === 'ios' && isIOS)); - const firstItemRef = useRef(null); - const isVisibleRef = useRef(isVisible); - const hasFocusedTargetOnCurrentOpenRef = useRef(false); - const scheduledFocusRetryTimeoutRef = useRef | null>(null); - const dismissAccessibilityTimeoutRef = useRef | null>(null); - const [shouldEnableBottomDockedDismissAccessibility, setShouldEnableBottomDockedDismissAccessibility] = useState(!shouldDeferDismissButtonAccessibility); - - const clearScheduledFocusTarget = useCallback(() => { - if (!scheduledFocusRetryTimeoutRef.current) { - return; - } - - clearTimeout(scheduledFocusRetryTimeoutRef.current); - scheduledFocusRetryTimeoutRef.current = null; - }, []); - - const clearDismissAccessibilityTimeout = useCallback(() => { - if (!dismissAccessibilityTimeoutRef.current) { - return; - } - - clearTimeout(dismissAccessibilityTimeoutRef.current); - dismissAccessibilityTimeoutRef.current = null; - }, []); - - const markTargetUnfocused = useCallback(() => { - clearScheduledFocusTarget(); - clearDismissAccessibilityTimeout(); - hasFocusedTargetOnCurrentOpenRef.current = false; - setShouldEnableBottomDockedDismissAccessibility(!shouldDeferDismissButtonAccessibility); - }, [clearDismissAccessibilityTimeout, clearScheduledFocusTarget, shouldDeferDismissButtonAccessibility]); - - useEffect(() => { - isVisibleRef.current = isVisible; - if (isVisible) { - return; - } - - const animationFrame = requestAnimationFrame(() => { - markTargetUnfocused(); - }); - - return () => cancelAnimationFrame(animationFrame); - }, [isVisible, markTargetUnfocused]); - - useEffect( - () => () => { - clearScheduledFocusTarget(); - clearDismissAccessibilityTimeout(); - }, - [clearDismissAccessibilityTimeout, clearScheduledFocusTarget], - ); - - const getFocusTarget = useCallback(() => { - if (!isVisibleRef.current || hasFocusedTargetOnCurrentOpenRef.current) { - return null; - } - - return (focusTargetRef ?? firstItemRef).current; - }, [focusTargetRef]); - - const markTargetFocused = useCallback(() => { - clearScheduledFocusTarget(); - clearDismissAccessibilityTimeout(); - hasFocusedTargetOnCurrentOpenRef.current = true; - setShouldEnableBottomDockedDismissAccessibility(true); - }, [clearDismissAccessibilityTimeout, clearScheduledFocusTarget]); - - const focusTargetOnWeb = useCallback(() => { - const target = getFocusTarget(); - if (!isFocusableElement(target)) { - return false; - } - - target.focus(); - markTargetFocused(); - return true; - }, [getFocusTarget, markTargetFocused]); - - const focusTargetOnNative = useCallback(() => { - const target = getFocusTarget(); - if (!target) { - return false; - } - - const accessibilityFocusTargetRef = (focusTargetRef ?? firstItemRef) as RefObject; - const sendAccessibilityEvent = AccessibilityInfo.sendAccessibilityEvent; - if (sendAccessibilityEvent && isAndroid) { - sendAccessibilityEvent(target as ReactNativeElement, 'viewHoverEnter'); - } - - Accessibility.moveAccessibilityFocus(accessibilityFocusTargetRef); - if (!shouldDeferDismissButtonAccessibility || resolvedDismissAccessibilityMode !== 'focusConfirmation' || !shouldConfirmFirstItemFocus) { - markTargetFocused(); - } - return true; - }, [focusTargetRef, getFocusTarget, isAndroid, markTargetFocused, resolvedDismissAccessibilityMode, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); - - const focusTarget = useCallback(() => { - if (isWeb) { - return focusTargetOnWeb(); - } - - return focusTargetOnNative(); - }, [focusTargetOnNative, focusTargetOnWeb, isWeb]); - - const scheduleFocusTargetOnWeb = useCallback(() => { - const focusTargetWithRetries = (retries = maxFocusRetries) => { - if (!isVisibleRef.current || hasFocusedTargetOnCurrentOpenRef.current) { - return; - } - - if (focusTarget()) { - return; - } - - if (retries <= 0) { - return; - } - - requestAnimationFrame(() => focusTargetWithRetries(retries - 1)); - }; - - requestAnimationFrame(() => focusTargetWithRetries()); - }, [focusTarget, maxFocusRetries]); - - const scheduleFocusTargetOnNative = useCallback(() => { - const focusTargetWithRetries = (retries = maxFocusRetries) => { - requestAnimationFrame(() => { - if (!isVisibleRef.current || hasFocusedTargetOnCurrentOpenRef.current) { - return; - } - - focusTarget(); - - if ( - !shouldDeferDismissButtonAccessibility || - resolvedDismissAccessibilityMode !== 'focusConfirmation' || - !shouldConfirmFirstItemFocus || - hasFocusedTargetOnCurrentOpenRef.current - ) { - return; - } - - if (retries <= 0) { - markTargetFocused(); - return; - } - - scheduledFocusRetryTimeoutRef.current = setTimeout(() => { - scheduledFocusRetryTimeoutRef.current = null; - focusTargetWithRetries(retries - 1); - }, nativeFocusRetryDelayMs); - }); - }; - - clearScheduledFocusTarget(); - scheduledFocusRetryTimeoutRef.current = setTimeout( - () => { - scheduledFocusRetryTimeoutRef.current = null; - focusTargetWithRetries(); - }, - shouldDeferDismissButtonAccessibility ? animationDelayMs : 0, - ); - }, [ - animationDelayMs, - clearScheduledFocusTarget, - focusTarget, - markTargetFocused, - maxFocusRetries, - nativeFocusRetryDelayMs, - resolvedDismissAccessibilityMode, - shouldConfirmFirstItemFocus, - shouldDeferDismissButtonAccessibility, - ]); - - const scheduleDismissAccessibilityTimer = useCallback(() => { - if (!shouldDeferDismissButtonAccessibility || resolvedDismissAccessibilityMode !== 'timer') { - return; - } - - clearDismissAccessibilityTimeout(); - setShouldEnableBottomDockedDismissAccessibility(false); - dismissAccessibilityTimeoutRef.current = setTimeout(() => { - setShouldEnableBottomDockedDismissAccessibility(true); - dismissAccessibilityTimeoutRef.current = null; - }, dismissAccessibilityDelayMs ?? animationDelayMs); - }, [animationDelayMs, clearDismissAccessibilityTimeout, dismissAccessibilityDelayMs, resolvedDismissAccessibilityMode, shouldDeferDismissButtonAccessibility]); - - const shouldScheduleFocusTargetOnOpen = shouldUseManualWebFocus || shouldUseNativeFocusHandoff; - - const scheduleFocusTargetOnOpen = useCallback(() => { - if (shouldUseManualWebFocus) { - scheduleFocusTargetOnWeb(); - return; - } - - if (shouldUseNativeFocusHandoff) { - scheduleFocusTargetOnNative(); - } - }, [scheduleFocusTargetOnNative, scheduleFocusTargetOnWeb, shouldUseManualWebFocus, shouldUseNativeFocusHandoff]); - - const handleModalShow = useCallback(() => { - onModalShow?.(); - if (!shouldActivate) { - return; - } - - if (resolvedDismissAccessibilityMode === 'timer') { - scheduleDismissAccessibilityTimer(); - return; - } - - scheduleFocusTargetOnOpen(); - }, [onModalShow, resolvedDismissAccessibilityMode, scheduleDismissAccessibilityTimer, scheduleFocusTargetOnOpen, shouldActivate]); - - useEffect(() => { - if (!isVisible || !shouldActivate || hasFocusedTargetOnCurrentOpenRef.current || !shouldScheduleFocusTargetOnOpen) { - return; - } - - scheduleFocusTargetOnOpen(); - }, [isVisible, scheduleFocusTargetOnOpen, shouldActivate, shouldScheduleFocusTargetOnOpen]); - - useEffect(() => { - if ( - !isVisible || - !shouldDeferDismissButtonAccessibility || - resolvedDismissAccessibilityMode !== 'focusConfirmation' || - !shouldConfirmFirstItemFocus || - focusedIndex !== 0 || - hasFocusedTargetOnCurrentOpenRef.current - ) { - return; - } - - const animationFrame = requestAnimationFrame(() => { - markTargetFocused(); - }); - - return () => cancelAnimationFrame(animationFrame); - }, [focusedIndex, isVisible, markTargetFocused, resolvedDismissAccessibilityMode, shouldConfirmFirstItemFocus, shouldDeferDismissButtonAccessibility]); - - const handleFirstItemFocus = useCallback(() => { - if (hasFocusedTargetOnCurrentOpenRef.current) { - return; - } - - markTargetFocused(); - }, [markTargetFocused]); - - const initialFocus = shouldUseInitialWebFocus ? () => (focusTargetRef ?? firstItemRef).current as NonNullable : undefined; - - return { - firstItemRef, - handleFirstItemFocus, - handleModalShow, - shouldEnableBottomDockedDismissAccessibility: shouldDeferDismissButtonAccessibility ? shouldEnableBottomDockedDismissAccessibility : undefined, - initialFocus, - }; -} - -export default useBottomDockedDismissAccessibility; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts index 86125a76aea7..71c249cf1d10 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts @@ -1,19 +1,12 @@ import {AccessibilityInfo} from 'react-native'; -import type {ReactNativeElement} from 'react-native'; import type MoveAccessibilityFocus from './types'; const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { - const focusTarget = ref && 'current' in ref ? ref.current : ref; - - if (!focusTarget) { + if (!ref) { return; } - AccessibilityInfo.sendAccessibilityEvent(focusTarget as ReactNativeElement, 'focus'); - - if ('focus' in focusTarget && typeof focusTarget.focus === 'function') { - focusTarget.focus(); - } + AccessibilityInfo.sendAccessibilityEvent(ref, 'focus'); }; export default moveAccessibilityFocus; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.ts b/src/libs/Accessibility/moveAccessibilityFocus/index.ts index 884c02aed85a..cafe1a216db3 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.ts @@ -1,15 +1,10 @@ import type MoveAccessibilityFocus from './types'; const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { - const focusTarget = ref && 'current' in ref ? ref.current : ref; - - if (!focusTarget) { + if (!ref?.current) { return; } - - if ('focus' in focusTarget && typeof focusTarget.focus === 'function') { - focusTarget.focus(); - } + ref.current.focus(); }; export default moveAccessibilityFocus; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/types.ts b/src/libs/Accessibility/moveAccessibilityFocus/types.ts index 140eb87d19d6..6756bdd6f773 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/types.ts +++ b/src/libs/Accessibility/moveAccessibilityFocus/types.ts @@ -1,10 +1,6 @@ -import type {RefObject} from 'react'; -import type {NativeMethods} from 'react-native'; +import type {ElementRef, RefObject} from 'react'; +import type {HostComponent} from 'react-native'; -type AccessibilityFocusable = NativeMethods | HTMLOrSVGElement; - -type AccessibilityFocusableRef = RefObject; - -type MoveAccessibilityFocus = (ref?: AccessibilityFocusable | AccessibilityFocusableRef) => void; +type MoveAccessibilityFocus = (ref?: ElementRef> & RefObject) => void; export default MoveAccessibilityFocus; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABFirstItemRefContext.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABFirstItemRefContext.tsx deleted file mode 100644 index a8a3e458fba0..000000000000 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABFirstItemRefContext.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import {createContext} from 'react'; -import type {RefObject} from 'react'; -import type {View} from 'react-native'; - -const FABFirstItemRefContext = createContext>({current: null}); - -export default FABFirstItemRefContext; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx index 50c421b42984..1e5515bc4314 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx @@ -1,8 +1,7 @@ -import React, {useContext} from 'react'; +import React from 'react'; import FocusableMenuItem from '@components/FocusableMenuItem'; import type {MenuItemProps} from '@components/MenuItem'; import CONST from '@src/CONST'; -import FABFirstItemRefContext from './FABFirstItemRefContext'; import useFABMenuItem from './useFABMenuItem'; type FABFocusableMenuItemProps = Omit & { @@ -14,7 +13,6 @@ type FABFocusableMenuItemProps = Omit setFocusedIndex(itemIndex)} wrapperStyle={wrapperStyle} diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index 3815d11c2bde..f3a491d58077 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -4,7 +4,6 @@ import {View} from 'react-native'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; -import useBottomDockedDismissAccessibility from '@hooks/useBottomDockedDismissAccessibility'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -12,7 +11,6 @@ import {close} from '@libs/actions/Modal'; import {isSafari} from '@libs/Browser'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import CONST from '@src/CONST'; -import FABFirstItemRefContext from './FABFirstItemRefContext'; import {FABMenuContext} from './FABMenuContext'; const FAB_ITEM_ORDER = [ @@ -44,6 +42,7 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio const [contentActivityMode, setContentActivityMode] = useState(isVisible ? 'visible' : 'hidden'); const [registeredSet, setRegisteredSet] = useState>(new Set()); + const registeredItems = FAB_ITEM_ORDER.filter((id) => registeredSet.has(id)); const itemCount = registeredItems.length; @@ -74,13 +73,6 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio maxIndex: itemCount - 1, isActive: isVisible, }); - const {firstItemRef, handleModalShow, shouldEnableBottomDockedDismissAccessibility} = useBottomDockedDismissAccessibility({ - isVisible, - shouldActivate: shouldUseNarrowLayout, - animationDelayMs: animationInTiming ?? 0, - shouldConfirmFirstItemFocus: true, - focusedIndex, - }); const handleClose = () => { setFocusedIndex(-1); @@ -111,41 +103,37 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio unregisterItem, }} > - - setContentActivityMode('visible')} - onModalShow={handleModalShow} - onModalHide={() => setContentActivityMode('hidden')} - fromSidebarMediumScreen={!shouldUseNarrowLayout} - animationIn="fadeIn" - animationOut="fadeOut" - animationInTiming={animationInTiming} - animationOutTiming={animationOutTiming} - disableAnimation={false} - shouldHandleNavigationBack - shouldEnableBottomDockedDismissAccessibility={shouldEnableBottomDockedDismissAccessibility} - innerContainerStyle={styles.pv0} + setContentActivityMode('visible')} + onModalHide={() => setContentActivityMode('hidden')} + fromSidebarMediumScreen={!shouldUseNarrowLayout} + animationIn="fadeIn" + animationOut="fadeOut" + animationInTiming={animationInTiming} + animationOutTiming={animationOutTiming} + disableAnimation={false} + shouldHandleNavigationBack + innerContainerStyle={styles.pv0} + > + - - - - {children} - - - - - + + + {children} + + + + ); } From f8a4b0672ef20667686a572b9c0947084e0d69ac Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 28 Apr 2026 14:22:18 +0430 Subject: [PATCH 36/36] added DismissDialog to CONST --- src/CONST/index.ts | 3 +++ src/components/Modal/BaseModal.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 11d8c46b711a..40ae54db4529 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8829,6 +8829,9 @@ const CONST = { SEND_BUTTON: 'AttachmentModal-SendButton', IMAGE_ZOOM: 'AttachmentModal-ImageZoom', }, + MODAL: { + DISMISS_DIALOG: 'Modal-DismissDialog', + }, HEADER: { BACK_BUTTON: 'Header-BackButton', DOWNLOAD_BUTTON: 'Header-DownloadButton', diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index e2501020212e..16cd10802baa 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -402,7 +402,7 @@ function BaseModal({ role={CONST.ROLE.BUTTON} accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.dismiss')} - sentryLabel="Modal-DismissDialog" + sentryLabel={CONST.SENTRY_LABEL.MODAL.DISMISS_DIALOG} style={styles.bottomDockedModalDismissButton} >