diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 2694e30f45476..e4340b8f66e01 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -1,5 +1,5 @@ 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'; @@ -11,6 +11,7 @@ import type {Section, SelectionListWithSectionsHandle} from '@components/Selecti 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'; @@ -352,73 +353,86 @@ function SearchAutocompleteList({ }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); /* Sections generation */ - const sections: Array> = []; - let sectionIndex = 0; + 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) { - sections.push({data: [searchQueryItem as AutocompleteListItem], sectionIndex: sectionIndex++}); - } + if (searchQueryItem) { + pushSection({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) { + pushSection(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) { + pushSection({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++}); - } + pushSection({ + 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, + }; + }); + + pushSection({title: translate('search.suggestions'), data: autocompleteData, sectionIndex: sectionIndex++}); + } + + 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 trimmedAutocompleteQueryValue = autocompleteQueryValue.trim(); + const isLoading = !isRecentSearchesDataLoaded || !areOptionsInitialized; + const suggestionsAnnouncement = suggestionsCount > 0 ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedAutocompleteQueryValue) : ''; + useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, autocompleteQueryValue); - const firstRecentReportKey = styledRecentReports.at(0)?.keyForList; + const noResultsFoundText = translate('common.noResultsFound'); + const shouldAnnounceNoResults = !isLoading && suggestionsCount === 0 && !!trimmedAutocompleteQueryValue; + useDebouncedAccessibilityAnnouncement(noResultsFoundText, shouldAnnounceNoResults, autocompleteQueryValue); - // 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; - } - 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. + const firstRecentReportKey = styledRecentReports.at(0)?.keyForList; + let firstRecentReportFlatIndex = -1; + if (firstRecentReportKey) { let flatIndex = 0; for (const section of sections) { const hasData = (section.data?.length ?? 0) > 0; @@ -428,13 +442,28 @@ function SearchAutocompleteList({ } for (const item of section.data ?? []) { if (item.keyForList === firstRecentReportKey) { - innerListRef.current?.updateAndScrollToFocusedIndex(flatIndex, false); - return; + firstRecentReportFlatIndex = flatIndex; + break; } flatIndex++; } + if (firstRecentReportFlatIndex !== -1) { + break; + } } - }, [areOptionsInitialized, firstRecentReportKey, sections, shouldUseNarrowLayout]); + } + + // 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; @@ -444,8 +473,6 @@ function SearchAutocompleteList({ } }, [autocompleteQueryValue, onHighlightFirstItem, normalizedReferenceText]); - const isLoading = !isRecentSearchesDataLoaded || !areOptionsInitialized; - const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'SearchAutocompleteList', isRecentSearchesDataLoaded, diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 4c46a41f50d05..29dc9f607c478 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -293,7 +293,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/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index a2498ebab3bee..2fe18cbc5b220 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -86,8 +86,15 @@ function TextInput({ const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); + const trimmedSearchValue = value?.trim() ?? ''; + const suggestionsCount = dataLength ?? 0; + const suggestionsAnnouncement = + !!shouldShowTextInput && !shouldShowLoadingPlaceholder && !isLoadingNewOptions && suggestionsCount > 0 + ? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedSearchValue) + : ''; useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage, value ?? ''); + useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, value ?? ''); const focusTimeoutRef = useRef(null); const mergedRef = mergeRefs(ref, optionsRef); diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts index bc104b6f9d467..8c240721d9f4f 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?.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; } diff --git a/src/languages/de.ts b/src/languages/de.ts index 2dab5e5b0d0da..92528a08e9335 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7417,6 +7417,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 = '') => ({ + 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 88d0ed3e1f781..e124b8048c0ac 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7404,6 +7404,10 @@ const translations = { searchIn: 'Search in', searchPlaceholder: 'Search for something', suggestions: 'Suggestions', + 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 5114871383ff3..c33045ac6738e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7292,6 +7292,10 @@ ${amount} para ${merchant} - ${date}`, searchIn: 'Buscar en', searchPlaceholder: 'Busca algo', suggestions: 'Sugerencias', + 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 f1200a74fdbd1..6711b4d1c5200 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7441,6 +7441,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 = '') => ({ + 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 37cd717669d47..878ea8d7ea37f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7405,6 +7405,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 = '') => ({ + 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 8ea363a221c86..bad18442b4e84 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7313,6 +7313,10 @@ ${reportName} searchIn: '検索対象', searchPlaceholder: '何かを検索', suggestions: '提案', + 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 4978d187e7dc0..9ed7c70ea1f26 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7371,6 +7371,10 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar searchIn: 'Zoeken in', searchPlaceholder: 'Zoek iets', suggestions: 'Suggesties', + 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 a30d0aab2915f..3522b713423ea 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7372,6 +7372,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 = '') => ({ + 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 b919fb7aac345..9c35f4562e384 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7359,6 +7359,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 = '') => ({ + 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 048c91e54335a..a56c786e9ccef 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7188,6 +7188,10 @@ ${reportName} searchIn: '搜索范围', searchPlaceholder: '搜索内容', suggestions: '建议', + suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + one: `有可用建议${query ? `:${query}` : ''}。共${count}条结果。`, + other: (resultCount: number) => `有可用建议${query ? `:${query}` : ''}。共${resultCount}条结果。`, + }), exportSearchResults: { title: '创建导出', description: '哇,项目真不少!我们会把它们打包好,Concierge 很快就会给你发送一个文件。',