11import type { ForwardedRef , RefObject } from 'react' ;
2- import React , { useContext , useEffect , useRef , useState } from 'react' ;
2+ import React , { useContext , useEffect , useMemo , useRef , useState } from 'react' ;
33import type { OnyxCollection , OnyxEntry } from 'react-native-onyx' ;
44import { OptionsListStateContext , useOptionsList } from '@components/OptionListContextProvider' ;
55import OptionsListSkeletonView from '@components/OptionsListSkeletonView' ;
@@ -11,6 +11,7 @@ import type {Section, SelectionListWithSectionsHandle} from '@components/Selecti
1111import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions' ;
1212import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' ;
1313import useDebounce from '@hooks/useDebounce' ;
14+ import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement' ;
1415import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards' ;
1516import { useMemoizedLazyExpensifyIcons } from '@hooks/useLazyAsset' ;
1617import useLocalize from '@hooks/useLocalize' ;
@@ -352,73 +353,86 @@ function SearchAutocompleteList({
352353 } , [ autocompleteQueryWithoutFilters , debounceHandleSearch ] ) ;
353354
354355 /* Sections generation */
355- const sections : Array < Section < AutocompleteListItem > > = [ ] ;
356- let sectionIndex = 0 ;
356+ const { sections, styledRecentReports, suggestionsCount} = useMemo ( ( ) => {
357+ const nextSections : Array < Section < AutocompleteListItem > > = [ ] ;
358+ let sectionIndex = 0 ;
359+ let nextSuggestionsCount = 0 ;
360+
361+ const pushSection = ( section : Section < AutocompleteListItem > ) => {
362+ nextSections . push ( section ) ;
363+ nextSuggestionsCount += section . data . filter ( ( item ) => item . keyForList !== CONST . SEARCH . SEARCH_ROUTER_ITEM_TYPE . FIND_ITEM ) . length ;
364+ } ;
357365
358- if ( searchQueryItem ) {
359- sections . push ( { data : [ searchQueryItem as AutocompleteListItem ] , sectionIndex : sectionIndex ++ } ) ;
360- }
366+ if ( searchQueryItem ) {
367+ pushSection ( { data : [ searchQueryItem as AutocompleteListItem ] , sectionIndex : sectionIndex ++ } ) ;
368+ }
361369
362- const additionalSections = getAdditionalSections ?.( searchOptions , sectionIndex ) ;
370+ const additionalSections = getAdditionalSections ?.( searchOptions , sectionIndex ) ;
363371
364- if ( additionalSections ) {
365- for ( const section of additionalSections ) {
366- sections . push ( section ) ;
367- sectionIndex ++ ;
372+ if ( additionalSections ) {
373+ for ( const section of additionalSections ) {
374+ pushSection ( section ) ;
375+ sectionIndex ++ ;
376+ }
368377 }
369- }
370378
371- if ( ! autocompleteQueryValue && recentSearchesData && recentSearchesData . length > 0 ) {
372- sections . push ( { title : translate ( 'search.recentSearches' ) , data : recentSearchesData as AutocompleteListItem [ ] , sectionIndex : sectionIndex ++ } ) ;
373- }
374- const styledRecentReports = recentReportsOptions . map ( ( option ) => {
375- const report = getReportOrDraftReport ( option . reportID ) ;
376- const reportAction = getReportAction ( report ?. parentReportID , report ?. parentReportActionID ) ;
377- const shouldParserToHTML = reportAction ?. actionName !== CONST . REPORT . ACTIONS . TYPE . ADD_COMMENT ;
378- const keyForList = option . keyForList ?? option . reportID ?? ( option . accountID ? String ( option . accountID ) : undefined ) ;
379- return {
380- ...option ,
381- keyForList,
382- pressableStyle : styles . br2 ,
383- text : StringUtils . lineBreaksToSpaces ( shouldParserToHTML ? Parser . htmlToText ( option . text ?? '' ) : ( option . text ?? '' ) ) ,
384- wrapperStyle : [ styles . pr3 , styles . pl3 ] ,
385- } as AutocompleteListItem ;
386- } ) ;
387-
388- sections . push ( { title : autocompleteQueryValue . trim ( ) === '' ? translate ( 'search.recentChats' ) : undefined , data : styledRecentReports , sectionIndex : sectionIndex ++ } ) ;
379+ if ( ! autocompleteQueryValue && recentSearchesData && recentSearchesData . length > 0 ) {
380+ pushSection ( { title : translate ( 'search.recentSearches' ) , data : recentSearchesData as AutocompleteListItem [ ] , sectionIndex : sectionIndex ++ } ) ;
381+ }
389382
390- if ( autocompleteSuggestions . length > 0 ) {
391- const autocompleteData : AutocompleteListItem [ ] = autocompleteSuggestions . map ( ( { filterKey, text, autocompleteID, mapKey} ) => {
383+ const nextStyledRecentReports = recentReportsOptions . map ( ( option ) => {
384+ const report = getReportOrDraftReport ( option . reportID ) ;
385+ const reportAction = getReportAction ( report ?. parentReportID , report ?. parentReportActionID ) ;
386+ const shouldParserToHTML = reportAction ?. actionName !== CONST . REPORT . ACTIONS . TYPE . ADD_COMMENT ;
387+ const keyForList = option . keyForList ?? option . reportID ?? ( option . accountID ? String ( option . accountID ) : undefined ) ;
392388 return {
393- text : getAutocompleteDisplayText ( filterKey , text ) ,
394- mapKey : mapKey ? getSubstitutionMapKey ( mapKey , text ) : undefined ,
395- singleIcon : expensifyIcons . MagnifyingGlass ,
396- searchQuery : text ,
397- autocompleteID,
398- keyForList : autocompleteID ?? text , // in case we have a unique identifier then use it because text might not be unique
399- searchItemType : CONST . SEARCH . SEARCH_ROUTER_ITEM_TYPE . AUTOCOMPLETE_SUGGESTION ,
400- } ;
389+ ...option ,
390+ keyForList,
391+ pressableStyle : styles . br2 ,
392+ text : StringUtils . lineBreaksToSpaces ( shouldParserToHTML ? Parser . htmlToText ( option . text ?? '' ) : ( option . text ?? '' ) ) ,
393+ wrapperStyle : [ styles . pr3 , styles . pl3 ] ,
394+ } as AutocompleteListItem ;
401395 } ) ;
402396
403- sections . push ( { title : translate ( 'search.suggestions' ) , data : autocompleteData , sectionIndex : sectionIndex ++ } ) ;
404- }
397+ pushSection ( {
398+ title : autocompleteQueryValue . trim ( ) === '' ? translate ( 'search.recentChats' ) : undefined ,
399+ data : nextStyledRecentReports ,
400+ sectionIndex : sectionIndex ++ ,
401+ } ) ;
402+
403+ if ( autocompleteSuggestions . length > 0 ) {
404+ const autocompleteData : AutocompleteListItem [ ] = autocompleteSuggestions . map ( ( { filterKey, text, autocompleteID, mapKey} ) => {
405+ return {
406+ text : getAutocompleteDisplayText ( filterKey , text ) ,
407+ mapKey : mapKey ? getSubstitutionMapKey ( mapKey , text ) : undefined ,
408+ singleIcon : expensifyIcons . MagnifyingGlass ,
409+ searchQuery : text ,
410+ autocompleteID,
411+ keyForList : autocompleteID ?? text , // in case we have a unique identifier then use it because text might not be unique
412+ searchItemType : CONST . SEARCH . SEARCH_ROUTER_ITEM_TYPE . AUTOCOMPLETE_SUGGESTION ,
413+ } ;
414+ } ) ;
415+
416+ pushSection ( { title : translate ( 'search.suggestions' ) , data : autocompleteData , sectionIndex : sectionIndex ++ } ) ;
417+ }
418+
419+ return { sections : nextSections , styledRecentReports : nextStyledRecentReports , suggestionsCount : nextSuggestionsCount } ;
420+ } , [ autocompleteQueryValue , autocompleteSuggestions , expensifyIcons , getAdditionalSections , recentReportsOptions , recentSearchesData , searchOptions , searchQueryItem , styles , translate ] ) ;
405421
406422 const sectionItemText = sections ?. at ( 1 ) ?. data ?. [ 0 ] ?. text ?? '' ;
407423 const normalizedReferenceText = sectionItemText . toLowerCase ( ) ;
424+ const trimmedAutocompleteQueryValue = autocompleteQueryValue . trim ( ) ;
425+ const isLoading = ! isRecentSearchesDataLoaded || ! areOptionsInitialized ;
426+ const suggestionsAnnouncement = suggestionsCount > 0 ? translate ( 'search.suggestionsAvailable' , { count : suggestionsCount } , trimmedAutocompleteQueryValue ) : '' ;
427+ useDebouncedAccessibilityAnnouncement ( suggestionsAnnouncement , ! ! suggestionsAnnouncement , autocompleteQueryValue ) ;
408428
409- const firstRecentReportKey = styledRecentReports . at ( 0 ) ?. keyForList ;
429+ const noResultsFoundText = translate ( 'common.noResultsFound' ) ;
430+ const shouldAnnounceNoResults = ! isLoading && suggestionsCount === 0 && ! ! trimmedAutocompleteQueryValue ;
431+ useDebouncedAccessibilityAnnouncement ( noResultsFoundText , shouldAnnounceNoResults , autocompleteQueryValue ) ;
410432
411- // When options initialize after the list is already mounted, initiallyFocusedItemKey has no effect
412- // because useState(initialFocusedIndex) in useArrowKeyFocusManager only reads the initial value.
413- // Imperatively focus the first recent report once options become available (desktop only).
414- useEffect ( ( ) => {
415- if ( shouldUseNarrowLayout || ! areOptionsInitialized || hasSetInitialFocusRef . current || ! firstRecentReportKey ) {
416- return ;
417- }
418- hasSetInitialFocusRef . current = true ;
419-
420- // Compute the flat index of firstRecentReportKey by replicating the flattening logic
421- // from useFlattenedSections: each section may prepend a header row when it has a title/customHeader.
433+ const firstRecentReportKey = styledRecentReports . at ( 0 ) ?. keyForList ;
434+ let firstRecentReportFlatIndex = - 1 ;
435+ if ( firstRecentReportKey ) {
422436 let flatIndex = 0 ;
423437 for ( const section of sections ) {
424438 const hasData = ( section . data ?. length ?? 0 ) > 0 ;
@@ -428,13 +442,28 @@ function SearchAutocompleteList({
428442 }
429443 for ( const item of section . data ?? [ ] ) {
430444 if ( item . keyForList === firstRecentReportKey ) {
431- innerListRef . current ?. updateAndScrollToFocusedIndex ( flatIndex , false ) ;
432- return ;
445+ firstRecentReportFlatIndex = flatIndex ;
446+ break ;
433447 }
434448 flatIndex ++ ;
435449 }
450+ if ( firstRecentReportFlatIndex !== - 1 ) {
451+ break ;
452+ }
436453 }
437- } , [ areOptionsInitialized , firstRecentReportKey , sections , shouldUseNarrowLayout ] ) ;
454+ }
455+
456+ // When options initialize after the list is already mounted, initiallyFocusedItemKey has no effect
457+ // because useState(initialFocusedIndex) in useArrowKeyFocusManager only reads the initial value.
458+ // Imperatively focus the first recent report once options become available (desktop only).
459+ useEffect ( ( ) => {
460+ if ( shouldUseNarrowLayout || ! areOptionsInitialized || hasSetInitialFocusRef . current || firstRecentReportFlatIndex === - 1 ) {
461+ return ;
462+ }
463+ hasSetInitialFocusRef . current = true ;
464+
465+ innerListRef . current ?. updateAndScrollToFocusedIndex ( firstRecentReportFlatIndex , false ) ;
466+ } , [ areOptionsInitialized , firstRecentReportFlatIndex , shouldUseNarrowLayout ] ) ;
438467
439468 useEffect ( ( ) => {
440469 const targetText = autocompleteQueryValue ;
@@ -444,8 +473,6 @@ function SearchAutocompleteList({
444473 }
445474 } , [ autocompleteQueryValue , onHighlightFirstItem , normalizedReferenceText ] ) ;
446475
447- const isLoading = ! isRecentSearchesDataLoaded || ! areOptionsInitialized ;
448-
449476 const reasonAttributes : SkeletonSpanReasonAttributes = {
450477 context : 'SearchAutocompleteList' ,
451478 isRecentSearchesDataLoaded,
0 commit comments