Skip to content

Commit 96200e6

Browse files
Merge pull request #85296 from ShridharGoel/accessibility77390
Implement accessibility changes for search suggestions
2 parents c0b3895 + 2ef3782 commit 96200e6

14 files changed

Lines changed: 155 additions & 62 deletions

File tree

src/components/Search/SearchAutocompleteList.tsx

Lines changed: 86 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {ForwardedRef, RefObject} from 'react';
2-
import React, {useContext, useEffect, useRef, useState} from 'react';
2+
import React, {useContext, useEffect, useMemo, useRef, useState} from 'react';
33
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
44
import {OptionsListStateContext, useOptionsList} from '@components/OptionListContextProvider';
55
import OptionsListSkeletonView from '@components/OptionsListSkeletonView';
@@ -11,6 +11,7 @@ import type {Section, SelectionListWithSectionsHandle} from '@components/Selecti
1111
import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions';
1212
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
1313
import useDebounce from '@hooks/useDebounce';
14+
import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement';
1415
import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards';
1516
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
1617
import 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,

src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
293293
accessibilityLabel={textInputOptions?.label}
294294
options={textInputOptions}
295295
onSubmit={selectFocusedItem}
296-
dataLength={flattenedData.length}
296+
dataLength={itemsCount}
297297
isLoading={isLoadingNewOptions}
298298
onFocusChange={(v: boolean) => (isTextInputFocusedRef.current = v)}
299299
shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder}

src/components/SelectionList/components/TextInput.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,15 @@ function TextInput({
8686
const isNoResultsFoundMessage = headerMessage === noResultsFoundText;
8787
const noData = dataLength === 0 && !shouldShowLoadingPlaceholder;
8888
const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData);
89+
const trimmedSearchValue = value?.trim() ?? '';
90+
const suggestionsCount = dataLength ?? 0;
91+
const suggestionsAnnouncement =
92+
!!shouldShowTextInput && !shouldShowLoadingPlaceholder && !isLoadingNewOptions && suggestionsCount > 0
93+
? translate('search.suggestionsAvailable', {count: suggestionsCount}, trimmedSearchValue)
94+
: '';
8995

9096
useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage, value ?? '');
97+
useDebouncedAccessibilityAnnouncement(suggestionsAnnouncement, !!suggestionsAnnouncement, value ?? '');
9198

9299
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
93100
const mergedRef = mergeRefs<BaseTextInputRef>(ref, optionsRef);

src/hooks/useAccessibilityAnnouncement/index.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,35 @@ const ANNOUNCEMENT_DELAY_MS = 300;
2424

2525
let wrapper: HTMLDivElement | null = null;
2626

