Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
34 changes: 33 additions & 1 deletion src/components/AddressSearch/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -96,6 +96,8 @@ function AddressSearch({
const shouldTriggerGeolocationCallbacks = useRef(true);
const [shouldHidePredefinedPlaces, setShouldHidePredefinedPlaces] = useState(false);
const containerRef = useRef<View>(null);
const [suggestionsAnnouncement, setSuggestionsAnnouncement] = useState({id: 0, text: ''});
const lastAnnouncementKeyRef = useRef('');
const query = useMemo(
() => ({
language: preferredLocale,
Expand All @@ -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) {
Expand Down Expand Up @@ -362,6 +384,16 @@ function AddressSearch({
ref={containerRef}
fsClass={forwardedFSClass}
>
{Platform.OS === 'web' && !!suggestionsAnnouncement.text && (
<Text
// Changing the key ensures the live region re-announces the same text.
key={suggestionsAnnouncement.id}
role={CONST.ROLE.STATUS}
style={styles.hiddenElementOutsideOfWindow}
>
{suggestionsAnnouncement.text}
</Text>
)}
<GooglePlacesAutocomplete
disableScroll
fetchDetails
Expand Down
41 changes: 39 additions & 2 deletions src/components/SelectionList/BaseSelectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/fl
import {deepEqual} from 'fast-equals';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import type {TextInputKeyPressEvent} from 'react-native';
import {View} from 'react-native';
import {Platform, View} from 'react-native';
import OptionsListSkeletonView from '@components/OptionsListSkeletonView';
import Text from '@components/Text';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useActiveElementRole from '@hooks/useActiveElementRole';
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';
Expand Down Expand Up @@ -93,6 +95,7 @@ function BaseSelectionList<TItem extends ListItem>({
setShouldDisableHoverStyle = () => {},
}: SelectionListProps<TItem>) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const isFocused = useIsFocused();
const scrollEnabled = useScrollEnabled();
const {singleExecution} = useSingleExecution();
Expand All @@ -102,9 +105,12 @@ function BaseSelectionList<TItem extends ListItem>({

const innerTextInputRef = useRef<BaseTextInputRef | null>(null);
const isTextInputFocusedRef = useRef<boolean>(false);
const [isTextInputFocused, setIsTextInputFocused] = useState(false);
const hasKeyBeenPressed = useRef(false);
const listRef = useRef<FlashListRef<TItem> | null>(null);
const itemFocusTimeoutRef = useRef<NodeJS.Timeout | null>(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<Set<string> | null>(null);
Expand Down Expand Up @@ -312,7 +318,10 @@ function BaseSelectionList<TItem extends ListItem>({
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}
/>
Expand Down Expand Up @@ -395,6 +404,24 @@ function BaseSelectionList<TItem extends ListItem>({

const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(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
Expand Down Expand Up @@ -507,6 +534,16 @@ function BaseSelectionList<TItem extends ListItem>({
return (
<View style={[styles.flex1, addBottomSafeAreaPadding && !hasFooter && paddingBottomStyle, style?.containerStyle]}>
{textInputComponent({shouldBeInsideList: false})}
{Platform.OS === 'web' && !!suggestionsAnnouncement.text && (
<Text
// Changing the key ensures the live region re-announces the same text.
key={suggestionsAnnouncement.id}
role={CONST.ROLE.STATUS}
style={styles.hiddenElementOutsideOfWindow}
>
{suggestionsAnnouncement.text}
</Text>
)}
{data.length === 0 && (showLoadingPlaceholder || showListEmptyContent) ? (
renderListEmptyContent()
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -70,13 +71,17 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
shouldPreventDefaultFocusOnSelectRow = false,
}: SelectionListWithSectionsProps<TItem>) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const isScreenFocused = useIsFocused();
const scrollEnabled = useScrollEnabled();
const {singleExecution} = useSingleExecution();
const listRef = useRef<FlashListRef<FlattenedItem<TItem>> | null>(null);
const innerTextInputRef = useRef<BaseTextInputRef | null>(null);
const isTextInputFocusedRef = useRef<boolean>(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();
Expand Down Expand Up @@ -184,8 +189,9 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
/**
* 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, () => ({
Expand Down Expand Up @@ -250,6 +256,25 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
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;
Expand All @@ -265,7 +290,10 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
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}
/>
Expand Down Expand Up @@ -338,6 +366,16 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
onLayout={onLayout}
>
{textInputComponent()}
{Platform.OS === 'web' && !!suggestionsAnnouncement.text && (
<Text
// Changing the key ensures the live region re-announces the same text.
key={suggestionsAnnouncement.id}
role={CONST.ROLE.STATUS}
style={styles.hiddenElementOutsideOfWindow}
>
{suggestionsAnnouncement.text}
</Text>
)}
{itemsCount === 0 && (showLoadingPlaceholder || showListEmptyContent) ? (
renderListEmptyContent()
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -189,9 +189,12 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
}, [canSelectMultiple, isItemSelected, sections]);
const [currentPage, setCurrentPage] = useState(() => calculateInitialCurrentPage());
const isTextInputFocusedRef = useRef<boolean>(false);
const [isTextInputFocused, setIsTextInputFocused] = useState(false);
const {singleExecution} = useSingleExecution();
const itemHeights = useRef<Record<string, number>>({});
const pendingScrollIndexRef = useRef<number | null>(null);
const [suggestionsAnnouncement, setSuggestionsAnnouncement] = useState({id: 0, text: ''});
const lastAnnouncementKeyRef = useRef('');

const onItemLayout = (event: LayoutChangeEvent, itemKey: string | null | undefined) => {
if (!itemKey) {
Expand Down Expand Up @@ -761,8 +764,14 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
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}
Expand Down Expand Up @@ -814,6 +823,24 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
[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) {
Expand Down Expand Up @@ -956,8 +983,9 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
*
* @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(
Expand Down Expand Up @@ -1027,6 +1055,16 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
return (
<View style={[styles.flex1, !addBottomSafeAreaPadding && paddingBottomStyle, containerStyle]}>
{shouldShowTextInput && !shouldShowTextInputAfterHeader && renderInput()}
{Platform.OS === 'web' && !!suggestionsAnnouncement.text && (
<Text
// Changing the key ensures the live region re-announces the same text.
key={suggestionsAnnouncement.id}
role={CONST.ROLE.STATUS}
style={styles.hiddenElementOutsideOfWindow}
>
{suggestionsAnnouncement.text}
</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()}
Expand Down
4 changes: 4 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading
Loading