From bbdd92fc1c982184adfd321d0037f3a103e5d9f4 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sun, 1 Mar 2026 11:41:55 +0430 Subject: [PATCH 1/4] 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 42e39a5514ee..cab1d6004f4a 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 4e5bc9aeebe6..9c3ff954b71c 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 000000000000..fa21c92dbfa7 --- /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 000000000000..d1e034276297 --- /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; From 00e47c61df034842c139e043d945ce9b9894ab70 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 4 Mar 2026 15:15:00 +0430 Subject: [PATCH 2/4] fixed on macos --- .../SelectionList/components/TextInput.tsx | 39 ++++++++++++++++++- .../BaseSelectionListWithSections.tsx | 35 ++++++++++++++++- src/styles/index.ts | 17 ++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index cab1d6004f4a..5c44841d782c 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -1,5 +1,5 @@ import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useRef} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; import {View} from 'react-native'; import type {TextInputOptions} from '@components/SelectionList/types'; @@ -9,6 +9,7 @@ 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 getPlatform from '@libs/getPlatform'; import mergeRefs from '@libs/mergeRefs'; import CONST from '@src/CONST'; @@ -50,6 +51,8 @@ type TextInputProps = { focusTextInput: () => void; }; +const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; + function TextInput({ ref, options, @@ -72,6 +75,9 @@ function TextInput({ const noData = dataLength === 0 && !showLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; + const shouldUsePersistentLiveRegion = getPlatform() === CONST.PLATFORM.WEB; + const [liveRegionMessage, setLiveRegionMessage] = useState(''); + const liveRegionToggleRef = useRef(false); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); @@ -111,6 +117,27 @@ function TextInput({ useStatusMessageAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults); + useEffect(() => { + if (!shouldUsePersistentLiveRegion) { + return; + } + + if (!shouldAnnounceNoResults) { + setLiveRegionMessage(''); + return; + } + + // Toggling content forces re-announcement even when the text doesn't change. + const suffix = liveRegionToggleRef.current ? '\u200B' : ''; + liveRegionToggleRef.current = !liveRegionToggleRef.current; + + // Clear first so screen readers detect a change, then set the message on next tick. + setLiveRegionMessage(''); + const timeoutId = setTimeout(() => setLiveRegionMessage(`${headerMessage}${suffix}`), DELAY_FOR_ACCESSIBILITY_TREE_SYNC); + + return () => clearTimeout(timeoutId); + }, [headerMessage, shouldAnnounceNoResults, shouldUsePersistentLiveRegion]); + if (!shouldShowTextInput) { return null; } @@ -147,12 +174,20 @@ function TextInput({ {headerMessage} )} + {shouldUsePersistentLiveRegion && ( + + {liveRegionMessage} + + )} ); } diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 9c3ff954b71c..133d2827ce81 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -24,6 +24,7 @@ import useScrollEnabled from '@hooks/useScrollEnabled'; import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; +import getPlatform from '@libs/getPlatform'; import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset'; import Log from '@libs/Log'; import variables from '@styles/variables'; @@ -37,6 +38,8 @@ import type {ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, SectionLi const getDefaultItemHeight = () => variables.optionRowHeight; +const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; + function BaseSelectionListWithSections({ sections, ListItem, @@ -1023,15 +1026,37 @@ function BaseSelectionListWithSections({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !showLoadingPlaceholder)); const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; + const shouldUsePersistentLiveRegion = getPlatform() === CONST.PLATFORM.WEB; + const [liveRegionMessage, setLiveRegionMessage] = useState(''); + const liveRegionToggleRef = useRef(false); useStatusMessageAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults); + useEffect(() => { + if (!shouldUsePersistentLiveRegion) { + return; + } + + if (!shouldAnnounceNoResults) { + setLiveRegionMessage(''); + return; + } + + const suffix = liveRegionToggleRef.current ? '\u200B' : ''; + liveRegionToggleRef.current = !liveRegionToggleRef.current; + + setLiveRegionMessage(''); + const timeoutId = setTimeout(() => setLiveRegionMessage(`${headerMessage}${suffix}`), DELAY_FOR_ACCESSIBILITY_TREE_SYNC); + + return () => clearTimeout(timeoutId); + }, [headerMessage, shouldAnnounceNoResults, shouldUsePersistentLiveRegion]); + const headerMessageContent = () => shouldShowHeaderMessage && ( {headerMessage} @@ -1054,6 +1079,14 @@ function BaseSelectionListWithSections({ // TODO: test _every_ component that uses SelectionList return ( + {shouldUsePersistentLiveRegion && ( + + {liveRegionMessage} + + )} {shouldShowTextInput && !shouldShowTextInputAfterHeader && renderInput()} {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} {/* This is misleading because we might be in the process of loading fresh options from the server. */} diff --git a/src/styles/index.ts b/src/styles/index.ts index 36ef9fa45340..92514d67eb37 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -966,6 +966,23 @@ const staticStyles = (theme: ThemeColors) => height: 0, }, + /** + * Visually hides live-region content while keeping it in the accessibility tree. + * Needed for platforms (e.g., macOS Safari) that require a pre-mounted live region. + */ + accessibilityLiveRegionSROnly: { + position: 'absolute', + left: -9999, + top: 0, + width: 1, + height: 1, + overflow: 'hidden', + padding: 0, + margin: 0, + borderWidth: 0, + opacity: 0, + }, + visibilityHidden: { ...visibility.hidden, }, From ce59e7640994aa6256da0e43d77c7ef80331747b Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Sat, 7 Mar 2026 14:05:24 +0430 Subject: [PATCH 3/4] Fix no-results accessibility announcements across selection lists --- src/components/AccessibilityLiveRegion.tsx | 22 +++++++++ src/components/FormHelpMessage.tsx | 4 +- .../SelectionList/components/TextInput.tsx | 47 ++----------------- .../BaseSelectionListWithSections.tsx | 43 ++--------------- ...elpMessageAccessibilityAnnouncement.ios.ts | 31 ------------ ...ormHelpMessageAccessibilityAnnouncement.ts | 6 --- ...eStatusMessageAccessibilityAnnouncement.ts | 6 --- .../index.ios.ts} | 7 ++- .../useAccessibilityAnnouncement/index.ts | 8 ++++ .../useAccessibilityAnnouncement/index.web.ts | 33 +++++++++++++ 10 files changed, 79 insertions(+), 128 deletions(-) create mode 100644 src/components/AccessibilityLiveRegion.tsx delete mode 100644 src/components/utils/useFormHelpMessageAccessibilityAnnouncement.ios.ts delete mode 100644 src/components/utils/useFormHelpMessageAccessibilityAnnouncement.ts delete mode 100644 src/components/utils/useStatusMessageAccessibilityAnnouncement.ts rename src/{components/utils/useStatusMessageAccessibilityAnnouncement.ios.ts => hooks/useAccessibilityAnnouncement/index.ios.ts} (73%) create mode 100644 src/hooks/useAccessibilityAnnouncement/index.ts create mode 100644 src/hooks/useAccessibilityAnnouncement/index.web.ts diff --git a/src/components/AccessibilityLiveRegion.tsx b/src/components/AccessibilityLiveRegion.tsx new file mode 100644 index 000000000000..a65160816151 --- /dev/null +++ b/src/components/AccessibilityLiveRegion.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Text from './Text'; + +type AccessibilityLiveRegionProps = { + message: string; +}; + +function AccessibilityLiveRegion({message}: AccessibilityLiveRegionProps) { + const styles = useThemeStyles(); + + return ( + + {message} + + ); +} + +export default AccessibilityLiveRegion; diff --git a/src/components/FormHelpMessage.tsx b/src/components/FormHelpMessage.tsx index cf96b6ace7f5..e4e40bec393b 100644 --- a/src/components/FormHelpMessage.tsx +++ b/src/components/FormHelpMessage.tsx @@ -2,6 +2,7 @@ import isEmpty from 'lodash/isEmpty'; import React, {useMemo} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -11,7 +12,6 @@ import CONST from '@src/CONST'; import Icon from './Icon'; import RenderHTML from './RenderHTML'; import Text from './Text'; -import useFormHelpMessageAccessibilityAnnouncement from './utils/useFormHelpMessageAccessibilityAnnouncement'; type FormHelpMessageProps = { /** Error or hint text. Ignored when children is not empty */ @@ -69,7 +69,7 @@ function FormHelpMessage({ return `${replacedText}`; }, [isError, message, shouldRenderMessageAsHTML]); - useFormHelpMessageAccessibilityAnnouncement(message, shouldAnnounceError); + useAccessibilityAnnouncement(message, shouldAnnounceError); const errorIconLabel = isError && shouldShowRedDotIndicator ? CONST.ACCESSIBILITY_LABELS.ERROR : undefined; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 75ddd7876d80..9f4c4293dda7 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -1,15 +1,15 @@ import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useRef} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; import {View} from 'react-native'; +import AccessibilityLiveRegion from '@components/AccessibilityLiveRegion'; 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 useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import getPlatform from '@libs/getPlatform'; import mergeRefs from '@libs/mergeRefs'; import CONST from '@src/CONST'; @@ -51,8 +51,6 @@ type TextInputProps = { focusTextInput: () => void; }; -const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; - function TextInput({ ref, options, @@ -75,9 +73,7 @@ function TextInput({ const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; - const shouldUsePersistentLiveRegion = getPlatform() === CONST.PLATFORM.WEB; - const [liveRegionMessage, setLiveRegionMessage] = useState(''); - const liveRegionToggleRef = useRef(false); + const {liveRegionMessage, shouldUsePersistentLiveRegion} = useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, true); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); @@ -115,32 +111,6 @@ function TextInput({ onFocusChange(false); }, [onFocusChange]); - useStatusMessageAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults); - - useEffect(() => { - if (!shouldUsePersistentLiveRegion) { - return; - } - - if (!shouldAnnounceNoResults) { - const clearTimeoutId = setTimeout(() => setLiveRegionMessage(''), 0); - return () => clearTimeout(clearTimeoutId); - } - - // Toggling content forces re-announcement even when the text doesn't change. - const suffix = liveRegionToggleRef.current ? '\u200B' : ''; - liveRegionToggleRef.current = !liveRegionToggleRef.current; - - // Clear first so screen readers detect a change, then set the message on next tick. - const clearTimeoutId = setTimeout(() => setLiveRegionMessage(''), 0); - const timeoutId = setTimeout(() => setLiveRegionMessage(`${headerMessage}${suffix}`), DELAY_FOR_ACCESSIBILITY_TREE_SYNC); - - return () => { - clearTimeout(clearTimeoutId); - clearTimeout(timeoutId); - }; - }, [headerMessage, shouldAnnounceNoResults, shouldUsePersistentLiveRegion]); - if (!shouldShowTextInput) { return null; } @@ -183,14 +153,7 @@ function TextInput({ )} - {shouldUsePersistentLiveRegion && ( - - {liveRegionMessage} - - )} + {shouldUsePersistentLiveRegion && } ); } diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 9d9c0dadddfe..9081fbe1fd82 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -4,6 +4,7 @@ import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListData, SectionListRenderItemInfo, TextInputKeyPressEvent} from 'react-native'; import {View} from 'react-native'; +import AccessibilityLiveRegion from '@components/AccessibilityLiveRegion'; import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; import FixedFooter from '@components/FixedFooter'; @@ -12,7 +13,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 useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useActiveElementRole from '@hooks/useActiveElementRole'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -24,7 +25,6 @@ import useScrollEnabled from '@hooks/useScrollEnabled'; import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; -import getPlatform from '@libs/getPlatform'; import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset'; import Log from '@libs/Log'; import variables from '@styles/variables'; @@ -38,8 +38,6 @@ import type {ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, SectionLi const getDefaultItemHeight = () => variables.optionRowHeight; -const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; - function BaseSelectionListWithSections({ sections, ListItem, @@ -1007,33 +1005,7 @@ function BaseSelectionListWithSections({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !shouldShowLoadingPlaceholder)); const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; - const shouldUsePersistentLiveRegion = getPlatform() === CONST.PLATFORM.WEB; - const [liveRegionMessage, setLiveRegionMessage] = useState(''); - const liveRegionToggleRef = useRef(false); - - useStatusMessageAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults); - - useEffect(() => { - if (!shouldUsePersistentLiveRegion) { - return; - } - - if (!shouldAnnounceNoResults) { - const clearTimeoutId = setTimeout(() => setLiveRegionMessage(''), 0); - return () => clearTimeout(clearTimeoutId); - } - - const suffix = liveRegionToggleRef.current ? '\u200B' : ''; - liveRegionToggleRef.current = !liveRegionToggleRef.current; - - const clearTimeoutId = setTimeout(() => setLiveRegionMessage(''), 0); - const timeoutId = setTimeout(() => setLiveRegionMessage(`${headerMessage}${suffix}`), DELAY_FOR_ACCESSIBILITY_TREE_SYNC); - - return () => { - clearTimeout(clearTimeoutId); - clearTimeout(timeoutId); - }; - }, [headerMessage, shouldAnnounceNoResults, shouldUsePersistentLiveRegion]); + const {liveRegionMessage, shouldUsePersistentLiveRegion} = useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, true); const headerMessageContent = () => shouldShowHeaderMessage && ( @@ -1063,14 +1035,7 @@ function BaseSelectionListWithSections({ // TODO: test _every_ component that uses SelectionList return ( - {shouldUsePersistentLiveRegion && ( - - {liveRegionMessage} - - )} + {shouldUsePersistentLiveRegion && } {shouldShowTextInput && !shouldShowTextInputAfterHeader && renderInput()} {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} {/* This is misleading because we might be in the process of loading fresh options from the server. */} diff --git a/src/components/utils/useFormHelpMessageAccessibilityAnnouncement.ios.ts b/src/components/utils/useFormHelpMessageAccessibilityAnnouncement.ios.ts deleted file mode 100644 index b891fbbd96b0..000000000000 --- a/src/components/utils/useFormHelpMessageAccessibilityAnnouncement.ios.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type {ReactNode} from 'react'; -import {useEffect, useRef} from 'react'; -import {AccessibilityInfo} from 'react-native'; - -const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; - -function useFormHelpMessageAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceError: boolean) { - const previousAnnouncedMessageRef = useRef(''); - - useEffect(() => { - if (!shouldAnnounceError || 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, shouldAnnounceError]); -} - -export default useFormHelpMessageAccessibilityAnnouncement; diff --git a/src/components/utils/useFormHelpMessageAccessibilityAnnouncement.ts b/src/components/utils/useFormHelpMessageAccessibilityAnnouncement.ts deleted file mode 100644 index 2183a4d40775..000000000000 --- a/src/components/utils/useFormHelpMessageAccessibilityAnnouncement.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {ReactNode} from 'react'; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function useFormHelpMessageAccessibilityAnnouncement(_message: string | ReactNode, _shouldAnnounceError: boolean) {} - -export default useFormHelpMessageAccessibilityAnnouncement; diff --git a/src/components/utils/useStatusMessageAccessibilityAnnouncement.ts b/src/components/utils/useStatusMessageAccessibilityAnnouncement.ts deleted file mode 100644 index d1e034276297..000000000000 --- a/src/components/utils/useStatusMessageAccessibilityAnnouncement.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {ReactNode} from 'react'; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function useStatusMessageAccessibilityAnnouncement(_message: string | ReactNode, _shouldAnnounceMessage: boolean) {} - -export default useStatusMessageAccessibilityAnnouncement; diff --git a/src/components/utils/useStatusMessageAccessibilityAnnouncement.ios.ts b/src/hooks/useAccessibilityAnnouncement/index.ios.ts similarity index 73% rename from src/components/utils/useStatusMessageAccessibilityAnnouncement.ios.ts rename to src/hooks/useAccessibilityAnnouncement/index.ios.ts index fa21c92dbfa7..7d3bdfac020d 100644 --- a/src/components/utils/useStatusMessageAccessibilityAnnouncement.ios.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ios.ts @@ -4,7 +4,8 @@ import {AccessibilityInfo} from 'react-native'; const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; -function useStatusMessageAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean) { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, _shouldUsePersistentLiveRegionOnWeb = false) { const previousAnnouncedMessageRef = useRef(''); useEffect(() => { @@ -26,6 +27,8 @@ function useStatusMessageAccessibilityAnnouncement(message: string | ReactNode, return () => clearTimeout(timeout); }, [message, shouldAnnounceMessage]); + + return {liveRegionMessage: '', shouldUsePersistentLiveRegion: false} as const; } -export default useStatusMessageAccessibilityAnnouncement; +export default useAccessibilityAnnouncement; diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts new file mode 100644 index 000000000000..aad6768a86ee --- /dev/null +++ b/src/hooks/useAccessibilityAnnouncement/index.ts @@ -0,0 +1,8 @@ +import type {ReactNode} from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function useAccessibilityAnnouncement(_message: string | ReactNode, _shouldAnnounceMessage: boolean, _shouldUsePersistentLiveRegionOnWeb = false) { + return {liveRegionMessage: '', shouldUsePersistentLiveRegion: false} as const; +} + +export default useAccessibilityAnnouncement; diff --git a/src/hooks/useAccessibilityAnnouncement/index.web.ts b/src/hooks/useAccessibilityAnnouncement/index.web.ts new file mode 100644 index 000000000000..10eb240dc601 --- /dev/null +++ b/src/hooks/useAccessibilityAnnouncement/index.web.ts @@ -0,0 +1,33 @@ +import type {ReactNode} from 'react'; +import {useEffect, useRef, useState} from 'react'; + +const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; + +function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, shouldUsePersistentLiveRegionOnWeb = false) { + const [liveRegionMessage, setLiveRegionMessage] = useState(''); + const liveRegionToggleRef = useRef(false); + + useEffect(() => { + if (!shouldUsePersistentLiveRegionOnWeb || !shouldAnnounceMessage || typeof message !== 'string' || !message.trim()) { + const clearTimeoutId = setTimeout(() => setLiveRegionMessage(''), 0); + return () => clearTimeout(clearTimeoutId); + } + + // Toggling content forces re-announcement even when the text doesn't change. + const suffix = liveRegionToggleRef.current ? '\u200B' : ''; + liveRegionToggleRef.current = !liveRegionToggleRef.current; + + // Clear first so screen readers detect a change, then set the message on the next tick. + const clearTimeoutId = setTimeout(() => setLiveRegionMessage(''), 0); + const timeoutId = setTimeout(() => setLiveRegionMessage(`${message}${suffix}`), DELAY_FOR_ACCESSIBILITY_TREE_SYNC); + + return () => { + clearTimeout(clearTimeoutId); + clearTimeout(timeoutId); + }; + }, [message, shouldAnnounceMessage, shouldUsePersistentLiveRegionOnWeb]); + + return {liveRegionMessage, shouldUsePersistentLiveRegion: shouldUsePersistentLiveRegionOnWeb} as const; +} + +export default useAccessibilityAnnouncement; From 87d518e8e05685d78cbdc60b8e8b6047b6282388 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 10 Mar 2026 13:52:49 +0430 Subject: [PATCH 4/4] Fix no-results accessibility announcements on native --- src/components/AccessibilityLiveRegion.tsx | 22 ------------- .../SelectionList/components/TextInput.tsx | 12 ++----- .../BaseSelectionListWithSections.tsx | 12 ++----- .../useAccessibilityAnnouncement/index.ios.ts | 8 +++-- .../index.native.ts | 28 ++++++++++++++++ .../useAccessibilityAnnouncement/index.ts | 8 +++-- .../useAccessibilityAnnouncement/index.web.ts | 33 ------------------- src/styles/index.ts | 17 ---------- 8 files changed, 44 insertions(+), 96 deletions(-) delete mode 100644 src/components/AccessibilityLiveRegion.tsx create mode 100644 src/hooks/useAccessibilityAnnouncement/index.native.ts delete mode 100644 src/hooks/useAccessibilityAnnouncement/index.web.ts diff --git a/src/components/AccessibilityLiveRegion.tsx b/src/components/AccessibilityLiveRegion.tsx deleted file mode 100644 index a65160816151..000000000000 --- a/src/components/AccessibilityLiveRegion.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Text from './Text'; - -type AccessibilityLiveRegionProps = { - message: string; -}; - -function AccessibilityLiveRegion({message}: AccessibilityLiveRegionProps) { - const styles = useThemeStyles(); - - return ( - - {message} - - ); -} - -export default AccessibilityLiveRegion; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 9f4c4293dda7..7eacdd401655 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -2,7 +2,6 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useRef} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; import {View} from 'react-native'; -import AccessibilityLiveRegion from '@components/AccessibilityLiveRegion'; import type {TextInputOptions} from '@components/SelectionList/types'; import Text from '@components/Text'; import BaseTextInput from '@components/TextInput'; @@ -73,7 +72,8 @@ function TextInput({ const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; - const {liveRegionMessage, shouldUsePersistentLiveRegion} = useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, true); + + useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); @@ -145,15 +145,9 @@ function TextInput({ {shouldShowHeaderMessage && ( - - {headerMessage} - + {headerMessage} )} - {shouldUsePersistentLiveRegion && } ); } diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 9081fbe1fd82..966ca077f18f 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -4,7 +4,6 @@ import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListData, SectionListRenderItemInfo, TextInputKeyPressEvent} from 'react-native'; import {View} from 'react-native'; -import AccessibilityLiveRegion from '@components/AccessibilityLiveRegion'; import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; import FixedFooter from '@components/FixedFooter'; @@ -1005,17 +1004,13 @@ function BaseSelectionListWithSections({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !shouldShowLoadingPlaceholder)); const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; - const {liveRegionMessage, shouldUsePersistentLiveRegion} = useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, true); + + useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); const headerMessageContent = () => shouldShowHeaderMessage && ( - - {headerMessage} - + {headerMessage} ); @@ -1035,7 +1030,6 @@ function BaseSelectionListWithSections({ // TODO: test _every_ component that uses SelectionList return ( - {shouldUsePersistentLiveRegion && } {shouldShowTextInput && !shouldShowTextInputAfterHeader && renderInput()} {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} {/* This is misleading because we might be in the process of loading fresh options from the server. */} diff --git a/src/hooks/useAccessibilityAnnouncement/index.ios.ts b/src/hooks/useAccessibilityAnnouncement/index.ios.ts index 7d3bdfac020d..3d21af2b7b7b 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ios.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ios.ts @@ -2,10 +2,14 @@ import type {ReactNode} from 'react'; import {useEffect, useRef} from 'react'; import {AccessibilityInfo} from 'react-native'; +type UseAccessibilityAnnouncementOptions = { + shouldAnnounceOnNative?: boolean; +}; + const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; // eslint-disable-next-line @typescript-eslint/no-unused-vars -function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, _shouldUsePersistentLiveRegionOnWeb = false) { +function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, _options?: UseAccessibilityAnnouncementOptions) { const previousAnnouncedMessageRef = useRef(''); useEffect(() => { @@ -27,8 +31,6 @@ function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounc return () => clearTimeout(timeout); }, [message, shouldAnnounceMessage]); - - return {liveRegionMessage: '', shouldUsePersistentLiveRegion: false} as const; } export default useAccessibilityAnnouncement; diff --git a/src/hooks/useAccessibilityAnnouncement/index.native.ts b/src/hooks/useAccessibilityAnnouncement/index.native.ts new file mode 100644 index 000000000000..b66f9540f3ab --- /dev/null +++ b/src/hooks/useAccessibilityAnnouncement/index.native.ts @@ -0,0 +1,28 @@ +import type {ReactNode} from 'react'; +import {useEffect, useRef} from 'react'; +import {AccessibilityInfo} from 'react-native'; + +type UseAccessibilityAnnouncementOptions = { + shouldAnnounceOnNative?: boolean; +}; + +function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, options?: UseAccessibilityAnnouncementOptions) { + const previousAnnouncedMessageRef = useRef(''); + const shouldAnnounceOnNative = options?.shouldAnnounceOnNative ?? false; + + useEffect(() => { + if (!shouldAnnounceOnNative || !shouldAnnounceMessage || typeof message !== 'string' || !message.trim()) { + previousAnnouncedMessageRef.current = ''; + return; + } + + if (previousAnnouncedMessageRef.current === message) { + return; + } + + previousAnnouncedMessageRef.current = message; + AccessibilityInfo.announceForAccessibility(message); + }, [message, shouldAnnounceMessage, shouldAnnounceOnNative]); +} + +export default useAccessibilityAnnouncement; diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts index aad6768a86ee..c853cec379be 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ts @@ -1,8 +1,10 @@ import type {ReactNode} from 'react'; +type UseAccessibilityAnnouncementOptions = { + shouldAnnounceOnNative?: boolean; +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -function useAccessibilityAnnouncement(_message: string | ReactNode, _shouldAnnounceMessage: boolean, _shouldUsePersistentLiveRegionOnWeb = false) { - return {liveRegionMessage: '', shouldUsePersistentLiveRegion: false} as const; -} +function useAccessibilityAnnouncement(_message: string | ReactNode, _shouldAnnounceMessage: boolean, _options?: UseAccessibilityAnnouncementOptions) {} export default useAccessibilityAnnouncement; diff --git a/src/hooks/useAccessibilityAnnouncement/index.web.ts b/src/hooks/useAccessibilityAnnouncement/index.web.ts deleted file mode 100644 index 10eb240dc601..000000000000 --- a/src/hooks/useAccessibilityAnnouncement/index.web.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {ReactNode} from 'react'; -import {useEffect, useRef, useState} from 'react'; - -const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; - -function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, shouldUsePersistentLiveRegionOnWeb = false) { - const [liveRegionMessage, setLiveRegionMessage] = useState(''); - const liveRegionToggleRef = useRef(false); - - useEffect(() => { - if (!shouldUsePersistentLiveRegionOnWeb || !shouldAnnounceMessage || typeof message !== 'string' || !message.trim()) { - const clearTimeoutId = setTimeout(() => setLiveRegionMessage(''), 0); - return () => clearTimeout(clearTimeoutId); - } - - // Toggling content forces re-announcement even when the text doesn't change. - const suffix = liveRegionToggleRef.current ? '\u200B' : ''; - liveRegionToggleRef.current = !liveRegionToggleRef.current; - - // Clear first so screen readers detect a change, then set the message on the next tick. - const clearTimeoutId = setTimeout(() => setLiveRegionMessage(''), 0); - const timeoutId = setTimeout(() => setLiveRegionMessage(`${message}${suffix}`), DELAY_FOR_ACCESSIBILITY_TREE_SYNC); - - return () => { - clearTimeout(clearTimeoutId); - clearTimeout(timeoutId); - }; - }, [message, shouldAnnounceMessage, shouldUsePersistentLiveRegionOnWeb]); - - return {liveRegionMessage, shouldUsePersistentLiveRegion: shouldUsePersistentLiveRegionOnWeb} as const; -} - -export default useAccessibilityAnnouncement; diff --git a/src/styles/index.ts b/src/styles/index.ts index 8a8e7c59eead..f839bd0fa251 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -953,23 +953,6 @@ const staticStyles = (theme: ThemeColors) => height: 0, }, - /** - * Visually hides live-region content while keeping it in the accessibility tree. - * Needed for platforms (e.g., macOS Safari) that require a pre-mounted live region. - */ - accessibilityLiveRegionSROnly: { - position: 'absolute', - left: -9999, - top: 0, - width: 1, - height: 1, - overflow: 'hidden', - padding: 0, - margin: 0, - borderWidth: 0, - opacity: 0, - }, - visibilityHidden: { ...visibility.hidden, },