From 468e57cbfc8d58242a3d52819953b6f188b3ec1a Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:41:11 +0530 Subject: [PATCH 01/11] Implement accessibility changes for suggestions --- .../Search/SearchAutocompleteList.tsx | 187 ++++++++++-------- .../BaseSelectionListWithSections.tsx | 2 +- .../SuggestionsAvailabilityAnnouncement.tsx | 83 ++++++++ ...sAvailabilityAnnouncementHelper.android.ts | 15 ++ ...tionsAvailabilityAnnouncementHelper.ios.ts | 12 ++ ...ggestionsAvailabilityAnnouncementHelper.ts | 10 + .../SelectionList/components/TextInput.tsx | 8 + .../BaseSelectionListWithSections.tsx | 10 + src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 18 files changed, 255 insertions(+), 82 deletions(-) create mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx create mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.android.ts create mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ios.ts create mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ts diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index aa1aa4fa66b62..2a5f72eff45c6 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -1,9 +1,10 @@ import type {ForwardedRef, RefObject} from 'react'; -import React, {useContext, useEffect, useRef, useState} from 'react'; +import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {OptionsListStateContext, useOptionsList} from '@components/OptionListContextProvider'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import SuggestionsAvailabilityAnnouncement from '@components/SelectionList/components/SuggestionsAvailabilityAnnouncement'; import type {ListItem as NewListItem, UserListItemProps} from '@components/SelectionList/ListItem/types'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; @@ -344,73 +345,78 @@ function SearchAutocompleteList({ }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); /* Sections generation */ - const sections: Array> = []; - let sectionIndex = 0; + const {sections, styledRecentReports} = useMemo(() => { + const nextSections: Array> = []; + let sectionIndex = 0; - if (searchQueryItem) { - sections.push({data: [searchQueryItem as AutocompleteListItem], sectionIndex: sectionIndex++}); - } + if (searchQueryItem) { + nextSections.push({data: [searchQueryItem as AutocompleteListItem], sectionIndex: sectionIndex++}); + } - const additionalSections = getAdditionalSections?.(searchOptions, sectionIndex); + const additionalSections = getAdditionalSections?.(searchOptions, sectionIndex); - if (additionalSections) { - for (const section of additionalSections) { - sections.push(section); - sectionIndex++; + if (additionalSections) { + for (const section of additionalSections) { + nextSections.push(section); + sectionIndex++; + } } - } - if (!autocompleteQueryValue && recentSearchesData && recentSearchesData.length > 0) { - sections.push({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); - } - const styledRecentReports = recentReportsOptions.map((option) => { - const report = getReportOrDraftReport(option.reportID); - const reportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); - const shouldParserToHTML = reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; - const keyForList = option.keyForList ?? option.reportID ?? (option.accountID ? String(option.accountID) : undefined); - return { - ...option, - keyForList, - pressableStyle: styles.br2, - text: StringUtils.lineBreaksToSpaces(shouldParserToHTML ? Parser.htmlToText(option.text ?? '') : (option.text ?? '')), - wrapperStyle: [styles.pr3, styles.pl3], - } as AutocompleteListItem; - }); - - sections.push({title: autocompleteQueryValue.trim() === '' ? translate('search.recentChats') : undefined, data: styledRecentReports, sectionIndex: sectionIndex++}); + if (!autocompleteQueryValue && recentSearchesData && recentSearchesData.length > 0) { + nextSections.push({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); + } - if (autocompleteSuggestions.length > 0) { - const autocompleteData: AutocompleteListItem[] = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey}) => { + const nextStyledRecentReports = recentReportsOptions.map((option) => { + const report = getReportOrDraftReport(option.reportID); + const reportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); + const shouldParserToHTML = reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; + const keyForList = option.keyForList ?? option.reportID ?? (option.accountID ? String(option.accountID) : undefined); return { - text: getAutocompleteDisplayText(filterKey, text), - mapKey: mapKey ? getSubstitutionMapKey(mapKey, text) : undefined, - singleIcon: expensifyIcons.MagnifyingGlass, - searchQuery: text, - autocompleteID, - keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, - }; + ...option, + keyForList, + pressableStyle: styles.br2, + text: StringUtils.lineBreaksToSpaces(shouldParserToHTML ? Parser.htmlToText(option.text ?? '') : (option.text ?? '')), + wrapperStyle: [styles.pr3, styles.pl3], + } as AutocompleteListItem; }); - sections.push({title: translate('search.suggestions'), data: autocompleteData, sectionIndex: sectionIndex++}); - } + nextSections.push({ + title: autocompleteQueryValue.trim() === '' ? translate('search.recentChats') : undefined, + data: nextStyledRecentReports, + sectionIndex: sectionIndex++, + }); + + if (autocompleteSuggestions.length > 0) { + const autocompleteData: AutocompleteListItem[] = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey}) => { + return { + text: getAutocompleteDisplayText(filterKey, text), + mapKey: mapKey ? getSubstitutionMapKey(mapKey, text) : undefined, + singleIcon: expensifyIcons.MagnifyingGlass, + searchQuery: text, + autocompleteID, + keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, + }; + }); + + nextSections.push({title: translate('search.suggestions'), data: autocompleteData, sectionIndex: sectionIndex++}); + } + + return {sections: nextSections, styledRecentReports: nextStyledRecentReports}; + }, [autocompleteQueryValue, autocompleteSuggestions, expensifyIcons, getAdditionalSections, recentReportsOptions, recentSearchesData, searchOptions, searchQueryItem, styles, translate]); const sectionItemText = sections?.at(1)?.data?.[0]?.text ?? ''; const normalizedReferenceText = sectionItemText.toLowerCase(); + const suggestionsCount = sections.reduce((total, section) => total + section.data.filter((item) => item.keyForList !== 'findItem').length, 0); + const trimmedAutocompleteQueryValue = autocompleteQueryValue.trim(); + const suggestionsAnnouncement = suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedAutocompleteQueryValue || undefined) : ''; const firstRecentReportKey = styledRecentReports.at(0)?.keyForList; - - // When options initialize after the list is already mounted, initiallyFocusedItemKey has no effect - // because useState(initialFocusedIndex) in useArrowKeyFocusManager only reads the initial value. - // Imperatively focus the first recent report once options become available (desktop only). - useEffect(() => { - if (shouldUseNarrowLayout || !areOptionsInitialized || hasSetInitialFocusRef.current || !firstRecentReportKey) { - return; + const firstRecentReportFlatIndex = useMemo(() => { + if (!firstRecentReportKey) { + return -1; } - hasSetInitialFocusRef.current = true; - // Compute the flat index of firstRecentReportKey by replicating the flattening logic - // from useFlattenedSections: each section may prepend a header row when it has a title/customHeader. let flatIndex = 0; for (const section of sections) { const hasData = (section.data?.length ?? 0) > 0; @@ -420,13 +426,26 @@ function SearchAutocompleteList({ } for (const item of section.data ?? []) { if (item.keyForList === firstRecentReportKey) { - innerListRef.current?.updateAndScrollToFocusedIndex(flatIndex, false); - return; + return flatIndex; } flatIndex++; } } - }, [areOptionsInitialized, firstRecentReportKey, sections, shouldUseNarrowLayout]); + + return -1; + }, [firstRecentReportKey, sections]); + + // When options initialize after the list is already mounted, initiallyFocusedItemKey has no effect + // because useState(initialFocusedIndex) in useArrowKeyFocusManager only reads the initial value. + // Imperatively focus the first recent report once options become available (desktop only). + useEffect(() => { + if (shouldUseNarrowLayout || !areOptionsInitialized || hasSetInitialFocusRef.current || firstRecentReportFlatIndex === -1) { + return; + } + hasSetInitialFocusRef.current = true; + + innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, false); + }, [areOptionsInitialized, firstRecentReportFlatIndex, shouldUseNarrowLayout]); useEffect(() => { const targetText = autocompleteQueryValue; @@ -456,32 +475,38 @@ function SearchAutocompleteList({ } return ( - - shouldShowLoadingPlaceholder - sections={sections} - onSelectRow={onListItemPress} - ListItem={SearchRouterItem} - style={{ - containerStyle: [styles.mh100], - listStyle: [styles.ph2, styles.overscrollBehaviorContain], - contentContainerStyle: styles.pb2, - listItemWrapperStyle: [styles.pr0, styles.pl0], - sectionTitleStyles: styles.mhn2, - }} - shouldSingleExecuteRowSelect - ref={setListRef} - initialScrollIndex={0} - initiallyFocusedItemKey={!shouldUseNarrowLayout ? firstRecentReportKey : undefined} - shouldScrollToFocusedIndex={!isInitialRender} - disableKeyboardShortcuts={!shouldSubscribeToArrowKeyEvents} - addBottomSafeAreaPadding - onLayout={() => { - endSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_LIST_RENDER); - setPerformanceTimersEnd(); - setIsInitialRender(false); - innerListRef.current?.updateExternalTextInputFocus(textInputRef?.current?.isFocused() ?? false); - }} - /> + <> + + shouldShowLoadingPlaceholder + sections={sections} + onSelectRow={onListItemPress} + ListItem={SearchRouterItem} + style={{ + containerStyle: [styles.mh100], + listStyle: [styles.ph2, styles.overscrollBehaviorContain], + contentContainerStyle: styles.pb2, + listItemWrapperStyle: [styles.pr0, styles.pl0], + sectionTitleStyles: styles.mhn2, + }} + shouldSingleExecuteRowSelect + ref={setListRef} + initialScrollIndex={0} + initiallyFocusedItemKey={!shouldUseNarrowLayout ? firstRecentReportKey : undefined} + shouldScrollToFocusedIndex={!isInitialRender} + disableKeyboardShortcuts={!shouldSubscribeToArrowKeyEvents} + addBottomSafeAreaPadding + onLayout={() => { + endSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_LIST_RENDER); + setPerformanceTimersEnd(); + setIsInitialRender(false); + innerListRef.current?.updateExternalTextInputFocus(textInputRef?.current?.isFocused() ?? false); + }} + /> + + ); } diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 3c8afa0fe3706..db540b9d9fe85 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -272,7 +272,7 @@ function BaseSelectionListWithSections({ accessibilityLabel={textInputOptions?.label} options={textInputOptions} onSubmit={selectFocusedItem} - dataLength={flattenedData.length} + dataLength={itemsCount} isLoading={isLoadingNewOptions} onFocusChange={(v: boolean) => (isTextInputFocusedRef.current = v)} shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx new file mode 100644 index 0000000000000..09daccc1042b6 --- /dev/null +++ b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx @@ -0,0 +1,83 @@ +import React, {useEffect, useRef, useState} from 'react'; +import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; +import announceSuggestionsAvailability from './SuggestionsAvailabilityAnnouncementHelper'; + +type SuggestionsAvailabilityAnnouncementProps = { + /** The text announced to assistive technologies when suggestions become available */ + announcement: string; + + /** Delay the announcement to avoid interrupting text input focus changes */ + delayMS?: number; +}; + +function SuggestionsAvailabilityAnnouncement({announcement, delayMS = 0}: SuggestionsAvailabilityAnnouncementProps) { + const styles = useThemeStyles(); + const timeoutRef = useRef(null); + const androidAnnouncementTimeoutRef = useRef(null); + const lastAnnouncementRef = useRef(''); + const [liveRegionAnnouncement, setLiveRegionAnnouncement] = useState(''); + const setAndroidAnnouncementTimeout = (timeout: NodeJS.Timeout | null) => { + androidAnnouncementTimeoutRef.current = timeout; + }; + + useEffect(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (androidAnnouncementTimeoutRef.current) { + clearTimeout(androidAnnouncementTimeoutRef.current); + androidAnnouncementTimeoutRef.current = null; + } + + if (!announcement) { + lastAnnouncementRef.current = ''; + timeoutRef.current = setTimeout(() => { + setLiveRegionAnnouncement(''); + timeoutRef.current = null; + }, 0); + return; + } + + if (announcement === lastAnnouncementRef.current) { + return; + } + + lastAnnouncementRef.current = announcement; + timeoutRef.current = setTimeout(() => { + announceSuggestionsAvailability(announcement, setLiveRegionAnnouncement, setAndroidAnnouncementTimeout); + timeoutRef.current = null; + }, delayMS); + + return () => { + if (!timeoutRef.current) { + if (!androidAnnouncementTimeoutRef.current) { + return; + } + } + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (androidAnnouncementTimeoutRef.current) { + clearTimeout(androidAnnouncementTimeoutRef.current); + androidAnnouncementTimeoutRef.current = null; + } + }; + }, [announcement, delayMS]); + + return ( + + {liveRegionAnnouncement} + + ); +} + +export default SuggestionsAvailabilityAnnouncement; diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.android.ts b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.android.ts new file mode 100644 index 0000000000000..02b88e61d9352 --- /dev/null +++ b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.android.ts @@ -0,0 +1,15 @@ +import type React from 'react'; + +type SetLiveRegionAnnouncement = React.Dispatch>; + +function announceSuggestionsAvailability(announcement: string, setLiveRegionAnnouncement: SetLiveRegionAnnouncement, setAnnouncementTimeout: (timeout: NodeJS.Timeout | null) => void) { + // TalkBack reacts more reliably when an existing live region's text changes. + setLiveRegionAnnouncement(''); + const timeout = setTimeout(() => { + setLiveRegionAnnouncement(announcement); + setAnnouncementTimeout(null); + }, 50); + setAnnouncementTimeout(timeout); +} + +export default announceSuggestionsAvailability; diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ios.ts b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ios.ts new file mode 100644 index 0000000000000..bf050c0384b96 --- /dev/null +++ b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ios.ts @@ -0,0 +1,12 @@ +import type React from 'react'; +import {AccessibilityInfo} from 'react-native'; + +type SetLiveRegionAnnouncement = React.Dispatch>; + +function announceSuggestionsAvailability(announcement: string, setLiveRegionAnnouncement: SetLiveRegionAnnouncement, setAnnouncementTimeout: (timeout: NodeJS.Timeout | null) => void) { + setLiveRegionAnnouncement(announcement); + setAnnouncementTimeout(null); + AccessibilityInfo.announceForAccessibility(announcement); +} + +export default announceSuggestionsAvailability; diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ts b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ts new file mode 100644 index 0000000000000..db73a1d4dddbf --- /dev/null +++ b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ts @@ -0,0 +1,10 @@ +import type React from 'react'; + +type SetLiveRegionAnnouncement = React.Dispatch>; + +function announceSuggestionsAvailability(announcement: string, setLiveRegionAnnouncement: SetLiveRegionAnnouncement, setAnnouncementTimeout: (timeout: NodeJS.Timeout | null) => void) { + setLiveRegionAnnouncement(announcement); + setAnnouncementTimeout(null); +} + +export default announceSuggestionsAvailability; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 7eacdd4016559..6a3b1a158a221 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import mergeRefs from '@libs/mergeRefs'; import CONST from '@src/CONST'; +import SuggestionsAvailabilityAnnouncement from './SuggestionsAvailabilityAnnouncement'; type TextInputProps = { /** Reference to the BaseTextInput component */ @@ -74,6 +75,9 @@ function TextInput({ const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); + const trimmedSearchValue = value?.trim() ?? ''; + const suggestionsCount = dataLength ?? 0; + const suggestionsAnnouncement = !isLoadingNewOptions && suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedSearchValue || undefined) : ''; const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); @@ -148,6 +152,10 @@ function TextInput({ {headerMessage} )} + ); } diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 13d9469a1f17d..0c735a1ee5c7c 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -10,6 +10,7 @@ import FixedFooter from '@components/FixedFooter'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import {PressableWithFeedback} from '@components/Pressable'; import SectionList from '@components/SectionList'; +import SuggestionsAvailabilityAnnouncement from '@components/SelectionList/components/SuggestionsAvailabilityAnnouncement'; import {getListboxRole} from '@components/SelectionList/utils/getListboxRole'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -857,6 +858,11 @@ function BaseSelectionListWithSections({ const prevTextInputValue = usePrevious(textInputValue); const prevSelectedOptionsLength = usePrevious(flattenedSections.selectedOptions.length); const prevAllOptionsLength = usePrevious(flattenedSections.allOptions.length); + const trimmedSearchValue = textInputValue.trim(); + const suggestionsAnnouncement = + shouldShowTextInput && !isLoadingNewOptions && flattenedSections.allOptions.length > 0 + ? translate('search.suggestionsAvailable', {count: flattenedSections.allOptions.length}, trimmedSearchValue || undefined) + : ''; useEffect(() => { if (prevTextInputValue === textInputValue) { @@ -1039,6 +1045,10 @@ function BaseSelectionListWithSections({ return ( {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. */} {!shouldShowHeaderMessageAfterHeader && headerMessageContent()} diff --git a/src/languages/de.ts b/src/languages/de.ts index c826e8b49e889..5c81554607790 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7415,6 +7415,7 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und searchIn: 'Suchen in', searchPlaceholder: 'Nach etwas suchen', suggestions: 'Vorschläge', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `Vorschläge verfügbar${query ? ` für ${query}` : ''}. ${count} ${count === 1 ? 'Ergebnis' : 'Ergebnisse'}.`, exportSearchResults: { title: 'Export erstellen', description: 'Wow, das sind aber viele Elemente! Wir bündeln sie, und Concierge schickt dir in Kürze eine Datei.', diff --git a/src/languages/en.ts b/src/languages/en.ts index ea41723001910..e358a6c2523f4 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7384,6 +7384,7 @@ const translations = { searchIn: 'Search in', searchPlaceholder: 'Search for something', suggestions: 'Suggestions', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `Suggestions available${query ? ` for ${query}` : ''}. ${count} ${count === 1 ? 'result' : 'results'}.`, exportSearchResults: { title: 'Create export', description: "Whoa, that's a lot of items! We'll bundle them up, and Concierge will send you a file shortly.", diff --git a/src/languages/es.ts b/src/languages/es.ts index fab0d5ec0ecc5..58e883c4be448 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7238,6 +7238,7 @@ ${amount} para ${merchant} - ${date}`, searchIn: 'Buscar en', searchPlaceholder: 'Busca algo', suggestions: 'Sugerencias', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `Sugerencias disponibles${query ? ` para ${query}` : ''}. ${count} ${count === 1 ? 'resultado' : 'resultados'}.`, exportSearchResults: { title: 'Crear exportación', description: '¡Wow, esos son muchos elementos! Los agruparemos y Concierge te enviará un archivo en breve.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index cdd84323b33ce..5d2eda06b38de 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7437,6 +7437,7 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip searchIn: 'Rechercher dans', searchPlaceholder: 'Rechercher quelque chose', suggestions: 'Suggestions', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `Suggestions disponibles${query ? ` pour ${query}` : ''}. ${count} ${count === 1 ? 'résultat' : 'résultats'}.`, exportSearchResults: { title: 'Créer l’export', description: 'Ouah, ça fait beaucoup d’éléments ! Nous allons les regrouper et Concierge vous enverra un fichier sous peu.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 77ddcd87941dc..e84c00a3d39c2 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7401,6 +7401,7 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo searchIn: 'Cerca in', searchPlaceholder: 'Cerca qualcosa', suggestions: 'Suggerimenti', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `Suggerimenti disponibili${query ? ` per ${query}` : ''}. ${count} ${count === 1 ? 'risultato' : 'risultati'}.`, exportSearchResults: { title: 'Crea esportazione', description: 'Wow, sono davvero tanti elementi! Li raggrupperemo e Concierge ti invierà un file a breve.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index df3c49a88ab34..9722aff6e6a49 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7316,6 +7316,7 @@ ${reportName} searchIn: '検索対象', searchPlaceholder: '何かを検索', suggestions: '提案', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `候補があります${query ? `: ${query}` : ''}。${count}件の結果。`, exportSearchResults: { title: 'エクスポートを作成', description: 'おっと、アイテムがたくさんありますね!まとめて整理して、間もなくConciergeからファイルをお送りします。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0258ad7e7da8c..88e9e1da5b146 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7368,6 +7368,7 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar searchIn: 'Zoeken in', searchPlaceholder: 'Zoek iets', suggestions: 'Suggesties', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `Suggesties beschikbaar${query ? ` voor ${query}` : ''}. ${count} ${count === 1 ? 'resultaat' : 'resultaten'}.`, exportSearchResults: { title: 'Export maken', description: 'Wow, dat zijn veel items! We bundelen ze, en Concierge stuurt je binnenkort een bestand.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d9617682efe9d..7960236993c71 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7369,6 +7369,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i searchIn: 'Szukaj w', searchPlaceholder: 'Wyszukaj coś', suggestions: 'Sugestie', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `Dostępne sugestie${query ? ` dla ${query}` : ''}. ${count} ${count === 1 ? 'wynik' : 'wyniki'}.`, exportSearchResults: { title: 'Utwórz eksport', description: 'Wow, ale dużo pozycji! Spakujemy je, a Concierge wkrótce wyśle Ci plik.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 5d4aec900dae3..0b0749aa1f980 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7356,6 +7356,7 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e searchIn: 'Pesquisar em', searchPlaceholder: 'Pesquisar algo', suggestions: 'Sugestões', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `Sugestões disponíveis${query ? ` para ${query}` : ''}. ${count} ${count === 1 ? 'resultado' : 'resultados'}.`, exportSearchResults: { title: 'Criar exportação', description: 'Uau, são muitos itens! Vamos agrupá-los e o Concierge enviará um arquivo para você em breve.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ecc478ec7836d..18db0ff4e624f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7199,6 +7199,7 @@ ${reportName} searchIn: '搜索范围', searchPlaceholder: '搜索内容', suggestions: '建议', + suggestionsAvailable: ({count}: {count: number}, query?: string) => `有可用建议${query ? `:${query}` : ''}。共${count}条结果。`, exportSearchResults: { title: '创建导出', description: '哇,项目真不少!我们会把它们打包好,Concierge 很快就会给你发送一个文件。', From 5991c379bdcf40d473f98b3180d275a59c0603da Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 15 Mar 2026 04:28:08 +0530 Subject: [PATCH 02/11] Implement accessibility changes for search suggestions --- .../Search/SearchAutocompleteList.tsx | 7 +- ...estionsAvailabilityAnnouncement.native.tsx | 48 ++++++++++++ .../SuggestionsAvailabilityAnnouncement.tsx | 74 +------------------ .../SelectionList/components/TextInput.tsx | 6 +- .../BaseSelectionListWithSections.tsx | 6 +- 5 files changed, 54 insertions(+), 87 deletions(-) create mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.native.tsx diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 2a5f72eff45c6..afa745ae898f8 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -4,7 +4,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {OptionsListStateContext, useOptionsList} from '@components/OptionListContextProvider'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; -import SuggestionsAvailabilityAnnouncement from '@components/SelectionList/components/SuggestionsAvailabilityAnnouncement'; import type {ListItem as NewListItem, UserListItemProps} from '@components/SelectionList/ListItem/types'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; @@ -12,6 +11,7 @@ import type {Section, SelectionListWithSectionsHandle} from '@components/Selecti // eslint-disable-next-line no-restricted-imports import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; import SearchQueryListItem, {isSearchQueryItem} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; @@ -410,6 +410,7 @@ function SearchAutocompleteList({ const suggestionsCount = sections.reduce((total, section) => total + section.data.filter((item) => item.keyForList !== 'findItem').length, 0); const trimmedAutocompleteQueryValue = autocompleteQueryValue.trim(); const suggestionsAnnouncement = suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedAutocompleteQueryValue || undefined) : ''; + useAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, {shouldAnnounceOnNative: true}); const firstRecentReportKey = styledRecentReports.at(0)?.keyForList; const firstRecentReportFlatIndex = useMemo(() => { @@ -502,10 +503,6 @@ function SearchAutocompleteList({ innerListRef.current?.updateExternalTextInputFocus(textInputRef?.current?.isFocused() ?? false); }} /> - ); } diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.native.tsx b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.native.tsx new file mode 100644 index 0000000000000..125164bb932bf --- /dev/null +++ b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.native.tsx @@ -0,0 +1,48 @@ +import React, {useEffect, useRef} from 'react'; +import {AccessibilityInfo} from 'react-native'; + +type SuggestionsAvailabilityAnnouncementProps = { + /** The text announced to assistive technologies when suggestions become available */ + announcement: string; + + /** Delay the announcement to avoid interrupting text input focus changes */ + delayMS?: number; +}; + +function SuggestionsAvailabilityAnnouncement({announcement, delayMS = 0}: SuggestionsAvailabilityAnnouncementProps) { + const timeoutRef = useRef(null); + const lastAnnouncementRef = useRef(''); + + useEffect(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + if (!announcement) { + lastAnnouncementRef.current = ''; + return; + } + + if (announcement === lastAnnouncementRef.current) { + return; + } + + lastAnnouncementRef.current = announcement; + timeoutRef.current = setTimeout(() => { + AccessibilityInfo.announceForAccessibility(announcement); + timeoutRef.current = null; + }, delayMS); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [announcement, delayMS]); + + return null; +} + +export default SuggestionsAvailabilityAnnouncement; diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx index 09daccc1042b6..6a5a0cec909ab 100644 --- a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx +++ b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx @@ -1,8 +1,3 @@ -import React, {useEffect, useRef, useState} from 'react'; -import Text from '@components/Text'; -import useThemeStyles from '@hooks/useThemeStyles'; -import announceSuggestionsAvailability from './SuggestionsAvailabilityAnnouncementHelper'; - type SuggestionsAvailabilityAnnouncementProps = { /** The text announced to assistive technologies when suggestions become available */ announcement: string; @@ -11,73 +6,8 @@ type SuggestionsAvailabilityAnnouncementProps = { delayMS?: number; }; -function SuggestionsAvailabilityAnnouncement({announcement, delayMS = 0}: SuggestionsAvailabilityAnnouncementProps) { - const styles = useThemeStyles(); - const timeoutRef = useRef(null); - const androidAnnouncementTimeoutRef = useRef(null); - const lastAnnouncementRef = useRef(''); - const [liveRegionAnnouncement, setLiveRegionAnnouncement] = useState(''); - const setAndroidAnnouncementTimeout = (timeout: NodeJS.Timeout | null) => { - androidAnnouncementTimeoutRef.current = timeout; - }; - - useEffect(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - if (androidAnnouncementTimeoutRef.current) { - clearTimeout(androidAnnouncementTimeoutRef.current); - androidAnnouncementTimeoutRef.current = null; - } - - if (!announcement) { - lastAnnouncementRef.current = ''; - timeoutRef.current = setTimeout(() => { - setLiveRegionAnnouncement(''); - timeoutRef.current = null; - }, 0); - return; - } - - if (announcement === lastAnnouncementRef.current) { - return; - } - - lastAnnouncementRef.current = announcement; - timeoutRef.current = setTimeout(() => { - announceSuggestionsAvailability(announcement, setLiveRegionAnnouncement, setAndroidAnnouncementTimeout); - timeoutRef.current = null; - }, delayMS); - - return () => { - if (!timeoutRef.current) { - if (!androidAnnouncementTimeoutRef.current) { - return; - } - } - - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - if (androidAnnouncementTimeoutRef.current) { - clearTimeout(androidAnnouncementTimeoutRef.current); - androidAnnouncementTimeoutRef.current = null; - } - }; - }, [announcement, delayMS]); - - return ( - - {liveRegionAnnouncement} - - ); +function SuggestionsAvailabilityAnnouncement(_props: SuggestionsAvailabilityAnnouncementProps) { + return null; } export default SuggestionsAvailabilityAnnouncement; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 6a3b1a158a221..c607a6de8b767 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -11,7 +11,6 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import mergeRefs from '@libs/mergeRefs'; import CONST from '@src/CONST'; -import SuggestionsAvailabilityAnnouncement from './SuggestionsAvailabilityAnnouncement'; type TextInputProps = { /** Reference to the BaseTextInput component */ @@ -78,6 +77,7 @@ function TextInput({ const trimmedSearchValue = value?.trim() ?? ''; const suggestionsCount = dataLength ?? 0; const suggestionsAnnouncement = !isLoadingNewOptions && suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedSearchValue || undefined) : ''; + useAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, {shouldAnnounceOnNative: true}); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); @@ -152,10 +152,6 @@ function TextInput({ {headerMessage} )} - ); } diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 0c735a1ee5c7c..c25afc5fb231f 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -10,7 +10,6 @@ import FixedFooter from '@components/FixedFooter'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import {PressableWithFeedback} from '@components/Pressable'; import SectionList from '@components/SectionList'; -import SuggestionsAvailabilityAnnouncement from '@components/SelectionList/components/SuggestionsAvailabilityAnnouncement'; import {getListboxRole} from '@components/SelectionList/utils/getListboxRole'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -1020,6 +1019,7 @@ function BaseSelectionListWithSections({ const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); + useAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, {shouldAnnounceOnNative: true}); const headerMessageContent = () => shouldShowHeaderMessage && ( @@ -1045,10 +1045,6 @@ function BaseSelectionListWithSections({ return ( {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. */} {!shouldShowHeaderMessageAfterHeader && headerMessageContent()} From 8307d985e0710cd3bb5955370092a1cde36f359e Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:53:01 +0530 Subject: [PATCH 03/11] Improvements --- src/CONST/index.ts | 1 + .../Search/SearchAutocompleteList.tsx | 4 +- ...estionsAvailabilityAnnouncement.native.tsx | 48 ------------ .../SuggestionsAvailabilityAnnouncement.tsx | 13 ---- .../SelectionList/components/TextInput.tsx | 16 ++-- .../BaseSelectionListWithSections.tsx | 14 ++-- .../useAccessibilityAnnouncement/index.ios.ts | 5 +- .../index.native.ts | 5 +- .../useAccessibilityAnnouncement/index.ts | 73 ++++++++++++++++++- .../useAccessibilityAnnouncement/types.ts | 6 ++ .../useDebouncedAccessibilityAnnouncement.ts | 16 ++++ src/hooks/useDebouncedValue.ts | 19 +++++ 12 files changed, 134 insertions(+), 86 deletions(-) delete mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.native.tsx delete mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx create mode 100644 src/hooks/useAccessibilityAnnouncement/types.ts create mode 100644 src/hooks/useDebouncedAccessibilityAnnouncement.ts create mode 100644 src/hooks/useDebouncedValue.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ad1dc229273ee..d798e24bbb776 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1730,6 +1730,7 @@ const CONST = { TOOLTIP_SENSE: 1000, COMMENT_LENGTH_DEBOUNCE_TIME: 1500, SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, + ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME: 1000, SUGGESTION_DEBOUNCE_TIME: 100, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index afa745ae898f8..535aa6fb94d4e 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -11,10 +11,10 @@ import type {Section, SelectionListWithSectionsHandle} from '@components/Selecti // eslint-disable-next-line no-restricted-imports import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; import SearchQueryListItem, {isSearchQueryItem} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; -import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -410,7 +410,7 @@ function SearchAutocompleteList({ const suggestionsCount = sections.reduce((total, section) => total + section.data.filter((item) => item.keyForList !== 'findItem').length, 0); const trimmedAutocompleteQueryValue = autocompleteQueryValue.trim(); const suggestionsAnnouncement = suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedAutocompleteQueryValue || undefined) : ''; - useAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, {shouldAnnounceOnNative: true}); + useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, autocompleteQueryValue); const firstRecentReportKey = styledRecentReports.at(0)?.keyForList; const firstRecentReportFlatIndex = useMemo(() => { diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.native.tsx b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.native.tsx deleted file mode 100644 index 125164bb932bf..0000000000000 --- a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.native.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, {useEffect, useRef} from 'react'; -import {AccessibilityInfo} from 'react-native'; - -type SuggestionsAvailabilityAnnouncementProps = { - /** The text announced to assistive technologies when suggestions become available */ - announcement: string; - - /** Delay the announcement to avoid interrupting text input focus changes */ - delayMS?: number; -}; - -function SuggestionsAvailabilityAnnouncement({announcement, delayMS = 0}: SuggestionsAvailabilityAnnouncementProps) { - const timeoutRef = useRef(null); - const lastAnnouncementRef = useRef(''); - - useEffect(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - - if (!announcement) { - lastAnnouncementRef.current = ''; - return; - } - - if (announcement === lastAnnouncementRef.current) { - return; - } - - lastAnnouncementRef.current = announcement; - timeoutRef.current = setTimeout(() => { - AccessibilityInfo.announceForAccessibility(announcement); - timeoutRef.current = null; - }, delayMS); - - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }; - }, [announcement, delayMS]); - - return null; -} - -export default SuggestionsAvailabilityAnnouncement; diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx deleted file mode 100644 index 6a5a0cec909ab..0000000000000 --- a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncement.tsx +++ /dev/null @@ -1,13 +0,0 @@ -type SuggestionsAvailabilityAnnouncementProps = { - /** The text announced to assistive technologies when suggestions become available */ - announcement: string; - - /** Delay the announcement to avoid interrupting text input focus changes */ - delayMS?: number; -}; - -function SuggestionsAvailabilityAnnouncement(_props: SuggestionsAvailabilityAnnouncementProps) { - return null; -} - -export default SuggestionsAvailabilityAnnouncement; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index c607a6de8b767..742ddf0225ce4 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -6,7 +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 useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import mergeRefs from '@libs/mergeRefs'; @@ -71,13 +71,12 @@ function TextInput({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); - const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; - - useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); const trimmedSearchValue = value?.trim() ?? ''; const suggestionsCount = dataLength ?? 0; const suggestionsAnnouncement = !isLoadingNewOptions && suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedSearchValue || undefined) : ''; - useAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, {shouldAnnounceOnNative: true}); + + useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage, value ?? ''); + useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, value ?? ''); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); @@ -149,7 +148,12 @@ function TextInput({ {shouldShowHeaderMessage && ( - {headerMessage} + + {headerMessage} + )} diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index c25afc5fb231f..f39f5b67be6a3 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -13,9 +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 useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; @@ -1016,15 +1016,19 @@ function BaseSelectionListWithSections({ const noResultsFoundText = translate('common.noResultsFound'); const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !shouldShowLoadingPlaceholder)); - const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage; - useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true}); - useAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, {shouldAnnounceOnNative: true}); + useDebouncedAccessibilityAnnouncement(headerMessage, shouldShowHeaderMessage, textInputValue); + useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, textInputValue); const headerMessageContent = () => shouldShowHeaderMessage && ( - {headerMessage} + + {headerMessage} + ); diff --git a/src/hooks/useAccessibilityAnnouncement/index.ios.ts b/src/hooks/useAccessibilityAnnouncement/index.ios.ts index 3d21af2b7b7b1..ee479d2d37ca5 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ios.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ios.ts @@ -1,10 +1,7 @@ import type {ReactNode} from 'react'; import {useEffect, useRef} from 'react'; import {AccessibilityInfo} from 'react-native'; - -type UseAccessibilityAnnouncementOptions = { - shouldAnnounceOnNative?: 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 b66f9540f3abe..80f307820bb9a 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.native.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.native.ts @@ -1,10 +1,7 @@ import type {ReactNode} from 'react'; import {useEffect, useRef} from 'react'; import {AccessibilityInfo} from 'react-native'; - -type UseAccessibilityAnnouncementOptions = { - shouldAnnounceOnNative?: 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 c853cec379be8..6faa9b664deee 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ts @@ -1,10 +1,75 @@ import type {ReactNode} from 'react'; +import {useEffect, useRef} from 'react'; +import type UseAccessibilityAnnouncementOptions from './types'; -type UseAccessibilityAnnouncementOptions = { - shouldAnnounceOnNative?: boolean; +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', }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function useAccessibilityAnnouncement(_message: string | ReactNode, _shouldAnnounceMessage: boolean, _options?: UseAccessibilityAnnouncementOptions) {} +/** + * VoiceOver on Mac echoes the completed word shortly after typing stops. + * This delay helps ensure the alert fires after that echo finishes. + */ +const ANNOUNCEMENT_DELAY_MS = 300; + +let wrapper: HTMLDivElement | null = null; + +function getWrapper(): HTMLDivElement { + if (wrapper && document.body.contains(wrapper)) { + return wrapper; + } + + 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); + + 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/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/hooks/useDebouncedAccessibilityAnnouncement.ts b/src/hooks/useDebouncedAccessibilityAnnouncement.ts new file mode 100644 index 0000000000000..0f3aaeaaf71cb --- /dev/null +++ b/src/hooks/useDebouncedAccessibilityAnnouncement.ts @@ -0,0 +1,16 @@ +import CONST from '@src/CONST'; +import useAccessibilityAnnouncement from './useAccessibilityAnnouncement'; +import useDebouncedValue from './useDebouncedValue'; + +/** + * Announces a message after typing pauses, on both native and web. + */ +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, shouldAnnounceOnWeb: true}); +} + +export default useDebouncedAccessibilityAnnouncement; diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000000000..c606467faa138 --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,19 @@ +import {useEffect, useState} from 'react'; +import CONST from '@src/CONST'; + +/** + * Returns a debounced version of the given value. + * The first update must complete before equality checks can succeed. + */ +function useDebouncedValue(value: T, delay: number = CONST.TIMING.USE_DEBOUNCED_STATE_DELAY): T { + const [debouncedValue, setDebouncedValue] = useState(undefined); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue as T; +} + +export default useDebouncedValue; From f5a328a2b4fcec0dc54553a3f9b6528dc02520b0 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:28:51 +0530 Subject: [PATCH 04/11] Web updates --- .../useAccessibilityAnnouncement/index.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts index 6faa9b664deee..a67c38c791d35 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ts @@ -28,6 +28,7 @@ function getWrapper(): HTMLDivElement { } wrapper = document.createElement('div'); + wrapper.setAttribute('role', 'alert'); wrapper.setAttribute('aria-live', 'assertive'); wrapper.setAttribute('aria-atomic', 'true'); Object.assign(wrapper.style, VISUALLY_HIDDEN_STYLE); @@ -38,36 +39,29 @@ function getWrapper(): HTMLDivElement { function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, options?: UseAccessibilityAnnouncementOptions) { const shouldAnnounceOnWeb = options?.shouldAnnounceOnWeb ?? false; - const prevShouldAnnounceRef = useRef(false); + const previousAnnouncedMessageRef = useRef(''); useEffect(() => { - if (!shouldAnnounceMessage) { - prevShouldAnnounceRef.current = false; + if (!shouldAnnounceOnWeb || !shouldAnnounceMessage || typeof message !== 'string' || !message.trim()) { + previousAnnouncedMessageRef.current = ''; return; } - if (prevShouldAnnounceRef.current || !shouldAnnounceOnWeb || typeof message !== 'string' || !message.trim()) { + if (previousAnnouncedMessageRef.current === message) { return; } - prevShouldAnnounceRef.current = true; + previousAnnouncedMessageRef.current = message; const container = getWrapper(); - - while (container.firstChild) { - container.removeChild(container.firstChild); - } + container.textContent = ''; const timer = setTimeout(() => { - const node = document.createElement('div'); - node.setAttribute('role', 'alert'); - node.textContent = message; - container.appendChild(node); + container.textContent = message; }, ANNOUNCEMENT_DELAY_MS); return () => { clearTimeout(timer); - prevShouldAnnounceRef.current = false; }; }, [message, shouldAnnounceMessage, shouldAnnounceOnWeb]); } From 7db6af515dc8e7a6ec7cf06193dd35b63fcf8f0a Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:48:14 +0530 Subject: [PATCH 05/11] Fix fragment children warning --- Mobile-Expensify | 2 +- .../Search/SearchAutocompleteList.tsx | 54 +++++++++---------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 03346cd7dc89b..fda79d3306ed4 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 03346cd7dc89bca1289a65751c48703a58912e18 +Subproject commit fda79d3306ed4a966763b998e944ff961b6e341a diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 378aeadf83ac9..6b0c6ded00db9 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -484,34 +484,32 @@ function SearchAutocompleteList({ } return ( - <> - - shouldShowLoadingPlaceholder - sections={sections} - onSelectRow={onListItemPress} - ListItem={SearchRouterItem} - style={{ - containerStyle: [styles.mh100], - listStyle: [styles.ph2, styles.overscrollBehaviorContain], - contentContainerStyle: styles.pb2, - listItemWrapperStyle: [styles.pr0, styles.pl0], - sectionTitleStyles: styles.mhn2, - }} - shouldSingleExecuteRowSelect - ref={setListRef} - initialScrollIndex={0} - initiallyFocusedItemKey={!shouldUseNarrowLayout ? firstRecentReportKey : undefined} - shouldScrollToFocusedIndex={!isInitialRender} - disableKeyboardShortcuts={!shouldSubscribeToArrowKeyEvents} - addBottomSafeAreaPadding - onLayout={() => { - endSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_LIST_RENDER); - setPerformanceTimersEnd(); - setIsInitialRender(false); - innerListRef.current?.updateExternalTextInputFocus(textInputRef?.current?.isFocused() ?? false); - }} - /> - + + shouldShowLoadingPlaceholder + sections={sections} + onSelectRow={onListItemPress} + ListItem={SearchRouterItem} + style={{ + containerStyle: [styles.mh100], + listStyle: [styles.ph2, styles.overscrollBehaviorContain], + contentContainerStyle: styles.pb2, + listItemWrapperStyle: [styles.pr0, styles.pl0], + sectionTitleStyles: styles.mhn2, + }} + shouldSingleExecuteRowSelect + ref={setListRef} + initialScrollIndex={0} + initiallyFocusedItemKey={!shouldUseNarrowLayout ? firstRecentReportKey : undefined} + shouldScrollToFocusedIndex={!isInitialRender} + disableKeyboardShortcuts={!shouldSubscribeToArrowKeyEvents} + addBottomSafeAreaPadding + onLayout={() => { + endSpan(CONST.TELEMETRY.SPAN_SEARCH_ROUTER_LIST_RENDER); + setPerformanceTimersEnd(); + setIsInitialRender(false); + innerListRef.current?.updateExternalTextInputFocus(textInputRef?.current?.isFocused() ?? false); + }} + /> ); } From 6c1e503e2c25f9d98b0299947fde6cb829cede88 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:26:06 +0530 Subject: [PATCH 06/11] Address PR review comments --- .../Search/SearchAutocompleteList.tsx | 19 +++++++++---------- ...sAvailabilityAnnouncementHelper.android.ts | 15 --------------- ...tionsAvailabilityAnnouncementHelper.ios.ts | 12 ------------ ...ggestionsAvailabilityAnnouncementHelper.ts | 10 ---------- 4 files changed, 9 insertions(+), 47 deletions(-) delete mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.android.ts delete mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ios.ts delete mode 100644 src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ts diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 6b0c6ded00db9..1c04bac30ad5d 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -415,17 +415,14 @@ function SearchAutocompleteList({ const sectionItemText = sections?.at(1)?.data?.[0]?.text ?? ''; const normalizedReferenceText = sectionItemText.toLowerCase(); - const suggestionsCount = sections.reduce((total, section) => total + section.data.filter((item) => item.keyForList !== 'findItem').length, 0); + const suggestionsCount = sections.reduce((total, section) => total + section.data.filter((item) => item.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM).length, 0); const trimmedAutocompleteQueryValue = autocompleteQueryValue.trim(); const suggestionsAnnouncement = suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedAutocompleteQueryValue || undefined) : ''; useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, autocompleteQueryValue); const firstRecentReportKey = styledRecentReports.at(0)?.keyForList; - const firstRecentReportFlatIndex = useMemo(() => { - if (!firstRecentReportKey) { - return -1; - } - + let firstRecentReportFlatIndex = -1; + if (firstRecentReportKey) { let flatIndex = 0; for (const section of sections) { const hasData = (section.data?.length ?? 0) > 0; @@ -435,14 +432,16 @@ function SearchAutocompleteList({ } for (const item of section.data ?? []) { if (item.keyForList === firstRecentReportKey) { - return flatIndex; + firstRecentReportFlatIndex = flatIndex; + break; } flatIndex++; } + if (firstRecentReportFlatIndex !== -1) { + break; + } } - - return -1; - }, [firstRecentReportKey, sections]); + } // When options initialize after the list is already mounted, initiallyFocusedItemKey has no effect // because useState(initialFocusedIndex) in useArrowKeyFocusManager only reads the initial value. diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.android.ts b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.android.ts deleted file mode 100644 index 02b88e61d9352..0000000000000 --- a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.android.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type React from 'react'; - -type SetLiveRegionAnnouncement = React.Dispatch>; - -function announceSuggestionsAvailability(announcement: string, setLiveRegionAnnouncement: SetLiveRegionAnnouncement, setAnnouncementTimeout: (timeout: NodeJS.Timeout | null) => void) { - // TalkBack reacts more reliably when an existing live region's text changes. - setLiveRegionAnnouncement(''); - const timeout = setTimeout(() => { - setLiveRegionAnnouncement(announcement); - setAnnouncementTimeout(null); - }, 50); - setAnnouncementTimeout(timeout); -} - -export default announceSuggestionsAvailability; diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ios.ts b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ios.ts deleted file mode 100644 index bf050c0384b96..0000000000000 --- a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ios.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type React from 'react'; -import {AccessibilityInfo} from 'react-native'; - -type SetLiveRegionAnnouncement = React.Dispatch>; - -function announceSuggestionsAvailability(announcement: string, setLiveRegionAnnouncement: SetLiveRegionAnnouncement, setAnnouncementTimeout: (timeout: NodeJS.Timeout | null) => void) { - setLiveRegionAnnouncement(announcement); - setAnnouncementTimeout(null); - AccessibilityInfo.announceForAccessibility(announcement); -} - -export default announceSuggestionsAvailability; diff --git a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ts b/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ts deleted file mode 100644 index db73a1d4dddbf..0000000000000 --- a/src/components/SelectionList/components/SuggestionsAvailabilityAnnouncementHelper.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type React from 'react'; - -type SetLiveRegionAnnouncement = React.Dispatch>; - -function announceSuggestionsAvailability(announcement: string, setLiveRegionAnnouncement: SetLiveRegionAnnouncement, setAnnouncementTimeout: (timeout: NodeJS.Timeout | null) => void) { - setLiveRegionAnnouncement(announcement); - setAnnouncementTimeout(null); -} - -export default announceSuggestionsAvailability; From 0a5b5ec9c27f50ff1aded602189a94c1fd81aa8b Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:46:57 +0530 Subject: [PATCH 07/11] Reuse accessibility announcement hook from main --- .../useAccessibilityAnnouncement/index.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts index 8703683094190..bc104b6f9d467 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ts @@ -16,9 +16,9 @@ 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. Combined with the debounce in - * useDebouncedAccessibilityAnnouncement, this delay helps the alert fire after - * that echo finishes. + * 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 = 300; @@ -40,21 +40,22 @@ function getWrapper(): HTMLDivElement { function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, options?: UseAccessibilityAnnouncementOptions) { const shouldAnnounceOnWeb = options?.shouldAnnounceOnWeb ?? false; - const previousAnnouncedMessageRef = useRef(''); + const prevShouldAnnounceRef = useRef(false); useEffect(() => { - if (!shouldAnnounceOnWeb || !shouldAnnounceMessage || typeof message !== 'string' || !message.trim()) { - previousAnnouncedMessageRef.current = ''; + if (!shouldAnnounceMessage) { + prevShouldAnnounceRef.current = false; return; } - if (previousAnnouncedMessageRef.current === message) { + if (prevShouldAnnounceRef.current || !shouldAnnounceOnWeb || typeof message !== 'string' || !message.trim()) { return; } - previousAnnouncedMessageRef.current = message; + prevShouldAnnounceRef.current = true; const container = getWrapper(); + while (container.firstChild) { container.removeChild(container.firstChild); } @@ -68,6 +69,7 @@ function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounc return () => { clearTimeout(timer); + prevShouldAnnounceRef.current = false; }; }, [message, shouldAnnounceMessage, shouldAnnounceOnWeb]); } From 4333f8dc191a22436dda3b632a045cd79ab12b0d Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:59:41 +0530 Subject: [PATCH 08/11] Addreess comments --- .../Search/SearchAutocompleteList.tsx | 23 +++++++++++-------- .../SelectionList/components/TextInput.tsx | 2 +- .../BaseSelectionListWithSections.tsx | 2 +- src/languages/de.ts | 5 +++- src/languages/en.ts | 5 +++- src/languages/es.ts | 5 +++- src/languages/fr.ts | 5 +++- src/languages/it.ts | 5 +++- src/languages/ja.ts | 5 +++- src/languages/nl.ts | 5 +++- src/languages/pl.ts | 5 +++- src/languages/pt-BR.ts | 5 +++- src/languages/zh-hans.ts | 5 +++- 13 files changed, 56 insertions(+), 21 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 1c04bac30ad5d..94d91397d7bac 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -353,25 +353,31 @@ function SearchAutocompleteList({ }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); /* Sections generation */ - const {sections, styledRecentReports} = useMemo(() => { + const {sections, styledRecentReports, suggestionsCount} = useMemo(() => { const nextSections: Array> = []; let sectionIndex = 0; + let nextSuggestionsCount = 0; + + const pushSection = (section: Section) => { + nextSections.push(section); + nextSuggestionsCount += section.data.filter((item) => item.keyForList !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM).length; + }; if (searchQueryItem) { - nextSections.push({data: [searchQueryItem as AutocompleteListItem], sectionIndex: sectionIndex++}); + pushSection({data: [searchQueryItem as AutocompleteListItem], sectionIndex: sectionIndex++}); } const additionalSections = getAdditionalSections?.(searchOptions, sectionIndex); if (additionalSections) { for (const section of additionalSections) { - nextSections.push(section); + pushSection(section); sectionIndex++; } } if (!autocompleteQueryValue && recentSearchesData && recentSearchesData.length > 0) { - nextSections.push({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); + pushSection({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); } const nextStyledRecentReports = recentReportsOptions.map((option) => { @@ -388,7 +394,7 @@ function SearchAutocompleteList({ } as AutocompleteListItem; }); - nextSections.push({ + pushSection({ title: autocompleteQueryValue.trim() === '' ? translate('search.recentChats') : undefined, data: nextStyledRecentReports, sectionIndex: sectionIndex++, @@ -407,17 +413,16 @@ function SearchAutocompleteList({ }; }); - nextSections.push({title: translate('search.suggestions'), data: autocompleteData, sectionIndex: sectionIndex++}); + pushSection({title: translate('search.suggestions'), data: autocompleteData, sectionIndex: sectionIndex++}); } - return {sections: nextSections, styledRecentReports: nextStyledRecentReports}; + return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [autocompleteQueryValue, autocompleteSuggestions, expensifyIcons, getAdditionalSections, recentReportsOptions, recentSearchesData, searchOptions, searchQueryItem, styles, translate]); const sectionItemText = sections?.at(1)?.data?.[0]?.text ?? ''; const normalizedReferenceText = sectionItemText.toLowerCase(); - const suggestionsCount = sections.reduce((total, section) => total + section.data.filter((item) => item.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM).length, 0); const trimmedAutocompleteQueryValue = autocompleteQueryValue.trim(); - const suggestionsAnnouncement = suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedAutocompleteQueryValue || undefined) : ''; + const suggestionsAnnouncement = suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedAutocompleteQueryValue) : ''; useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, autocompleteQueryValue); const firstRecentReportKey = styledRecentReports.at(0)?.keyForList; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 742ddf0225ce4..068ecffd86d42 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -73,7 +73,7 @@ function TextInput({ const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); const trimmedSearchValue = value?.trim() ?? ''; const suggestionsCount = dataLength ?? 0; - const suggestionsAnnouncement = !isLoadingNewOptions && suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedSearchValue || undefined) : ''; + const suggestionsAnnouncement = !isLoadingNewOptions && suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedSearchValue) : ''; useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage, value ?? ''); useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, value ?? ''); diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index f39f5b67be6a3..f338ff64b11a2 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -860,7 +860,7 @@ function BaseSelectionListWithSections({ const trimmedSearchValue = textInputValue.trim(); const suggestionsAnnouncement = shouldShowTextInput && !isLoadingNewOptions && flattenedSections.allOptions.length > 0 - ? translate('search.suggestionsAvailable', {count: flattenedSections.allOptions.length}, trimmedSearchValue || undefined) + ? translate('search.suggestionsAvailable', {count: flattenedSections.allOptions.length}, trimmedSearchValue) : ''; useEffect(() => { diff --git a/src/languages/de.ts b/src/languages/de.ts index e7d0e3169eea5..4f84c2ec59122 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7395,7 +7395,10 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und searchIn: 'Suchen in', searchPlaceholder: 'Nach etwas suchen', suggestions: 'Vorschläge', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `Vorschläge verfügbar${query ? ` für ${query}` : ''}. ${count} ${count === 1 ? 'Ergebnis' : 'Ergebnisse'}.`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `Vorschläge verfügbar${query ? ` für ${query}` : ''}. ${count} Ergebnis.`, + other: (resultCount: number) => `Vorschläge verfügbar${query ? ` für ${query}` : ''}. ${resultCount} Ergebnisse.`, + }), exportSearchResults: { title: 'Export erstellen', description: 'Wow, das sind aber viele Elemente! Wir bündeln sie, und Concierge schickt dir in Kürze eine Datei.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 87a75cf52ec6f..91933b9c9a3f2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7367,7 +7367,10 @@ const translations = { searchIn: 'Search in', searchPlaceholder: 'Search for something', suggestions: 'Suggestions', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `Suggestions available${query ? ` for ${query}` : ''}. ${count} ${count === 1 ? 'result' : 'results'}.`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `Suggestions available${query ? ` for ${query}` : ''}. ${count} result.`, + other: (resultCount: number) => `Suggestions available${query ? ` for ${query}` : ''}. ${resultCount} results.`, + }), exportSearchResults: { title: 'Create export', description: "Whoa, that's a lot of items! We'll bundle them up, and Concierge will send you a file shortly.", diff --git a/src/languages/es.ts b/src/languages/es.ts index 31233f21a4230..cb713ebe0dfc3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7258,7 +7258,10 @@ ${amount} para ${merchant} - ${date}`, searchIn: 'Buscar en', searchPlaceholder: 'Busca algo', suggestions: 'Sugerencias', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `Sugerencias disponibles${query ? ` para ${query}` : ''}. ${count} ${count === 1 ? 'resultado' : 'resultados'}.`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `Sugerencias disponibles${query ? ` para ${query}` : ''}. ${count} resultado.`, + other: (resultCount: number) => `Sugerencias disponibles${query ? ` para ${query}` : ''}. ${resultCount} resultados.`, + }), exportSearchResults: { title: 'Crear exportación', description: '¡Wow, esos son muchos elementos! Los agruparemos y Concierge te enviará un archivo en breve.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index f126a5a868786..162699534966b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7418,7 +7418,10 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip searchIn: 'Rechercher dans', searchPlaceholder: 'Rechercher quelque chose', suggestions: 'Suggestions', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `Suggestions disponibles${query ? ` pour ${query}` : ''}. ${count} ${count === 1 ? 'résultat' : 'résultats'}.`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `Suggestions disponibles${query ? ` pour ${query}` : ''}. ${count} résultat.`, + other: (resultCount: number) => `Suggestions disponibles${query ? ` pour ${query}` : ''}. ${resultCount} résultats.`, + }), exportSearchResults: { title: 'Créer l’export', description: 'Ouah, ça fait beaucoup d’éléments ! Nous allons les regrouper et Concierge vous enverra un fichier sous peu.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 623e8ecf64bfa..9b1301d873213 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7382,7 +7382,10 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo searchIn: 'Cerca in', searchPlaceholder: 'Cerca qualcosa', suggestions: 'Suggerimenti', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `Suggerimenti disponibili${query ? ` per ${query}` : ''}. ${count} ${count === 1 ? 'risultato' : 'risultati'}.`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `Suggerimenti disponibili${query ? ` per ${query}` : ''}. ${count} risultato.`, + other: (resultCount: number) => `Suggerimenti disponibili${query ? ` per ${query}` : ''}. ${resultCount} risultati.`, + }), exportSearchResults: { title: 'Crea esportazione', description: 'Wow, sono davvero tanti elementi! Li raggrupperemo e Concierge ti invierà un file a breve.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9f9f810e8ff17..174bd1cc59279 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7299,7 +7299,10 @@ ${reportName} searchIn: '検索対象', searchPlaceholder: '何かを検索', suggestions: '提案', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `候補があります${query ? `: ${query}` : ''}。${count}件の結果。`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `候補があります${query ? `: ${query}` : ''}。${count}件の結果。`, + other: (resultCount: number) => `候補があります${query ? `: ${query}` : ''}。${resultCount}件の結果。`, + }), exportSearchResults: { title: 'エクスポートを作成', description: 'おっと、アイテムがたくさんありますね!まとめて整理して、間もなくConciergeからファイルをお送りします。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 156c828a4283a..3287d8a16e252 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7348,7 +7348,10 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar searchIn: 'Zoeken in', searchPlaceholder: 'Zoek iets', suggestions: 'Suggesties', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `Suggesties beschikbaar${query ? ` voor ${query}` : ''}. ${count} ${count === 1 ? 'resultaat' : 'resultaten'}.`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `Suggesties beschikbaar${query ? ` voor ${query}` : ''}. ${count} resultaat.`, + other: (resultCount: number) => `Suggesties beschikbaar${query ? ` voor ${query}` : ''}. ${resultCount} resultaten.`, + }), exportSearchResults: { title: 'Export maken', description: 'Wow, dat zijn veel items! We bundelen ze, en Concierge stuurt je binnenkort een bestand.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ca765861dfe62..95173cafcd84e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7349,7 +7349,10 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i searchIn: 'Szukaj w', searchPlaceholder: 'Wyszukaj coś', suggestions: 'Sugestie', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `Dostępne sugestie${query ? ` dla ${query}` : ''}. ${count} ${count === 1 ? 'wynik' : 'wyniki'}.`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `Dostępne sugestie${query ? ` dla ${query}` : ''}. ${count} wynik.`, + other: (resultCount: number) => `Dostępne sugestie${query ? ` dla ${query}` : ''}. ${resultCount} wyniki.`, + }), exportSearchResults: { title: 'Utwórz eksport', description: 'Wow, ale dużo pozycji! Spakujemy je, a Concierge wkrótce wyśle Ci plik.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 08b5030599cb6..2c476048ca62e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7336,7 +7336,10 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e searchIn: 'Pesquisar em', searchPlaceholder: 'Pesquisar algo', suggestions: 'Sugestões', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `Sugestões disponíveis${query ? ` para ${query}` : ''}. ${count} ${count === 1 ? 'resultado' : 'resultados'}.`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `Sugestões disponíveis${query ? ` para ${query}` : ''}. ${count} resultado.`, + other: (resultCount: number) => `Sugestões disponíveis${query ? ` para ${query}` : ''}. ${resultCount} resultados.`, + }), exportSearchResults: { title: 'Criar exportação', description: 'Uau, são muitos itens! Vamos agrupá-los e o Concierge enviará um arquivo para você em breve.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2fdd08e4da783..52c99e8439990 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7177,7 +7177,10 @@ ${reportName} searchIn: '搜索范围', searchPlaceholder: '搜索内容', suggestions: '建议', - suggestionsAvailable: ({count}: {count: number}, query?: string) => `有可用建议${query ? `:${query}` : ''}。共${count}条结果。`, + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `有可用建议${query ? `:${query}` : ''}。共${count}条结果。`, + other: (resultCount: number) => `有可用建议${query ? `:${query}` : ''}。共${resultCount}条结果。`, + }), exportSearchResults: { title: '创建导出', description: '哇,项目真不少!我们会把它们打包好,Concierge 很快就会给你发送一个文件。', From 94434120ea2e03889e71d4cd7bb3a1349d2d39df Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:52:34 +0530 Subject: [PATCH 09/11] Handle no results scenario --- src/components/Search/SearchAutocompleteList.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 94d91397d7bac..df5e561f9eb9e 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -422,9 +422,14 @@ function SearchAutocompleteList({ const sectionItemText = sections?.at(1)?.data?.[0]?.text ?? ''; const normalizedReferenceText = sectionItemText.toLowerCase(); const trimmedAutocompleteQueryValue = autocompleteQueryValue.trim(); + const isLoading = !isRecentSearchesDataLoaded || !areOptionsInitialized; const suggestionsAnnouncement = suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedAutocompleteQueryValue) : ''; useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, autocompleteQueryValue); + const noResultsFoundText = translate('common.noResultsFound'); + const shouldAnnounceNoResults = !isLoading && suggestionsCount === 0 && !!trimmedAutocompleteQueryValue; + useDebouncedAccessibilityAnnouncement(noResultsFoundText, shouldAnnounceNoResults, autocompleteQueryValue); + const firstRecentReportKey = styledRecentReports.at(0)?.keyForList; let firstRecentReportFlatIndex = -1; if (firstRecentReportKey) { @@ -468,8 +473,6 @@ function SearchAutocompleteList({ } }, [autocompleteQueryValue, onHighlightFirstItem, normalizedReferenceText]); - const isLoading = !isRecentSearchesDataLoaded || !areOptionsInitialized; - const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'SearchAutocompleteList', isRecentSearchesDataLoaded, From 819ad95c78ac598c4892781786edbcf59091ee87 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:12:45 +0530 Subject: [PATCH 10/11] Guard suggestions announcements until the input is visible --- src/components/SelectionList/components/TextInput.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 068ecffd86d42..b0233b62e479d 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -73,7 +73,10 @@ function TextInput({ const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); const trimmedSearchValue = value?.trim() ?? ''; const suggestionsCount = dataLength ?? 0; - const suggestionsAnnouncement = !isLoadingNewOptions && suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedSearchValue) : ''; + const suggestionsAnnouncement = + !!shouldShowTextInput && !shouldShowLoadingPlaceholder && !isLoadingNewOptions && suggestionsCount > 0 + ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedSearchValue) + : ''; useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage, value ?? ''); useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, value ?? ''); From b41e509f89fdad9dedf1c49e571dff41bfc780a9 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:43:08 +0530 Subject: [PATCH 11/11] Mount accessibility announcements inside active modal dialogs --- .../useAccessibilityAnnouncement/index.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts index bc104b6f9d467..2fa4266e67c15 100644 --- a/src/hooks/useAccessibilityAnnouncement/index.ts +++ b/src/hooks/useAccessibilityAnnouncement/index.ts @@ -24,16 +24,35 @@ const ANNOUNCEMENT_DELAY_MS = 300; let wrapper: HTMLDivElement | null = null; +function getAnnouncementRoot(): HTMLElement { + const activeElement = document.activeElement; + const activeDialog = activeElement instanceof HTMLElement ? activeElement.closest('[role="dialog"][aria-modal="true"]') : null; + + if (activeDialog) { + return activeDialog; + } + + const modalDialogs = document.querySelectorAll('[role="dialog"][aria-modal="true"]'); + return modalDialogs.item(modalDialogs.length - 1) ?? document.body; +} + function getWrapper(): HTMLDivElement { - if (wrapper && document.body.contains(wrapper)) { + const root = getAnnouncementRoot(); + + if (wrapper && root.contains(wrapper)) { return wrapper; } + if (wrapper && wrapper.parentElement && wrapper.parentElement !== root) { + wrapper.parentElement.removeChild(wrapper); + wrapper = null; + } + 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); + root.appendChild(wrapper); return wrapper; }