27+
function getAnnouncementRoot(): HTMLElement {
28+
const activeElement = document.activeElement;
29+
const activeDialog = activeElement instanceof HTMLElement ? activeElement.closest<HTMLElement>('[role="dialog"][aria-modal="true"]') : null;
30+
31+
if (activeDialog) {
32+
return activeDialog;
33+
}
34+
35+
const modalDialogs = document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]');
36+
return modalDialogs.item(modalDialogs.length - 1) ?? document.body;
37+
}
38+
2739
function getWrapper(): HTMLDivElement {
28-
if (wrapper && document.body.contains(wrapper)) {
40+
const root = getAnnouncementRoot();
41+
42+
if (wrapper && root.contains(wrapper)) {
2943
return wrapper;
3044
}
3145

46+
if (wrapper?.parentElement && wrapper.parentElement !== root) {
47+
wrapper.parentElement.removeChild(wrapper);
48+
wrapper = null;
49+
}
50+
3251
wrapper = document.createElement('div');
3352
wrapper.setAttribute('aria-live', 'assertive');
3453
wrapper.setAttribute('aria-atomic', 'true');
3554
Object.assign(wrapper.style, VISUALLY_HIDDEN_STYLE);
36-
document.body.appendChild(wrapper);
55+
root.appendChild(wrapper);
3756

3857
return wrapper;
3958
}

src/languages/de.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7429,6 +7429,10 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und
74297429
searchIn: 'Suchen in',
74307430
searchPlaceholder: 'Nach etwas suchen',
74317431
suggestions: 'Vorschläge',
7432+
suggestionsAvailable: ({count}: {count: number}, query = '') => ({
7433+
one: `Vorschläge verfügbar${query ? ` für ${query}` : ''}. ${count} Ergebnis.`,
7434+
other: (resultCount: number) => `Vorschläge verfügbar${query ? ` für ${query}` : ''}. ${resultCount} Ergebnisse.`,
7435+
}),
74327436
exportSearchResults: {
74337437
title: 'Export erstellen',
74347438
description: 'Wow, das sind aber viele Elemente! Wir bündeln sie, und Concierge schickt dir in Kürze eine Datei.',

src/languages/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7416,6 +7416,10 @@ const translations = {
74167416
searchIn: 'Search in',
74177417
searchPlaceholder: 'Search for something',
74187418
suggestions: 'Suggestions',
7419+
suggestionsAvailable: ({count}: {count: number}, query = '') => ({
7420+
one: `Suggestions available${query ? ` for ${query}` : ''}. ${count} result.`,
7421+
other: (resultCount: number) => `Suggestions available${query ? ` for ${query}` : ''}. ${resultCount} results.`,
7422+
}),
74197423
exportSearchResults: {
74207424
title: 'Create export',
74217425
description: "Whoa, that's a lot of items! We'll bundle them up, and Concierge will send you a file shortly.",

src/languages/es.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7304,6 +7304,10 @@ ${amount} para ${merchant} - ${date}`,
73047304
searchIn: 'Buscar en',
73057305
searchPlaceholder: 'Busca algo',
73067306
suggestions: 'Sugerencias',
7307+
suggestionsAvailable: ({count}: {count: number}, query = '') => ({
7308+
one: `Sugerencias disponibles${query ? ` para ${query}` : ''}. ${count} resultado.`,
7309+
other: (resultCount: number) => `Sugerencias disponibles${query ? ` para ${query}` : ''}. ${resultCount} resultados.`,
7310+
}),
73077311
exportSearchResults: {
73087312
title: 'Crear exportación',
73097313
description: '¡Wow, esos son muchos elementos! Los agruparemos y Concierge te enviará un archivo en breve.',

src/languages/fr.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7453,6 +7453,10 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip
74537453
searchIn: 'Rechercher dans',
74547454
searchPlaceholder: 'Rechercher quelque chose',
74557455
suggestions: 'Suggestions',
7456+
suggestionsAvailable: ({count}: {count: number}, query = '') => ({
7457+
one: `Suggestions disponibles${query ? ` pour ${query}` : ''}. ${count} résultat.`,
7458+
other: (resultCount: number) => `Suggestions disponibles${query ? ` pour ${query}` : ''}. ${resultCount} résultats.`,
7459+
}),
74567460
exportSearchResults: {
74577461
title: 'Créer l’export',
74587462
description: 'Ouah, ça fait beaucoup d’éléments ! Nous allons les regrouper et Concierge vous enverra un fichier sous peu.',

src/languages/it.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7417,6 +7417,10 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo
74177417
searchIn: 'Cerca in',
74187418
searchPlaceholder: 'Cerca qualcosa',
74197419
suggestions: 'Suggerimenti',
7420+
suggestionsAvailable: ({count}: {count: number}, query = '') => ({
7421+
one: `Suggerimenti disponibili${query ? ` per ${query}` : ''}. ${count} risultato.`,
7422+
other: (resultCount: number) => `Suggerimenti disponibili${query ? ` per ${query}` : ''}. ${resultCount} risultati.`,
7423+
}),
74207424
exportSearchResults: {
74217425
title: 'Crea esportazione',
74227426
description: 'Wow, sono davvero tanti elementi! Li raggrupperemo e Concierge ti invierà un file a breve.',

src/languages/ja.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7325,6 +7325,10 @@ ${reportName}
73257325
searchIn: '検索対象',
73267326
searchPlaceholder: '何かを検索',
73277327
suggestions: '提案',
7328+
suggestionsAvailable: ({count}: {count: number}, query = '') => ({
7329+
one: `候補があります${query ? `: ${query}` : ''}。${count}件の結果。`,
7330+
other: (resultCount: number) => `候補があります${query ? `: ${query}` : ''}。${resultCount}件の結果。`,
7331+
}),
73287332
exportSearchResults: {
73297333
title: 'エクスポートを作成',
73307334
description: 'おっと、アイテムがたくさんありますね!まとめて整理して、間もなくConciergeからファイルをお送りします。',

0 commit comments

Comments
 (0)