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..303fa6d15612d 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,26 @@ 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; + // eslint-disable-next-line react-hooks/set-state-in-effect + setSuggestionsAnnouncement((prev) => ({ + id: prev.id + 1, + text: translate('search.suggestionsAvailable'), + })); + }, [displayListViewBorder, isFocused, searchValue, translate]); + const saveLocationDetails = (autocompleteData: GooglePlaceData, details: GooglePlaceDetail | null) => { const addressComponents = details?.address_components; if (!addressComponents) { @@ -362,6 +384,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/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 24f1be4297332..cd406f81019a7 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -1,9 +1,9 @@ import {useIsFocused} from '@react-navigation/native'; import {FlashList} from '@shopify/flash-list'; import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; -import React, {useImperativeHandle, useRef} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; -import {View} from 'react-native'; +import {Platform, View} from 'react-native'; import type {ValueOf} from 'type-fest'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import Footer from '@components/SelectionList/components/Footer'; @@ -20,6 +20,7 @@ import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useDebounce from '@hooks/useDebounce'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useKeyboardState from '@hooks/useKeyboardState'; +import useLocalize from '@hooks/useLocalize'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useScrollEnabled from '@hooks/useScrollEnabled'; import useSingleExecution from '@hooks/useSingleExecution'; @@ -70,13 +71,17 @@ function BaseSelectionListWithSections({ shouldPreventDefaultFocusOnSelectRow = false, }: SelectionListWithSectionsProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const isScreenFocused = useIsFocused(); const scrollEnabled = useScrollEnabled(); const {singleExecution} = useSingleExecution(); const listRef = useRef> | null>(null); const innerTextInputRef = useRef(null); const isTextInputFocusedRef = useRef(false); + const [isTextInputFocused, setIsTextInputFocused] = useState(false); const hasKeyBeenPressed = useRef(false); + const [suggestionsAnnouncement, setSuggestionsAnnouncement] = useState({id: 0, text: ''}); + const lastAnnouncementKeyRef = useRef(''); const activeElementRole = useActiveElementRole(); const {isKeyboardShown} = useKeyboardState(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); @@ -184,8 +189,9 @@ function BaseSelectionListWithSections({ /** * Handles isTextInputFocusedRef value when using external TextInput, so external TextInput does not lose focus when typing in it. */ - const updateExternalTextInputFocus = (isTextInputFocused: boolean) => { - isTextInputFocusedRef.current = isTextInputFocused; + const updateExternalTextInputFocus = (isInputFocused: boolean) => { + isTextInputFocusedRef.current = isInputFocused; + setIsTextInputFocused(isInputFocused); }; useImperativeHandle(ref, () => ({ @@ -250,6 +256,25 @@ function BaseSelectionListWithSections({ firstFocusableIndex, }); + useEffect(() => { + if (Platform.OS !== 'web' || !isTextInputFocused || isLoadingNewOptions || itemsCount === 0) { + lastAnnouncementKeyRef.current = ''; + return; + } + + const announcementKey = `${textInputOptions?.value ?? ''}-${itemsCount}`; + if (lastAnnouncementKeyRef.current === announcementKey) { + return; + } + + lastAnnouncementKeyRef.current = announcementKey; + // eslint-disable-next-line react-hooks/set-state-in-effect + setSuggestionsAnnouncement((prev) => ({ + id: prev.id + 1, + text: translate('search.suggestionsAvailable', {count: itemsCount}), + })); + }, [isLoadingNewOptions, isTextInputFocused, itemsCount, textInputOptions?.value, translate]); + const textInputComponent = () => { if (!shouldShowTextInput) { return null; @@ -265,7 +290,10 @@ function BaseSelectionListWithSections({ onSubmit={selectFocusedItem} dataLength={flattenedData.length} isLoading={isLoadingNewOptions} - onFocusChange={(v: boolean) => (isTextInputFocusedRef.current = v)} + onFocusChange={(v: boolean) => { + isTextInputFocusedRef.current = v; + setIsTextInputFocused(v); + }} showLoadingPlaceholder={showLoadingPlaceholder} isLoadingNewOptions={isLoadingNewOptions} /> @@ -338,6 +366,16 @@ function BaseSelectionListWithSections({ onLayout={onLayout} > {textInputComponent()} + {Platform.OS === 'web' && !!suggestionsAnnouncement.text && ( + + {suggestionsAnnouncement.text} + + )} {itemsCount === 0 && (showLoadingPlaceholder || showListEmptyContent) ? ( renderListEmptyContent() ) : ( diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 1906b2c62d4a2..59db1a4f63106 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' || !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, 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((isInputFocused: boolean) => { + isTextInputFocusedRef.current = isInputFocused; + setIsTextInputFocused(isInputFocused); }, []); 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/de.ts b/src/languages/de.ts index 2a8b251db17b1..84e81f1511aab 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7227,6 +7227,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} = {}) => { + const countText = count ? `${count} ${count === 1 ? 'Ergebnis' : 'Ergebnisse'}. ` : ''; + return `Vorschläge verfügbar. ${countText}Verwenden Sie die Pfeiltasten nach oben und unten zum Navigieren.`; + }, 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 d5c0aeb138f6e..7e3df95f30f19 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7179,6 +7179,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.", diff --git a/src/languages/es.ts b/src/languages/es.ts index dede4ccad19e0..e415f790f22c6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7030,6 +7030,10 @@ ${amount} para ${merchant} - ${date}`, searchIn: 'Buscar en', searchPlaceholder: 'Busca algo', suggestions: 'Sugerencias', + suggestionsAvailable: ({count}: {count?: number} = {}) => { + const countText = count ? `${count} ${count === 1 ? 'resultado' : 'resultados'}. ` : ''; + return `Sugerencias disponibles. ${countText}Usa las flechas arriba y abajo para navegar.`; + }, 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 624014061be15..02037287004b1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7248,6 +7248,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} = {}) => { + const countText = count ? `${count} ${count === 1 ? 'résultat' : 'résultats'}. ` : ''; + return `Suggestions disponibles. ${countText}Utilisez les flèches haut et bas pour naviguer.`; + }, 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 16f35a7e1ba35..18dd8b6944db7 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7213,6 +7213,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} = {}) => { + const countText = count ? `${count} ${count === 1 ? 'risultato' : 'risultati'}. ` : ''; + return `Suggerimenti disponibili. ${countText}Usa le frecce su e giù per navigare.`; + }, 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 aa873fba0bb70..5826fd3c20f3b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7140,6 +7140,10 @@ ${reportName} searchIn: '検索対象', searchPlaceholder: '何かを検索', suggestions: '提案', + suggestionsAvailable: ({count}: {count?: number} = {}) => { + const countText = count ? `${count} 件の結果。` : ''; + return `候補があります。${countText}上下の矢印キーで移動できます。`; + }, exportSearchResults: { title: 'エクスポートを作成', description: 'おっと、アイテムがたくさんありますね!まとめて整理して、間もなくConciergeからファイルをお送りします。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d064fdd6e68d9..20fa514c18308 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7192,6 +7192,10 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar searchIn: 'Zoeken in', searchPlaceholder: 'Zoek iets', suggestions: 'Suggesties', + suggestionsAvailable: ({count}: {count?: number} = {}) => { + const countText = count ? `${count} ${count === 1 ? 'resultaat' : 'resultaten'}. ` : ''; + return `Suggesties beschikbaar. ${countText}Gebruik de pijltoetsen omhoog en omlaag om te navigeren.`; + }, 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 26711f3d65a54..cd8471ae36af8 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7180,6 +7180,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} = {}) => { + const countText = count ? `${count} ${count === 1 ? 'wynik' : 'wyniki'}. ` : ''; + return `Dostępne sugestie. ${countText}Użyj strzałek w górę i w dół, aby nawigować.`; + }, 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 fa25aaeefb9e5..ebc5400dd1b14 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7184,6 +7184,10 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e searchIn: 'Pesquisar em', searchPlaceholder: 'Pesquise algo', suggestions: 'Sugestões', + suggestionsAvailable: ({count}: {count?: number} = {}) => { + const countText = count ? `${count} ${count === 1 ? 'resultado' : 'resultados'}. ` : ''; + return `Sugestões disponíveis. ${countText}Use as setas para cima e para baixo para navegar.`; + }, exportSearchResults: { title: 'Criar exportação', description: 'Uau, são muitos itens! Vamos agrupá-los e a Concierge enviará um arquivo para você em breve.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d9b83ec742c8e..dba779ae440c2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7028,6 +7028,10 @@ ${reportName} searchIn: '搜索范围', searchPlaceholder: '搜索内容', suggestions: '建议', + suggestionsAvailable: ({count}: {count?: number} = {}) => { + const countText = count ? `${count} 条结果。` : ''; + return `有可用建议。${countText}使用上下方向键导航。`; + }, exportSearchResults: { title: '创建导出', description: '哇,项目真不少!我们会把它们打包好,Concierge 很快就会给你发送一个文件。',