From 2f94e65c551f4e681772b1222e5375f120164fd1 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 20 Feb 2026 23:03:40 +0430 Subject: [PATCH] fix(a11y): announce availability of search suggestions in SR - Settings > Security --- src/CONST/index.ts | 2 + src/components/AddressSearch/index.tsx | 37 +++++++++++++- .../SelectionList/BaseSelectionList.tsx | 41 +++++++++++++++- .../BaseSelectionListWithSections.tsx | 48 +++++++++++++++++-- src/languages/en.ts | 4 ++ 5 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e8d303b1b8e18..f38a83891f3e5 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5756,6 +5756,8 @@ const CONST = { SPINBUTTON: 'spinbutton', /** Use for elements providing a summary of app conditions. */ SUMMARY: 'summary', + /** Use for elements providing status updates. */ + STATUS: 'status', /** Use for on/off switch elements. */ SWITCH: 'switch', /** Use for tab elements in a tab list. */ diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index ff8bf9746bfe1..32d0da51eba3d 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useMemo, useRef, useState} from 'react'; -import {Keyboard, LogBox, StyleSheet, View} from 'react-native'; +import {Keyboard, LogBox, Platform, StyleSheet, View} from 'react-native'; import type {LayoutChangeEvent} from 'react-native'; import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete'; import type {GooglePlaceData, GooglePlaceDetail} from 'react-native-google-places-autocomplete'; @@ -96,6 +96,8 @@ function AddressSearch({ const shouldTriggerGeolocationCallbacks = useRef(true); const [shouldHidePredefinedPlaces, setShouldHidePredefinedPlaces] = useState(false); const containerRef = useRef(null); + const [suggestionsAnnouncement, setSuggestionsAnnouncement] = useState({id: 0, text: ''}); + const lastAnnouncementKeyRef = useRef(''); const query = useMemo( () => ({ language: preferredLocale, @@ -106,6 +108,29 @@ function AddressSearch({ [preferredLocale, resultTypes, limitSearchesToCountry, locationBias], ); const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; + + useEffect(() => { + if (Platform.OS !== 'web' || !isFocused || !displayListViewBorder) { + lastAnnouncementKeyRef.current = ''; + return; + } + + const announcementKey = searchValue.trim(); + if (lastAnnouncementKeyRef.current === announcementKey) { + return; + } + + lastAnnouncementKeyRef.current = announcementKey; + const timeoutID = setTimeout(() => { + setSuggestionsAnnouncement((prev) => ({ + id: prev.id + 1, + text: translate('search.suggestionsAvailable'), + })); + }, 0); + + return () => clearTimeout(timeoutID); + }, [displayListViewBorder, isFocused, searchValue, translate]); + const saveLocationDetails = (autocompleteData: GooglePlaceData, details: GooglePlaceDetail | null) => { const addressComponents = details?.address_components; if (!addressComponents) { @@ -362,6 +387,16 @@ function AddressSearch({ ref={containerRef} fsClass={forwardedFSClass} > + {Platform.OS === 'web' && !!suggestionsAnnouncement.text && ( + + {suggestionsAnnouncement.text} + + )} ({ setShouldDisableHoverStyle = () => {}, }: SelectionListProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const isFocused = useIsFocused(); const scrollEnabled = useScrollEnabled(); const {singleExecution} = useSingleExecution(); @@ -102,9 +105,12 @@ function BaseSelectionList({ const innerTextInputRef = useRef(null); const isTextInputFocusedRef = useRef(false); + const [isTextInputFocused, setIsTextInputFocused] = useState(false); const hasKeyBeenPressed = useRef(false); const listRef = useRef | null>(null); const itemFocusTimeoutRef = useRef(null); + const [suggestionsAnnouncement, setSuggestionsAnnouncement] = useState({id: 0, text: ''}); + const lastAnnouncementKeyRef = useRef(''); const initialFocusedIndex = useMemo(() => data.findIndex((i) => i.keyForList === initiallyFocusedItemKey), [data, initiallyFocusedItemKey]); const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); @@ -312,7 +318,10 @@ function BaseSelectionList({ onSubmit={selectFocusedOption} dataLength={data.length} isLoading={isLoadingNewOptions} - onFocusChange={(v: boolean) => (isTextInputFocusedRef.current = v)} + onFocusChange={(v: boolean) => { + isTextInputFocusedRef.current = v; + setIsTextInputFocused(v); + }} showLoadingPlaceholder={showLoadingPlaceholder} isLoadingNewOptions={isLoadingNewOptions} /> @@ -395,6 +404,24 @@ function BaseSelectionList({ const scrollTimeoutRef = useRef(null); + useEffect(() => { + if (Platform.OS !== 'web' || !shouldShowTextInput || !isTextInputFocused || isLoadingNewOptions || data.length === 0) { + lastAnnouncementKeyRef.current = ''; + return; + } + + const announcementKey = `${textInputOptions?.value ?? ''}-${data.length}`; + if (lastAnnouncementKeyRef.current === announcementKey) { + return; + } + + lastAnnouncementKeyRef.current = announcementKey; + setSuggestionsAnnouncement((prev) => ({ + id: prev.id + 1, + text: translate('search.suggestionsAvailable', {count: data.length}), + })); + }, [data.length, isLoadingNewOptions, isTextInputFocused, shouldShowTextInput, textInputOptions?.value, translate]); + // The function scrolls to the focused input to prevent keyboard occlusion. // It ensures the entire list item is visible, not just the input field. // Added specifically for SplitExpensePage @@ -507,6 +534,16 @@ function BaseSelectionList({ return ( {textInputComponent({shouldBeInsideList: false})} + {Platform.OS === 'web' && !!suggestionsAnnouncement.text && ( + + {suggestionsAnnouncement.text} + + )} {data.length === 0 && (showLoadingPlaceholder || showListEmptyContent) ? ( renderListEmptyContent() ) : ( diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 1906b2c62d4a2..cee414d180be8 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -3,7 +3,7 @@ import lodashDebounce from 'lodash/debounce'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListData, SectionListRenderItemInfo, TextInputKeyPressEvent} from 'react-native'; -import {View} from 'react-native'; +import {Platform, View} from 'react-native'; import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; import FixedFooter from '@components/FixedFooter'; @@ -189,9 +189,12 @@ function BaseSelectionListWithSections({ }, [canSelectMultiple, isItemSelected, sections]); const [currentPage, setCurrentPage] = useState(() => calculateInitialCurrentPage()); const isTextInputFocusedRef = useRef(false); + const [isTextInputFocused, setIsTextInputFocused] = useState(false); const {singleExecution} = useSingleExecution(); const itemHeights = useRef>({}); const pendingScrollIndexRef = useRef(null); + const [suggestionsAnnouncement, setSuggestionsAnnouncement] = useState({id: 0, text: ''}); + const lastAnnouncementKeyRef = useRef(''); const onItemLayout = (event: LayoutChangeEvent, itemKey: string | null | undefined) => { if (!itemKey) { @@ -761,8 +764,14 @@ function BaseSelectionListWithSections({ textInputRef.current = element as RNTextInput; } }} - onFocus={() => (isTextInputFocusedRef.current = true)} - onBlur={() => (isTextInputFocusedRef.current = false)} + onFocus={() => { + isTextInputFocusedRef.current = true; + setIsTextInputFocused(true); + }} + onBlur={() => { + isTextInputFocusedRef.current = false; + setIsTextInputFocused(false); + }} label={textInputLabel} accessibilityLabel={textInputLabel} hint={textInputHint} @@ -814,6 +823,24 @@ function BaseSelectionListWithSections({ [onLayout, scrollToFocusedIndexOnFirstRender], ); + useEffect(() => { + if (Platform.OS !== 'web' || !shouldShowTextInput || !isTextInputFocused || isLoadingNewOptions || flattenedSections.allOptions.length === 0) { + lastAnnouncementKeyRef.current = ''; + return; + } + + const announcementKey = `${textInputValue}-${flattenedSections.allOptions.length}`; + if (lastAnnouncementKeyRef.current === announcementKey) { + return; + } + + lastAnnouncementKeyRef.current = announcementKey; + setSuggestionsAnnouncement((prev) => ({ + id: prev.id + 1, + text: translate('search.suggestionsAvailable', {count: flattenedSections.allOptions.length}), + })); + }, [flattenedSections.allOptions.length, isLoadingNewOptions, isTextInputFocused, shouldShowTextInput, textInputValue, translate]); + const updateAndScrollToFocusedIndex = useCallback( (newFocusedIndex: number, shouldSkipWhenIndexNonZero = false) => { if (shouldSkipWhenIndexNonZero && focusedIndex > 0) { @@ -956,8 +983,9 @@ function BaseSelectionListWithSections({ * * @param isTextInputFocused - Is external TextInput focused. */ - const updateExternalTextInputFocus = useCallback((isTextInputFocused: boolean) => { - isTextInputFocusedRef.current = isTextInputFocused; + const updateExternalTextInputFocus = useCallback((textInputFocused: boolean) => { + isTextInputFocusedRef.current = textInputFocused; + setIsTextInputFocused(textInputFocused); }, []); useImperativeHandle( @@ -1027,6 +1055,16 @@ function BaseSelectionListWithSections({ return ( {shouldShowTextInput && !shouldShowTextInputAfterHeader && renderInput()} + {Platform.OS === 'web' && !!suggestionsAnnouncement.text && ( + + {suggestionsAnnouncement.text} + + )} {/* 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/en.ts b/src/languages/en.ts index 97cd63ba82865..75d3650c03ad8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7182,6 +7182,10 @@ const translations = { searchIn: 'Search in', searchPlaceholder: 'Search for something', suggestions: 'Suggestions', + suggestionsAvailable: ({count}: {count?: number} = {}) => { + const countText = count ? `${count} ${count === 1 ? 'result' : 'results'}. ` : ''; + return `Suggestions available. ${countText}Use up and down arrow keys to navigate.`; + }, 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.",