From 84927fa30db81adf4c429c273974bc4c441cd250 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 13 Mar 2026 08:16:00 +0530 Subject: [PATCH 01/14] fix: Screen Readers: Many Pages: The status message of no results is not announced. Signed-off-by: krishna2323 --- src/components/AddressSearch/index.tsx | 23 +++++++++++++++---- .../EmojiPickerMenu/BaseEmojiPickerMenu.tsx | 14 ++++++++++- src/components/SearchBar.tsx | 15 ++++++++++-- .../SelectionList/components/TextInput.tsx | 8 ++++++- .../BaseSelectionListWithSections.tsx | 8 ++++++- src/components/Table/TableBody.tsx | 12 +++++++++- 6 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index b5e4b95472128..eca238b7fd153 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -8,6 +8,7 @@ import LocationErrorMessage from '@components/LocationErrorMessage'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -327,10 +328,24 @@ function AddressSearch({ return predefinedPlaces?.filter((predefinedPlace) => isPlaceMatchForSearch(searchValue, predefinedPlace)) ?? []; }, [predefinedPlaces, searchValue, shouldHidePredefinedPlaces]); - const listEmptyComponent = useMemo( - () => (!isTyping ? undefined : {translate('common.noResultsFound')}), - [isTyping, styles, translate], - ); + const noResultsFoundText = translate('common.noResultsFound'); + + useAccessibilityAnnouncement(noResultsFoundText, isTyping, {shouldAnnounceOnNative: true}); + + const listEmptyComponent = useMemo(() => { + if (!isTyping) { + return undefined; + } + return ( + + {noResultsFoundText} + + ); + }, [isTyping, noResultsFoundText, styles]); const listLoader = useMemo(() => { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'AddressSearch.listLoader'}; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index f6161b8f7dc60..72f3c0c88c331 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -8,6 +8,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import CategoryShortcutBar from '@components/EmojiPicker/CategoryShortcutBar'; import EmojiSkinToneList from '@components/EmojiPicker/EmojiSkinToneList'; import Text from '@components/Text'; +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {EmojiPickerList, EmojiPickerListItem, HeaderIndices} from '@libs/EmojiUtils'; @@ -76,8 +77,19 @@ const keyExtractor = (item: EmojiPickerListItem, index: number): string => `emoj function ListEmptyComponent() { const styles = useThemeStyles(); const {translate} = useLocalize(); + const noResultsFoundText = translate('common.noResultsFound'); - return {translate('common.noResultsFound')}; + useAccessibilityAnnouncement(noResultsFoundText, true, {shouldAnnounceOnNative: true}); + + return ( + + {noResultsFoundText} + + ); } function BaseEmojiPickerMenu({ diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 49c49713e7312..681fceb8a3363 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,6 +1,7 @@ import React 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 useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -25,6 +26,10 @@ function SearchBar({label, style, icon, inputValue, onChangeText, onSubmitEditin const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass']); + const noResultsMessage = translate('common.noResultsFoundMatching', inputValue); + const shouldAnnounceNoResults = !!shouldShowEmptyState && inputValue.length !== 0; + + useAccessibilityAnnouncement(noResultsMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); return ( <> @@ -45,9 +50,15 @@ function SearchBar({label, style, icon, inputValue, onChangeText, onSubmitEditin shouldHideClearButton={!inputValue?.length} /> - {!!shouldShowEmptyState && inputValue.length !== 0 && ( + {shouldAnnounceNoResults && ( - {translate('common.noResultsFoundMatching', inputValue)} + + {noResultsMessage} + )} diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 7eacdd4016559..b05260fbd8422 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -145,7 +145,13 @@ function TextInput({ {shouldShowHeaderMessage && ( - {headerMessage} + + {headerMessage} + )} diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 13d9469a1f17d..20f9365e8ac31 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -1018,7 +1018,13 @@ function BaseSelectionListWithSections({ const headerMessageContent = () => shouldShowHeaderMessage && ( - {headerMessage} + + {headerMessage} + ); diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index a9fd8c99943d6..f942e07c628a9 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -3,8 +3,10 @@ import React from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; import Text from '@components/Text'; +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; import {useTableContext} from './TableContext'; /** @@ -62,9 +64,17 @@ function TableBody({contentContainerStyle, ...props}: TableBodyProps) { const message = getEmptyMessage(); + useAccessibilityAnnouncement(message, isEmptyResult, {shouldAnnounceOnNative: true}); + const EmptyResultComponent = ( - {message} + + {message} + ); From 1a417b0e235d6d0028514e705a01549e658bcbfc Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 13 Mar 2026 08:33:23 +0530 Subject: [PATCH 02/14] fix: Re-announce "No results found" after each typing pause. Signed-off-by: krishna2323 --- .../SelectionList/components/TextInput.tsx | 7 +++++- .../BaseSelectionListWithSections.tsx | 7 +++++- src/hooks/useDebouncedValue.ts | 22 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useDebouncedValue.ts diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index b05260fbd8422..1d64ef11eb966 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -7,6 +7,7 @@ import Text from '@components/Text'; import BaseTextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useDebouncedValue from '@hooks/useDebouncedValue'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import mergeRefs from '@libs/mergeRefs'; @@ -71,7 +72,10 @@ function TextInput({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); - const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; + + const debouncedInputValue = useDebouncedValue(value ?? '', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const hasFinishedTyping = (value ?? '') === debouncedInputValue; + const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage && hasFinishedTyping; useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); @@ -146,6 +150,7 @@ function TextInput({ {shouldShowHeaderMessage && ( ({ const noResultsFoundText = translate('common.noResultsFound'); const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !shouldShowLoadingPlaceholder)); - const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; + + const debouncedTextInputValue = useDebouncedValue(textInputValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const hasFinishedTyping = textInputValue === debouncedTextInputValue; + const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage && hasFinishedTyping; useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); @@ -1019,6 +1023,7 @@ function BaseSelectionListWithSections({ shouldShowHeaderMessage && ( (value: T, delay: number = CONST.TIMING.USE_DEBOUNCED_STATE_DELAY): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} + +export default useDebouncedValue; From c2a11d385993d59cb90526c3d24c137c6c800d3b Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 13 Mar 2026 08:54:39 +0530 Subject: [PATCH 03/14] fix: Add accessibility announcements for "No results found" in emoji picker, workflows page, and import contacts Signed-off-by: krishna2323 --- .../EmojiPickerMenu/BaseEmojiPickerMenu.tsx | 19 ++++++++++---- .../EmojiPickerMenu/index.native.tsx | 9 +++++-- .../EmojiPicker/EmojiPickerMenu/index.tsx | 7 +++++- .../ImportContactButton/index.native.tsx | 6 ++++- .../workflows/WorkspaceWorkflowsPage.tsx | 25 +++++++++++++++++-- 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index 72f3c0c88c331..20757ac060633 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -9,6 +9,7 @@ import CategoryShortcutBar from '@components/EmojiPicker/CategoryShortcutBar'; import EmojiSkinToneList from '@components/EmojiPicker/EmojiSkinToneList'; import Text from '@components/Text'; import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useDebouncedValue from '@hooks/useDebouncedValue'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {EmojiPickerList, EmojiPickerListItem, HeaderIndices} from '@libs/EmojiUtils'; @@ -42,6 +43,9 @@ type BaseEmojiPickerMenuProps = { /** Whether the list should always bounce vertically */ alwaysBounceVertical?: boolean; + /** The current search input value, used for accessibility re-announcements */ + searchValue?: string; + /** Reference to the outer element */ ref?: ForwardedRef>; }; @@ -74,18 +78,22 @@ const keyExtractor = (item: EmojiPickerListItem, index: number): string => `emoj /** * Renders the list empty component */ -function ListEmptyComponent() { +function ListEmptyComponent({searchValue}: {searchValue?: string}) { const styles = useThemeStyles(); const {translate} = useLocalize(); const noResultsFoundText = translate('common.noResultsFound'); - useAccessibilityAnnouncement(noResultsFoundText, true, {shouldAnnounceOnNative: true}); + const debouncedSearchValue = useDebouncedValue(searchValue ?? '', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const hasFinishedTyping = (searchValue ?? '') === debouncedSearchValue; + + useAccessibilityAnnouncement(noResultsFoundText, hasFinishedTyping, {shouldAnnounceOnNative: true}); return ( {noResultsFoundText} @@ -102,6 +110,7 @@ function BaseEmojiPickerMenu({ stickyHeaderIndices = [], extraData = [], alwaysBounceVertical = false, + searchValue, ref, }: BaseEmojiPickerMenuProps) { const styles = useThemeStyles(); @@ -123,7 +132,7 @@ function BaseEmojiPickerMenu({ keyExtractor={keyExtractor} numColumns={CONST.EMOJI_NUM_PER_ROW} stickyHeaderIndices={stickyHeaderIndices} - ListEmptyComponent={ListEmptyComponent} + ListEmptyComponent={} alwaysBounceVertical={alwaysBounceVertical} contentContainerStyle={styles.ph4} extraData={extraData} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 718a9ff6b5b56..7565f983ab564 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -1,6 +1,6 @@ import type {ListRenderItem} from '@shopify/flash-list'; import lodashDebounce from 'lodash/debounce'; -import React, {useCallback} from 'react'; +import React, {useCallback, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; @@ -42,6 +42,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro emojiListRef, } = useEmojiPickerMenu(); const StyleUtils = useStyleUtils(); + const [searchText, setSearchText] = useState(''); const updateEmojiList = (emojiData: EmojiPickerList | Emoji[], headerData: number[] = []) => { setFilteredEmojis(emojiData); @@ -124,7 +125,10 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro label={translate('common.search')} accessibilityLabel={translate('common.search')} role={CONST.ROLE.PRESENTATION} - onChangeText={filterEmojis} + onChangeText={(text: string) => { + setSearchText(text); + filterEmojis(text); + }} submitBehavior={filteredEmojis.length > 0 ? 'blurAndSubmit' : 'submit'} sentryLabel={CONST.SENTRY_LABEL.EMOJI_PICKER.SEARCH_INPUT} /> @@ -145,6 +149,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro extraData={[filteredEmojis, preferredSkinTone]} stickyHeaderIndices={headerIndices} alwaysBounceVertical={filteredEmojis.length !== 0} + searchValue={searchText} /> ); diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 7029740133bd7..8e5e14801df70 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -59,6 +59,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro // prevent auto focus when open picker for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); + const [searchText, setSearchText] = useState(''); const [arePointerEventsDisabled, setArePointerEventsDisabled] = useState(false); const [isFocused, setIsFocused] = useState(false); const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false); @@ -330,7 +331,10 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro label={translate('common.search')} accessibilityLabel={translate('common.search')} role={CONST.ROLE.PRESENTATION} - onChangeText={filterEmojis} + onChangeText={(text: string) => { + setSearchText(text); + filterEmojis(text); + }} defaultValue="" ref={searchInputRef} autoFocus={shouldFocusInputOnScreenFocus} @@ -355,6 +359,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro renderItem={renderItem} extraData={[focusedIndex, preferredSkinTone]} stickyHeaderIndices={headerIndices} + searchValue={searchText} /> ); diff --git a/src/pages/iou/request/ImportContactButton/index.native.tsx b/src/pages/iou/request/ImportContactButton/index.native.tsx index d5f1cc47bc4d1..57b455d87d443 100644 --- a/src/pages/iou/request/ImportContactButton/index.native.tsx +++ b/src/pages/iou/request/ImportContactButton/index.native.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {View} from 'react-native'; import Text from '@components/Text'; +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import goToSettings from '@libs/goToSettings'; @@ -14,11 +15,14 @@ type ImportContactButtonProps = { function ImportContactButton({showImportContacts, inputHelperText, isInSearch = false}: ImportContactButtonProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const noResultsFoundText = translate('common.noResultsFound'); + + useAccessibilityAnnouncement(noResultsFoundText, !!isInSearch, {shouldAnnounceOnNative: true}); return showImportContacts && inputHelperText ? ( - {isInSearch ? `${translate('common.noResultsFound')}. ` : null} + {isInSearch ? `${noResultsFoundText}. ` : null} 0; + const debouncedSearchInput = useDebouncedValue(workflowSearchInput, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const hasFinishedTyping = workflowSearchInput === debouncedSearchInput; + const shouldAnnounceNoResults = isNoResults && hasFinishedTyping; + + useAccessibilityAnnouncement(noResultsMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); + useEffect(() => { if (filteredApprovalWorkflows.length > CONST.APPROVAL_WORKFLOW_SEARCH_LIMIT) { return; @@ -321,9 +331,16 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { style={[styles.mt6, {marginHorizontal: 0}]} /> )} - {searchFilteredWorkflows.length === 0 && workflowSearchInput.length > 0 && ( + {isNoResults && ( - {translate('common.noResultsFoundMatching', workflowSearchInput)} + + {noResultsMessage} + )} {searchFilteredWorkflows.map((workflow) => ( @@ -506,6 +523,10 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { searchFilteredWorkflows, workflowSearchInput, setWorkflowSearchInput, + isNoResults, + noResultsMessage, + shouldAnnounceNoResults, + debouncedSearchInput, addApprovalAction, isOffline, isPolicyAdmin, From f54c868fd0e9e8f1079fd2d12a76af0208f875a8 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 13 Mar 2026 09:01:32 +0530 Subject: [PATCH 04/14] address review comment. Signed-off-by: krishna2323 --- src/pages/iou/request/ImportContactButton/index.native.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/iou/request/ImportContactButton/index.native.tsx b/src/pages/iou/request/ImportContactButton/index.native.tsx index 57b455d87d443..c19bdda9a6c32 100644 --- a/src/pages/iou/request/ImportContactButton/index.native.tsx +++ b/src/pages/iou/request/ImportContactButton/index.native.tsx @@ -17,7 +17,8 @@ function ImportContactButton({showImportContacts, inputHelperText, isInSearch = const {translate} = useLocalize(); const noResultsFoundText = translate('common.noResultsFound'); - useAccessibilityAnnouncement(noResultsFoundText, !!isInSearch, {shouldAnnounceOnNative: true}); + const shouldAnnounce = !!isInSearch && !!showImportContacts && !!inputHelperText; + useAccessibilityAnnouncement(noResultsFoundText, shouldAnnounce, {shouldAnnounceOnNative: true}); return showImportContacts && inputHelperText ? ( From 7cb832070f2071ef2997e30ef1cbc335a5ccac4b Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 13 Mar 2026 09:47:04 +0530 Subject: [PATCH 05/14] remove accessibilityLiveRegion. Signed-off-by: krishna2323 --- src/components/AddressSearch/index.tsx | 12 ++++++++---- .../EmojiPickerMenu/BaseEmojiPickerMenu.tsx | 1 - src/components/SearchBar.tsx | 1 - .../SelectionList/components/TextInput.tsx | 1 - .../BaseSelectionListWithSections.tsx | 1 - src/components/Table/TableBody.tsx | 1 - .../workspace/workflows/WorkspaceWorkflowsPage.tsx | 1 - 7 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index eca238b7fd153..72e664f577948 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -9,6 +9,7 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useDebouncedValue from '@hooks/useDebouncedValue'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -329,8 +330,11 @@ function AddressSearch({ }, [predefinedPlaces, searchValue, shouldHidePredefinedPlaces]); const noResultsFoundText = translate('common.noResultsFound'); + const debouncedSearchValue = useDebouncedValue(searchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const hasFinishedTyping = searchValue === debouncedSearchValue; + const shouldAnnounceNoResults = isTyping && hasFinishedTyping; - useAccessibilityAnnouncement(noResultsFoundText, isTyping, {shouldAnnounceOnNative: true}); + useAccessibilityAnnouncement(noResultsFoundText, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); const listEmptyComponent = useMemo(() => { if (!isTyping) { @@ -338,14 +342,14 @@ function AddressSearch({ } return ( {noResultsFoundText} ); - }, [isTyping, noResultsFoundText, styles]); + }, [isTyping, noResultsFoundText, styles, shouldAnnounceNoResults, debouncedSearchValue]); const listLoader = useMemo(() => { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'AddressSearch.listLoader'}; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index 20757ac060633..e5dc87271bdd9 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -93,7 +93,6 @@ function ListEmptyComponent({searchValue}: {searchValue?: string}) { key={hasFinishedTyping ? `no-results-${debouncedSearchValue}` : undefined} style={[styles.textLabel, styles.colorMuted]} role={hasFinishedTyping ? CONST.ROLE.ALERT : undefined} - accessibilityLiveRegion={hasFinishedTyping ? 'polite' : undefined} > {noResultsFoundText} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 681fceb8a3363..fe5d4d2d65c94 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -55,7 +55,6 @@ function SearchBar({label, style, icon, inputValue, onChangeText, onSubmitEditin {noResultsMessage} diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 1d64ef11eb966..a5fc44762d544 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -153,7 +153,6 @@ function TextInput({ key={shouldAnnounceNoResults ? `no-results-${debouncedInputValue}` : undefined} style={[styles.textLabel, styles.colorMuted, styles.minHeight5]} role={shouldAnnounceNoResults ? CONST.ROLE.ALERT : undefined} - accessibilityLiveRegion={shouldAnnounceNoResults ? 'polite' : undefined} > {headerMessage} diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 9ce3865cf86f2..9686f6f5ab5af 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -1026,7 +1026,6 @@ function BaseSelectionListWithSections({ key={shouldAnnounceNoResults ? `no-results-${debouncedTextInputValue}` : undefined} style={[styles.textLabel, styles.colorMuted, styles.minHeight5]} role={shouldAnnounceNoResults ? CONST.ROLE.ALERT : undefined} - accessibilityLiveRegion={shouldAnnounceNoResults ? 'polite' : undefined} > {headerMessage} diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index f942e07c628a9..3246de2647dc6 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -71,7 +71,6 @@ function TableBody({contentContainerStyle, ...props}: TableBodyProps) { {message} diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 41f1a0c13e7e7..76a5bd3f7c2ef 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -337,7 +337,6 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { key={shouldAnnounceNoResults ? `no-results-${debouncedSearchInput}` : undefined} style={[styles.textNormal, styles.colorMuted]} role={shouldAnnounceNoResults ? CONST.ROLE.ALERT : undefined} - accessibilityLiveRegion={shouldAnnounceNoResults ? 'polite' : undefined} > {noResultsMessage} From 6d3ea6e7ab80dc8a579b8df471dd8311d566c7a6 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 13 Mar 2026 09:59:24 +0530 Subject: [PATCH 06/14] fix AddressSearch component, Signed-off-by: krishna2323 --- src/components/AddressSearch/index.tsx | 43 +++++++++++++------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 72e664f577948..8193ce1460209 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -50,6 +50,27 @@ function isPlaceMatchForSearch(search: string, place: PredefinedPlace): boolean // VirtualizedList component with a VirtualizedList-backed instead LogBox.ignoreLogs(['VirtualizedLists should never be nested']); +function AddressSearchListEmptyComponent({searchValue}: {searchValue: string}) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const noResultsFoundText = translate('common.noResultsFound'); + + const debouncedSearchValue = useDebouncedValue(searchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const hasFinishedTyping = searchValue === debouncedSearchValue; + + useAccessibilityAnnouncement(noResultsFoundText, hasFinishedTyping, {shouldAnnounceOnNative: true}); + + return ( + + {noResultsFoundText} + + ); +} + function AddressSearch({ canUseCurrentLocation = false, containerStyles, @@ -329,27 +350,7 @@ function AddressSearch({ return predefinedPlaces?.filter((predefinedPlace) => isPlaceMatchForSearch(searchValue, predefinedPlace)) ?? []; }, [predefinedPlaces, searchValue, shouldHidePredefinedPlaces]); - const noResultsFoundText = translate('common.noResultsFound'); - const debouncedSearchValue = useDebouncedValue(searchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); - const hasFinishedTyping = searchValue === debouncedSearchValue; - const shouldAnnounceNoResults = isTyping && hasFinishedTyping; - - useAccessibilityAnnouncement(noResultsFoundText, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); - - const listEmptyComponent = useMemo(() => { - if (!isTyping) { - return undefined; - } - return ( - - {noResultsFoundText} - - ); - }, [isTyping, noResultsFoundText, styles, shouldAnnounceNoResults, debouncedSearchValue]); + const listEmptyComponent = isTyping ? : undefined; const listLoader = useMemo(() => { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'AddressSearch.listLoader'}; From 297d2cb1a25f550818ef9fbc8d460d7dc62867a7 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 13 Mar 2026 10:51:36 +0530 Subject: [PATCH 07/14] fix: Prevent premature accessibility announcements and double announcements on Android Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 + src/components/AddressSearch/index.tsx | 2 +- .../EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx | 2 +- src/components/SelectionList/components/TextInput.tsx | 2 +- .../BaseSelectionListWithSections.tsx | 2 +- src/hooks/useDebouncedValue.ts | 9 +++++---- src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx | 2 +- 7 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index aa699de640f12..ef5e5e3763ace 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1736,6 +1736,7 @@ const CONST = { TOOLTIP_SENSE: 1000, COMMENT_LENGTH_DEBOUNCE_TIME: 1500, SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, + ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME: 700, SUGGESTION_DEBOUNCE_TIME: 100, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 8193ce1460209..13aeed26ea95a 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -55,7 +55,7 @@ function AddressSearchListEmptyComponent({searchValue}: {searchValue: string}) { const {translate} = useLocalize(); const noResultsFoundText = translate('common.noResultsFound'); - const debouncedSearchValue = useDebouncedValue(searchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const debouncedSearchValue = useDebouncedValue(searchValue, CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); const hasFinishedTyping = searchValue === debouncedSearchValue; useAccessibilityAnnouncement(noResultsFoundText, hasFinishedTyping, {shouldAnnounceOnNative: true}); diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index e5dc87271bdd9..70c1ceac2f24a 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -83,7 +83,7 @@ function ListEmptyComponent({searchValue}: {searchValue?: string}) { const {translate} = useLocalize(); const noResultsFoundText = translate('common.noResultsFound'); - const debouncedSearchValue = useDebouncedValue(searchValue ?? '', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const debouncedSearchValue = useDebouncedValue(searchValue ?? '', CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); const hasFinishedTyping = (searchValue ?? '') === debouncedSearchValue; useAccessibilityAnnouncement(noResultsFoundText, hasFinishedTyping, {shouldAnnounceOnNative: true}); diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index a5fc44762d544..d35fb5c15828a 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -73,7 +73,7 @@ function TextInput({ const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); - const debouncedInputValue = useDebouncedValue(value ?? '', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const debouncedInputValue = useDebouncedValue(value ?? '', CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); const hasFinishedTyping = (value ?? '') === debouncedInputValue; const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage && hasFinishedTyping; diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 9686f6f5ab5af..074365af8c1ee 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -1013,7 +1013,7 @@ function BaseSelectionListWithSections({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !shouldShowLoadingPlaceholder)); - const debouncedTextInputValue = useDebouncedValue(textInputValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const debouncedTextInputValue = useDebouncedValue(textInputValue, CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); const hasFinishedTyping = textInputValue === debouncedTextInputValue; const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage && hasFinishedTyping; diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts index 028e08e473227..e6aa265935f15 100644 --- a/src/hooks/useDebouncedValue.ts +++ b/src/hooks/useDebouncedValue.ts @@ -5,18 +5,19 @@ import CONST from '@src/CONST'; * Returns a debounced version of the given value. The returned value only updates * after the source value has stopped changing for the specified delay. * - * Useful for detecting when a user has paused typing by comparing - * the original value to the debounced value: `value === debouncedValue`. + * Initializes as undefined so the first debounce cycle must complete before + * `value === debouncedValue` can be true. This prevents false positives + * on mount when using the hook to detect typing pauses. */ function useDebouncedValue(value: T, delay: number = CONST.TIMING.USE_DEBOUNCED_STATE_DELAY): T { - const [debouncedValue, setDebouncedValue] = useState(value); + const [debouncedValue, setDebouncedValue] = useState(undefined); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); - return debouncedValue; + return debouncedValue as T; } export default useDebouncedValue; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 76a5bd3f7c2ef..47e65ac158a98 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -213,7 +213,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const noResultsMessage = translate('common.noResultsFoundMatching', workflowSearchInput); const isNoResults = searchFilteredWorkflows.length === 0 && workflowSearchInput.length > 0; - const debouncedSearchInput = useDebouncedValue(workflowSearchInput, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const debouncedSearchInput = useDebouncedValue(workflowSearchInput, CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); const hasFinishedTyping = workflowSearchInput === debouncedSearchInput; const shouldAnnounceNoResults = isNoResults && hasFinishedTyping; From 8c1a333df4f2dc0a384bfee756ba6e7eef54038c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 13 Mar 2026 10:57:44 +0530 Subject: [PATCH 08/14] minor fix. Signed-off-by: krishna2323 --- src/CONST/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ef5e5e3763ace..e77e4bfe8e741 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1736,7 +1736,7 @@ const CONST = { TOOLTIP_SENSE: 1000, COMMENT_LENGTH_DEBOUNCE_TIME: 1500, SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, - ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME: 700, + ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME: 500, SUGGESTION_DEBOUNCE_TIME: 100, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, From f2343cf320adda1f0bedfd4eb73a52eb6ab465c1 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 13 Mar 2026 13:31:21 +0530 Subject: [PATCH 09/14] refactor: Extract duplicated debounced accessibility announcement logic into reusable hook. Signed-off-by: krishna2323 --- src/components/AddressSearch/index.tsx | 12 ++++------ .../EmojiPickerMenu/BaseEmojiPickerMenu.tsx | 12 ++++------ .../SelectionList/components/TextInput.tsx | 13 +++++------ .../BaseSelectionListWithSections.tsx | 13 +++++------ .../useDebouncedAccessibilityAnnouncement.ts | 23 +++++++++++++++++++ .../workflows/WorkspaceWorkflowsPage.tsx | 13 +++++------ 6 files changed, 49 insertions(+), 37 deletions(-) create mode 100644 src/hooks/useDebouncedAccessibilityAnnouncement.ts diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 13aeed26ea95a..68679722b0fae 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -8,8 +8,7 @@ import LocationErrorMessage from '@components/LocationErrorMessage'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; -import useDebouncedValue from '@hooks/useDebouncedValue'; +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -55,16 +54,13 @@ function AddressSearchListEmptyComponent({searchValue}: {searchValue: string}) { const {translate} = useLocalize(); const noResultsFoundText = translate('common.noResultsFound'); - const debouncedSearchValue = useDebouncedValue(searchValue, CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); - const hasFinishedTyping = searchValue === debouncedSearchValue; - - useAccessibilityAnnouncement(noResultsFoundText, hasFinishedTyping, {shouldAnnounceOnNative: true}); + const {shouldAnnounceNow, debouncedSearchValue} = useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue); return ( {noResultsFoundText} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index 70c1ceac2f24a..5d361ae2cc7a4 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -8,8 +8,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import CategoryShortcutBar from '@components/EmojiPicker/CategoryShortcutBar'; import EmojiSkinToneList from '@components/EmojiPicker/EmojiSkinToneList'; import Text from '@components/Text'; -import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; -import useDebouncedValue from '@hooks/useDebouncedValue'; +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {EmojiPickerList, EmojiPickerListItem, HeaderIndices} from '@libs/EmojiUtils'; @@ -83,16 +82,13 @@ function ListEmptyComponent({searchValue}: {searchValue?: string}) { const {translate} = useLocalize(); const noResultsFoundText = translate('common.noResultsFound'); - const debouncedSearchValue = useDebouncedValue(searchValue ?? '', CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); - const hasFinishedTyping = (searchValue ?? '') === debouncedSearchValue; - - useAccessibilityAnnouncement(noResultsFoundText, hasFinishedTyping, {shouldAnnounceOnNative: true}); + const {shouldAnnounceNow, debouncedSearchValue} = useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue ?? ''); return ( {noResultsFoundText} diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index d35fb5c15828a..fa3e8a6b0cf52 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -6,8 +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 useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; -import useDebouncedValue from '@hooks/useDebouncedValue'; +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import mergeRefs from '@libs/mergeRefs'; @@ -73,11 +72,11 @@ function TextInput({ const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); - const debouncedInputValue = useDebouncedValue(value ?? '', CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); - const hasFinishedTyping = (value ?? '') === debouncedInputValue; - const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage && hasFinishedTyping; - - useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); + const {shouldAnnounceNow: shouldAnnounceNoResults, debouncedSearchValue: debouncedInputValue} = useDebouncedAccessibilityAnnouncement( + headerMessage ?? '', + shouldShowHeaderMessage && isNoResultsFoundMessage, + value ?? '', + ); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 074365af8c1ee..b48cfbc8f1747 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -13,10 +13,9 @@ import SectionList from '@components/SectionList'; import {getListboxRole} from '@components/SelectionList/utils/getListboxRole'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useActiveElementRole from '@hooks/useActiveElementRole'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; -import useDebouncedValue from '@hooks/useDebouncedValue'; +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; @@ -1013,11 +1012,11 @@ function BaseSelectionListWithSections({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !shouldShowLoadingPlaceholder)); - const debouncedTextInputValue = useDebouncedValue(textInputValue, CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); - const hasFinishedTyping = textInputValue === debouncedTextInputValue; - const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage && hasFinishedTyping; - - useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); + const {shouldAnnounceNow: shouldAnnounceNoResults, debouncedSearchValue: debouncedTextInputValue} = useDebouncedAccessibilityAnnouncement( + headerMessage, + shouldShowHeaderMessage && isNoResultsFoundMessage, + textInputValue, + ); const headerMessageContent = () => shouldShowHeaderMessage && ( diff --git a/src/hooks/useDebouncedAccessibilityAnnouncement.ts b/src/hooks/useDebouncedAccessibilityAnnouncement.ts new file mode 100644 index 0000000000000..2671712a8deb9 --- /dev/null +++ b/src/hooks/useDebouncedAccessibilityAnnouncement.ts @@ -0,0 +1,23 @@ +import CONST from '@src/CONST'; +import useAccessibilityAnnouncement from './useAccessibilityAnnouncement'; +import useDebouncedValue from './useDebouncedValue'; + +/** + * Encapsulates the debounced accessibility announcement pattern: + * waits for a typing pause before announcing a message via screen reader. + * + * On web, the returned `key` and `role` should be spread onto the Text element + * to force a remount that re-triggers `role="alert"`. + * On native, announcements are made programmatically via AccessibilityInfo. + */ +function useDebouncedAccessibilityAnnouncement(message: string, shouldAnnounce: boolean, searchValue: string) { + const debouncedSearchValue = useDebouncedValue(searchValue, CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); + const hasFinishedTyping = searchValue === debouncedSearchValue; + const shouldAnnounceNow = shouldAnnounce && hasFinishedTyping; + + useAccessibilityAnnouncement(message, shouldAnnounceNow, {shouldAnnounceOnNative: true}); + + return {hasFinishedTyping, debouncedSearchValue, shouldAnnounceNow}; +} + +export default useDebouncedAccessibilityAnnouncement; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 47e65ac158a98..c68e85f518e4d 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -15,10 +15,9 @@ import RenderHTML from '@components/RenderHTML'; import SearchBar from '@components/SearchBar'; import Section from '@components/Section'; import Text from '@components/Text'; -import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useCardFeeds from '@hooks/useCardFeeds'; import useConfirmModal from '@hooks/useConfirmModal'; -import useDebouncedValue from '@hooks/useDebouncedValue'; +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -213,11 +212,11 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const noResultsMessage = translate('common.noResultsFoundMatching', workflowSearchInput); const isNoResults = searchFilteredWorkflows.length === 0 && workflowSearchInput.length > 0; - const debouncedSearchInput = useDebouncedValue(workflowSearchInput, CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME); - const hasFinishedTyping = workflowSearchInput === debouncedSearchInput; - const shouldAnnounceNoResults = isNoResults && hasFinishedTyping; - - useAccessibilityAnnouncement(noResultsMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); + const {shouldAnnounceNow: shouldAnnounceNoResults, debouncedSearchValue: debouncedSearchInput} = useDebouncedAccessibilityAnnouncement( + noResultsMessage, + isNoResults, + workflowSearchInput, + ); useEffect(() => { if (filteredApprovalWorkflows.length > CONST.APPROVAL_WORKFLOW_SEARCH_LIMIT) { From 1f05dcb8a134a19e2e0dbc831820151667e10a0d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sat, 14 Mar 2026 12:07:44 +0530 Subject: [PATCH 10/14] fix: implement web accessibility announcements via hidden role="alert" elements. Signed-off-by: krishna2323 --- src/CONST/index.ts | 2 +- src/components/AddressSearch/index.tsx | 12 +--- .../EmojiPickerMenu/BaseEmojiPickerMenu.tsx | 12 +--- .../SelectionList/components/TextInput.tsx | 14 +--- .../BaseSelectionListWithSections.tsx | 14 +--- .../useAccessibilityAnnouncement/index.ios.ts | 1 + .../index.native.ts | 1 + .../useAccessibilityAnnouncement/index.ts | 72 ++++++++++++++++++- .../useDebouncedAccessibilityAnnouncement.ts | 7 +- .../workflows/WorkspaceWorkflowsPage.tsx | 16 +---- 10 files changed, 85 insertions(+), 66 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 443b0fcd7d6a0..6e603bd49600a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1736,7 +1736,7 @@ const CONST = { TOOLTIP_SENSE: 1000, COMMENT_LENGTH_DEBOUNCE_TIME: 1500, SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, - ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME: 500, + ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME: 1000, SUGGESTION_DEBOUNCE_TIME: 100, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 68679722b0fae..db9d1da92e56d 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -54,17 +54,9 @@ function AddressSearchListEmptyComponent({searchValue}: {searchValue: string}) { const {translate} = useLocalize(); const noResultsFoundText = translate('common.noResultsFound'); - const {shouldAnnounceNow, debouncedSearchValue} = useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue); + useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue); - return ( - - {noResultsFoundText} - - ); + return {noResultsFoundText}; } function AddressSearch({ diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index 5d361ae2cc7a4..bb642cae68741 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -82,17 +82,9 @@ function ListEmptyComponent({searchValue}: {searchValue?: string}) { const {translate} = useLocalize(); const noResultsFoundText = translate('common.noResultsFound'); - const {shouldAnnounceNow, debouncedSearchValue} = useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue ?? ''); + useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue ?? ''); - return ( - - {noResultsFoundText} - - ); + return {noResultsFoundText}; } function BaseEmojiPickerMenu({ diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index fa3e8a6b0cf52..7659d58e7442c 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -72,11 +72,7 @@ function TextInput({ const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); - const {shouldAnnounceNow: shouldAnnounceNoResults, debouncedSearchValue: debouncedInputValue} = useDebouncedAccessibilityAnnouncement( - headerMessage ?? '', - shouldShowHeaderMessage && isNoResultsFoundMessage, - value ?? '', - ); + useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage && isNoResultsFoundMessage && !isLoading, value ?? ''); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); @@ -148,13 +144,7 @@ function TextInput({ {shouldShowHeaderMessage && ( - - {headerMessage} - + {headerMessage} )} diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index b48cfbc8f1747..1c20da9ceb565 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -1012,22 +1012,12 @@ function BaseSelectionListWithSections({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !shouldShowLoadingPlaceholder)); - const {shouldAnnounceNow: shouldAnnounceNoResults, debouncedSearchValue: debouncedTextInputValue} = useDebouncedAccessibilityAnnouncement( - headerMessage, - shouldShowHeaderMessage && isNoResultsFoundMessage, - textInputValue, - ); + useDebouncedAccessibilityAnnouncement(headerMessage, shouldShowHeaderMessage && isNoResultsFoundMessage, textInputValue); const headerMessageContent = () => shouldShowHeaderMessage && ( - - {headerMessage} - + {headerMessage} ); diff --git a/src/hooks/useAccessibilityAnnouncement/index.ios.ts b/src/hooks/useAccessibilityAnnouncement/index.ios.ts index 3d21af2b7b7b1..6f1655700c4a3 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ios.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ios.ts @@ -4,6 +4,7 @@ import {AccessibilityInfo} from 'react-native'; type UseAccessibilityAnnouncementOptions = { shouldAnnounceOnNative?: boolean; + shouldAnnounceOnWeb?: boolean; }; const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; diff --git a/src/hooks/useAccessibilityAnnouncement/index.native.ts b/src/hooks/useAccessibilityAnnouncement/index.native.ts index b66f9540f3abe..6a0ecf1380c01 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.native.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.native.ts @@ -4,6 +4,7 @@ import {AccessibilityInfo} from 'react-native'; type UseAccessibilityAnnouncementOptions = { shouldAnnounceOnNative?: boolean; + shouldAnnounceOnWeb?: boolean; }; function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, options?: UseAccessibilityAnnouncementOptions) { diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts index c853cec379be8..33ec071280601 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ts @@ -1,10 +1,78 @@ import type {ReactNode} from 'react'; +import {useEffect, useRef} from 'react'; type UseAccessibilityAnnouncementOptions = { shouldAnnounceOnNative?: boolean; + shouldAnnounceOnWeb?: boolean; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function useAccessibilityAnnouncement(_message: string | ReactNode, _shouldAnnounceMessage: boolean, _options?: UseAccessibilityAnnouncementOptions) {} +const VISUALLY_HIDDEN_STYLE: Partial = { + position: 'absolute', + width: '1px', + height: '1px', + margin: '-1px', + padding: '0', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: '0', +}; + +/** + * VoiceOver on Mac echoes the completed word ~500-750ms after the last keystroke + * and takes ~300-500ms to speak it. This web-only delay ensures our role="alert" + * announcement fires after VoiceOver finishes speaking the word echo. + */ +const ANNOUNCEMENT_DELAY_MS = 1000; + +let wrapper: HTMLDivElement | null = null; + +function getWrapper(): HTMLDivElement { + if (wrapper && document.body.contains(wrapper)) { + return wrapper; + } + + wrapper = document.createElement('div'); + Object.assign(wrapper.style, VISUALLY_HIDDEN_STYLE); + document.body.appendChild(wrapper); + + return wrapper; +} + +function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, options?: UseAccessibilityAnnouncementOptions) { + const shouldAnnounceOnWeb = options?.shouldAnnounceOnWeb ?? false; + const prevShouldAnnounceRef = useRef(false); + + useEffect(() => { + if (!shouldAnnounceMessage) { + prevShouldAnnounceRef.current = false; + return; + } + + if (prevShouldAnnounceRef.current || !shouldAnnounceOnWeb || typeof message !== 'string' || !message.trim()) { + return; + } + + prevShouldAnnounceRef.current = true; + + const container = getWrapper(); + + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + const timer = setTimeout(() => { + const node = document.createElement('div'); + node.setAttribute('role', 'alert'); + node.textContent = message; + container.appendChild(node); + }, ANNOUNCEMENT_DELAY_MS); + + return () => { + clearTimeout(timer); + prevShouldAnnounceRef.current = false; + }; + }, [message, shouldAnnounceMessage, shouldAnnounceOnWeb]); +} export default useAccessibilityAnnouncement; diff --git a/src/hooks/useDebouncedAccessibilityAnnouncement.ts b/src/hooks/useDebouncedAccessibilityAnnouncement.ts index 2671712a8deb9..72cf6e39f34ad 100644 --- a/src/hooks/useDebouncedAccessibilityAnnouncement.ts +++ b/src/hooks/useDebouncedAccessibilityAnnouncement.ts @@ -6,8 +6,7 @@ import useDebouncedValue from './useDebouncedValue'; * Encapsulates the debounced accessibility announcement pattern: * waits for a typing pause before announcing a message via screen reader. * - * On web, the returned `key` and `role` should be spread onto the Text element - * to force a remount that re-triggers `role="alert"`. + * On web, announcements are made via a hidden role="alert" element. * On native, announcements are made programmatically via AccessibilityInfo. */ function useDebouncedAccessibilityAnnouncement(message: string, shouldAnnounce: boolean, searchValue: string) { @@ -15,9 +14,7 @@ function useDebouncedAccessibilityAnnouncement(message: string, shouldAnnounce: const hasFinishedTyping = searchValue === debouncedSearchValue; const shouldAnnounceNow = shouldAnnounce && hasFinishedTyping; - useAccessibilityAnnouncement(message, shouldAnnounceNow, {shouldAnnounceOnNative: true}); - - return {hasFinishedTyping, debouncedSearchValue, shouldAnnounceNow}; + useAccessibilityAnnouncement(message, shouldAnnounceNow, {shouldAnnounceOnNative: true, shouldAnnounceOnWeb: true}); } export default useDebouncedAccessibilityAnnouncement; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index d5ae9ab6fa97b..4e32a9f1d07f9 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -214,11 +214,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const noResultsMessage = translate('common.noResultsFoundMatching', workflowSearchInput); const isNoResults = searchFilteredWorkflows.length === 0 && workflowSearchInput.length > 0; - const {shouldAnnounceNow: shouldAnnounceNoResults, debouncedSearchValue: debouncedSearchInput} = useDebouncedAccessibilityAnnouncement( - noResultsMessage, - isNoResults, - workflowSearchInput, - ); + useDebouncedAccessibilityAnnouncement(noResultsMessage, isNoResults, workflowSearchInput); useEffect(() => { if (filteredApprovalWorkflows.length > CONST.APPROVAL_WORKFLOW_SEARCH_LIMIT) { @@ -334,13 +330,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { )} {isNoResults && ( - - {noResultsMessage} - + {noResultsMessage} )} {searchFilteredWorkflows.map((workflow) => ( @@ -525,8 +515,6 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { setWorkflowSearchInput, isNoResults, noResultsMessage, - shouldAnnounceNoResults, - debouncedSearchInput, addApprovalAction, isOffline, isPolicyAdmin, From 5866cac92f7680761bdecee7b144a5164c481cd6 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sat, 14 Mar 2026 12:55:54 +0530 Subject: [PATCH 11/14] fix jaws+chrome. Signed-off-by: krishna2323 --- src/hooks/useAccessibilityAnnouncement/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts index 33ec071280601..d564ae839b9cf 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ts @@ -20,10 +20,11 @@ const VISUALLY_HIDDEN_STYLE: Partial = { /** * VoiceOver on Mac echoes the completed word ~500-750ms after the last keystroke - * and takes ~300-500ms to speak it. This web-only delay ensures our role="alert" - * announcement fires after VoiceOver finishes speaking the word echo. + * and takes ~300-500ms to speak it. Combined with the 1000ms debounce in + * useDebouncedAccessibilityAnnouncement, this delay ensures the announcement + * fires after VoiceOver finishes the word echo (~1300ms total from last keystroke). */ -const ANNOUNCEMENT_DELAY_MS = 1000; +const ANNOUNCEMENT_DELAY_MS = 300; let wrapper: HTMLDivElement | null = null; @@ -33,6 +34,8 @@ function getWrapper(): HTMLDivElement { } wrapper = document.createElement('div'); + wrapper.setAttribute('aria-live', 'assertive'); + wrapper.setAttribute('aria-atomic', 'true'); Object.assign(wrapper.style, VISUALLY_HIDDEN_STYLE); document.body.appendChild(wrapper); From 5409b5af15ca242590ae6a39feac1f80bb243570 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sat, 14 Mar 2026 13:14:14 +0530 Subject: [PATCH 12/14] fix: debounce and deduplicate no-results accessibility announcements Signed-off-by: krishna2323 --- src/components/SearchBar.tsx | 6 +++--- src/components/SelectionList/components/TextInput.tsx | 9 +++++++-- .../BaseSelectionListWithSections.tsx | 9 +++++++-- src/components/Table/TableBody.tsx | 7 +++---- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index fe5d4d2d65c94..bb4b49599c32e 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -29,7 +29,7 @@ function SearchBar({label, style, icon, inputValue, onChangeText, onSubmitEditin const noResultsMessage = translate('common.noResultsFoundMatching', inputValue); const shouldAnnounceNoResults = !!shouldShowEmptyState && inputValue.length !== 0; - useAccessibilityAnnouncement(noResultsMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); + useDebouncedAccessibilityAnnouncement(noResultsMessage, shouldAnnounceNoResults, inputValue); return ( <> @@ -54,7 +54,7 @@ function SearchBar({label, style, icon, inputValue, onChangeText, onSubmitEditin {noResultsMessage} diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 7659d58e7442c..c771f93235b14 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -72,7 +72,7 @@ function TextInput({ const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); - useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage && isNoResultsFoundMessage && !isLoading, value ?? ''); + useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage && !isLoading, value ?? ''); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); @@ -144,7 +144,12 @@ function TextInput({ {shouldShowHeaderMessage && ( - {headerMessage} + + {headerMessage} + )} diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 1c20da9ceb565..a5a472dff9111 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -1012,12 +1012,17 @@ function BaseSelectionListWithSections({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !shouldShowLoadingPlaceholder)); - useDebouncedAccessibilityAnnouncement(headerMessage, shouldShowHeaderMessage && isNoResultsFoundMessage, textInputValue); + useDebouncedAccessibilityAnnouncement(headerMessage, shouldShowHeaderMessage, textInputValue); const headerMessageContent = () => shouldShowHeaderMessage && ( - {headerMessage} + + {headerMessage} + ); diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 3246de2647dc6..76587f776fc27 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -3,10 +3,9 @@ import React from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; import Text from '@components/Text'; -import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; import {useTableContext} from './TableContext'; /** @@ -64,13 +63,13 @@ function TableBody({contentContainerStyle, ...props}: TableBodyProps) { const message = getEmptyMessage(); - useAccessibilityAnnouncement(message, isEmptyResult, {shouldAnnounceOnNative: true}); + useDebouncedAccessibilityAnnouncement(message, isEmptyResult, activeSearchString); const EmptyResultComponent = ( {message} From 8ff74003051cf25fa23835c84d0bcf59634233a2 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 16 Mar 2026 00:07:51 +0530 Subject: [PATCH 13/14] fix: address PR review feedback for no-results accessibility announcements - Add aria-hidden to AddressSearch and BaseEmojiPickerMenu visible text to prevent double screen reader announcements (matching other components) - Extract UseAccessibilityAnnouncementOptions type to shared types file - Extract WorkflowNoResultsView component in WorkspaceWorkflowsPage and remove redundant isNoResults/noResultsMessage from useMemo deps - Stabilize emoji picker filter debounce/throttle using ref-based pattern to prevent re-renders from recreating timers - Fix DebugReportActionsTest to handle aria-hidden text with includeHiddenElements Made-with: Cursor --- src/components/AddressSearch/index.tsx | 9 ++++- .../EmojiPickerMenu/BaseEmojiPickerMenu.tsx | 9 ++++- .../EmojiPickerMenu/index.native.tsx | 15 +++++--- .../EmojiPicker/EmojiPickerMenu/index.tsx | 10 ++++- .../useAccessibilityAnnouncement/index.ios.ts | 6 +-- .../index.native.ts | 6 +-- .../useAccessibilityAnnouncement/index.ts | 6 +-- .../useAccessibilityAnnouncement/types.ts | 6 +++ .../workflows/WorkspaceWorkflowsPage.tsx | 37 +++++++++++++------ tests/ui/DebugReportActionsTest.tsx | 2 +- 10 files changed, 69 insertions(+), 37 deletions(-) create mode 100644 src/hooks/useAccessibilityAnnouncement/types.ts diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index db9d1da92e56d..1b6ca1d1af9d7 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -56,7 +56,14 @@ function AddressSearchListEmptyComponent({searchValue}: {searchValue: string}) { useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue); - return {noResultsFoundText}; + return ( + + {noResultsFoundText} + + ); } function AddressSearch({ diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx index bb642cae68741..fcad7f0c132a0 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx @@ -84,7 +84,14 @@ function ListEmptyComponent({searchValue}: {searchValue?: string}) { useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue ?? ''); - return {noResultsFoundText}; + return ( + + {noResultsFoundText} + + ); } function BaseEmojiPickerMenu({ diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 7565f983ab564..c520b7d3d216d 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -1,6 +1,6 @@ import type {ListRenderItem} from '@shopify/flash-list'; import lodashDebounce from 'lodash/debounce'; -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; @@ -56,10 +56,8 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro }); }; - /** - * Filter the entire list of emojis to only emojis that have the search term in their keywords - */ - const filterEmojis = lodashDebounce((searchTerm: string) => { + const filterCallbackRef = useRef<(searchTerm: string) => void>(undefined); + filterCallbackRef.current = (searchTerm: string) => { const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm); if (normalizedSearchTerm === '') { @@ -67,7 +65,12 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro } else { updateEmojiList(newFilteredEmojiList ?? [], []); } - }, 300); + }; + + // Stable debounced function that delegates to the latest callback via ref, + // preventing re-renders from recreating the debounce timer. + // eslint-disable-next-line react-hooks/exhaustive-deps + const filterEmojis = useMemo(() => lodashDebounce((text: string) => filterCallbackRef.current?.(text), 300), []); const scrollToHeader = useCallback( (headerIndex: number) => { diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 8e5e14801df70..bf314e91a6f00 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -111,7 +111,8 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro allowNegativeIndexes: true, }); - const filterEmojis = throttle((searchTerm: string) => { + const filterCallbackRef = useRef<(searchTerm: string) => void>(undefined); + filterCallbackRef.current = (searchTerm: string) => { const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm); emojiListRef.current?.scrollToOffset({offset: 0, animated: false}); @@ -129,7 +130,12 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro setHeaderIndices([]); setHighlightFirstEmoji(true); setIsUsingKeyboardMovement(false); - }, throttleTime); + }; + + // Stable throttled function that delegates to the latest callback via ref, + // preventing re-renders from recreating the throttle timer. + // eslint-disable-next-line react-hooks/exhaustive-deps + const filterEmojis = useMemo(() => throttle((text: string) => filterCallbackRef.current?.(text), throttleTime), []); const keyDownHandler = useCallback( (keyBoardEvent: KeyboardEvent) => { diff --git a/src/hooks/useAccessibilityAnnouncement/index.ios.ts b/src/hooks/useAccessibilityAnnouncement/index.ios.ts index 6f1655700c4a3..ee479d2d37ca5 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ios.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ios.ts @@ -1,11 +1,7 @@ import type {ReactNode} from 'react'; import {useEffect, useRef} from 'react'; import {AccessibilityInfo} from 'react-native'; - -type UseAccessibilityAnnouncementOptions = { - shouldAnnounceOnNative?: boolean; - shouldAnnounceOnWeb?: boolean; -}; +import type UseAccessibilityAnnouncementOptions from './types'; const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100; diff --git a/src/hooks/useAccessibilityAnnouncement/index.native.ts b/src/hooks/useAccessibilityAnnouncement/index.native.ts index 6a0ecf1380c01..80f307820bb9a 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.native.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.native.ts @@ -1,11 +1,7 @@ import type {ReactNode} from 'react'; import {useEffect, useRef} from 'react'; import {AccessibilityInfo} from 'react-native'; - -type UseAccessibilityAnnouncementOptions = { - shouldAnnounceOnNative?: boolean; - shouldAnnounceOnWeb?: boolean; -}; +import type UseAccessibilityAnnouncementOptions from './types'; function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, options?: UseAccessibilityAnnouncementOptions) { const previousAnnouncedMessageRef = useRef(''); diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts index d564ae839b9cf..bc104b6f9d467 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ts @@ -1,10 +1,6 @@ import type {ReactNode} from 'react'; import {useEffect, useRef} from 'react'; - -type UseAccessibilityAnnouncementOptions = { - shouldAnnounceOnNative?: boolean; - shouldAnnounceOnWeb?: boolean; -}; +import type UseAccessibilityAnnouncementOptions from './types'; const VISUALLY_HIDDEN_STYLE: Partial = { position: 'absolute', diff --git a/src/hooks/useAccessibilityAnnouncement/types.ts b/src/hooks/useAccessibilityAnnouncement/types.ts new file mode 100644 index 0000000000000..fa7a8e4c1c537 --- /dev/null +++ b/src/hooks/useAccessibilityAnnouncement/types.ts @@ -0,0 +1,6 @@ +type UseAccessibilityAnnouncementOptions = { + shouldAnnounceOnNative?: boolean; + shouldAnnounceOnWeb?: boolean; +}; + +export default UseAccessibilityAnnouncementOptions; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 4e32a9f1d07f9..8cf24fc735359 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -74,6 +74,27 @@ import {getAutoReportingFrequencyDisplayNames} from './WorkspaceAutoReportingFre type WorkspaceWorkflowsPageProps = WithPolicyProps & PlatformStackScreenProps; type CurrencyType = TupleToUnion; +function WorkflowNoResultsView({message, shouldShow, searchValue}: {message: string; shouldShow: boolean; searchValue: string}) { + const styles = useThemeStyles(); + + useDebouncedAccessibilityAnnouncement(message, shouldShow, searchValue); + + if (!shouldShow) { + return null; + } + + return ( + + + {message} + + + ); +} + function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.workflows'); const {translate, localeCompare} = useLocalize(); @@ -212,10 +233,6 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const [workflowSearchInput, setWorkflowSearchInput, searchFilteredWorkflows] = useSearchResults(filteredApprovalWorkflows, filterWorkflow); - const noResultsMessage = translate('common.noResultsFoundMatching', workflowSearchInput); - const isNoResults = searchFilteredWorkflows.length === 0 && workflowSearchInput.length > 0; - useDebouncedAccessibilityAnnouncement(noResultsMessage, isNoResults, workflowSearchInput); - useEffect(() => { if (filteredApprovalWorkflows.length > CONST.APPROVAL_WORKFLOW_SEARCH_LIMIT) { return; @@ -328,11 +345,11 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { style={[styles.mt6, {marginHorizontal: 0}]} /> )} - {isNoResults && ( - - {noResultsMessage} - - )} + 0} + searchValue={workflowSearchInput} + /> {searchFilteredWorkflows.map((workflow) => ( { const input = screen.getByTestId('selection-list-text-input'); fireEvent.changeText(input, 'Should show no results found'); - expect(await screen.findByText('No results found')).toBeOnTheScreen(); + expect(await screen.findByText('No results found', {includeHiddenElements: true})).toBeOnTheScreen(); }); }); From 460abcd314f83165410e58719d61f5ed50e189b0 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 16 Mar 2026 15:41:40 +0530 Subject: [PATCH 14/14] minor fix. Signed-off-by: krishna2323 --- src/components/SelectionList/components/TextInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index c771f93235b14..9ad040dc3f1be 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -72,7 +72,7 @@ function TextInput({ const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); - useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage && !isLoading, value ?? ''); + useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage, value ?? ''); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef);