From bbdd92fc1c982184adfd321d0037f3a103e5d9f4 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sun, 1 Mar 2026 11:41:55 +0430 Subject: [PATCH] fix(accessibility): make empty results message readable by screen readers --- .../SelectionList/components/TextInput.tsx | 16 ++++++++-- .../BaseSelectionListWithSections.tsx | 18 +++++++++-- ...tusMessageAccessibilityAnnouncement.ios.ts | 31 +++++++++++++++++++ ...eStatusMessageAccessibilityAnnouncement.ts | 6 ++++ 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 src/components/utils/useStatusMessageAccessibilityAnnouncement.ios.ts create mode 100644 src/components/utils/useStatusMessageAccessibilityAnnouncement.ts diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 42e39a5514eeb..cab1d6004f4a0 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -6,6 +6,7 @@ import type {TextInputOptions} from '@components/SelectionList/types'; import Text from '@components/Text'; import BaseTextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import useStatusMessageAccessibilityAnnouncement from '@components/utils/useStatusMessageAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import mergeRefs from '@libs/mergeRefs'; @@ -66,9 +67,11 @@ function TextInput({ const styles = useThemeStyles(); const {translate} = useLocalize(); const {label, value, onChangeText, errorText, headerMessage, hint, disableAutoFocus, placeholder, maxLength, inputMode, ref: optionsRef, style, disableAutoCorrect} = options ?? {}; - const resultsFound = headerMessage !== translate('common.noResultsFound'); + const noResultsFoundText = translate('common.noResultsFound'); + const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const noData = dataLength === 0 && !showLoadingPlaceholder; - const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || resultsFound || noData); + const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); + const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); @@ -106,6 +109,8 @@ function TextInput({ onFocusChange(false); }, [onFocusChange]); + useStatusMessageAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults); + if (!shouldShowTextInput) { return null; } @@ -140,7 +145,12 @@ function TextInput({ {shouldShowHeaderMessage && ( - {headerMessage} + + {headerMessage} + )} diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 4e5bc9aeebe6e..9c3ff954b71ca 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -12,6 +12,7 @@ import {PressableWithFeedback} from '@components/Pressable'; import SectionList from '@components/SectionList'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import useStatusMessageAccessibilityAnnouncement from '@components/utils/useStatusMessageAccessibilityAnnouncement'; import useActiveElementRole from '@hooks/useActiveElementRole'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -1018,11 +1019,22 @@ function BaseSelectionListWithSections({ }, ); + const noResultsFoundText = translate('common.noResultsFound'); + const isNoResultsFoundMessage = headerMessage === noResultsFoundText; + const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !showLoadingPlaceholder)); + const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; + + useStatusMessageAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults); + const headerMessageContent = () => - (!isLoadingNewOptions || headerMessage !== translate('common.noResultsFound') || (flattenedSections.allOptions.length === 0 && !showLoadingPlaceholder)) && - !!headerMessage && ( + shouldShowHeaderMessage && ( - {headerMessage} + + {headerMessage} + ); diff --git a/src/components/utils/useStatusMessageAccessibilityAnnouncement.ios.ts b/src/components/utils/useStatusMessageAccessibilityAnnouncement.ios.ts new file mode 100644 index 0000000000000..fa21c92dbfa7a --- /dev/null +++ b/src/components/utils/useStatusMessageAccessibilityAnnouncement.ios.ts @@ -0,0 +1,31 @@ +import type {ReactNode} from 'react'; +import {useEffect, useRef} from 'react'; +import {AccessibilityInfo} from 'react-native'; + +const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; + +function useStatusMessageAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean) { + const previousAnnouncedMessageRef = useRef(''); + + useEffect(() => { + if (!shouldAnnounceMessage || typeof message !== 'string' || !message.trim()) { + previousAnnouncedMessageRef.current = ''; + return; + } + + if (previousAnnouncedMessageRef.current === message) { + return; + } + + previousAnnouncedMessageRef.current = message; + + // On iOS real devices, a brief delay helps the accessibility tree sync before announcing. + const timeout = setTimeout(() => { + AccessibilityInfo.announceForAccessibility(message); + }, DELAY_FOR_ACCESSIBILITY_TREE_SYNC); + + return () => clearTimeout(timeout); + }, [message, shouldAnnounceMessage]); +} + +export default useStatusMessageAccessibilityAnnouncement; diff --git a/src/components/utils/useStatusMessageAccessibilityAnnouncement.ts b/src/components/utils/useStatusMessageAccessibilityAnnouncement.ts new file mode 100644 index 0000000000000..d1e034276297b --- /dev/null +++ b/src/components/utils/useStatusMessageAccessibilityAnnouncement.ts @@ -0,0 +1,6 @@ +import type {ReactNode} from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function useStatusMessageAccessibilityAnnouncement(_message: string | ReactNode, _shouldAnnounceMessage: boolean) {} + +export default useStatusMessageAccessibilityAnnouncement;