Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1736,6 +1736,7 @@ const CONST = {
TOOLTIP_SENSE: 1000,
COMMENT_LENGTH_DEBOUNCE_TIME: 1500,
SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300,
ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME: 1000,
SUGGESTION_DEBOUNCE_TIME: 100,
RESIZE_DEBOUNCE_TIME: 100,
UNREAD_UPDATE_DEBOUNCE_TIME: 300,
Expand Down
23 changes: 19 additions & 4 deletions src/components/AddressSearch/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import LocationErrorMessage from '@components/LocationErrorMessage';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
Expand Down Expand Up @@ -48,6 +49,23 @@ function isPlaceMatchForSearch(search: string, place: PredefinedPlace): boolean
// VirtualizedList component with a VirtualizedList-backed instead
LogBox.ignoreLogs(['VirtualizedLists should never be nested']);

function AddressSearchListEmptyComponent({searchValue}: {searchValue: string}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const noResultsFoundText = translate('common.noResultsFound');

useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue);

return (
<Text
style={[styles.textLabel, styles.colorMuted, styles.pv4, styles.ph3, styles.overflowAuto]}
aria-hidden
>
{noResultsFoundText}
</Text>
);
}

function AddressSearch({
canUseCurrentLocation = false,
containerStyles,
Expand Down Expand Up @@ -327,10 +345,7 @@ function AddressSearch({
return predefinedPlaces?.filter((predefinedPlace) => isPlaceMatchForSearch(searchValue, predefinedPlace)) ?? [];
}, [predefinedPlaces, searchValue, shouldHidePredefinedPlaces]);

const listEmptyComponent = useMemo(
() => (!isTyping ? undefined : <Text style={[styles.textLabel, styles.colorMuted, styles.pv4, styles.ph3, styles.overflowAuto]}>{translate('common.noResultsFound')}</Text>),
[isTyping, styles, translate],
);
const listEmptyComponent = isTyping ? <AddressSearchListEmptyComponent searchValue={searchValue} /> : undefined;

const listLoader = useMemo(() => {
const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'AddressSearch.listLoader'};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import CategoryShortcutBar from '@components/EmojiPicker/CategoryShortcutBar';
import EmojiSkinToneList from '@components/EmojiPicker/EmojiSkinToneList';
import Text from '@components/Text';
import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import type {EmojiPickerList, EmojiPickerListItem, HeaderIndices} from '@libs/EmojiUtils';
Expand Down Expand Up @@ -41,6 +42,9 @@ type BaseEmojiPickerMenuProps = {
/** Whether the list should always bounce vertically */
alwaysBounceVertical?: boolean;

/** The current search input value, used for accessibility re-announcements */
searchValue?: string;

/** Reference to the outer element */
ref?: ForwardedRef<FlashListRef<EmojiPickerListItem>>;
};
Expand Down Expand Up @@ -73,11 +77,21 @@ const keyExtractor = (item: EmojiPickerListItem, index: number): string => `emoj
/**
* Renders the list empty component
*/
function ListEmptyComponent() {
function ListEmptyComponent({searchValue}: {searchValue?: string}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const noResultsFoundText = translate('common.noResultsFound');

useDebouncedAccessibilityAnnouncement(noResultsFoundText, true, searchValue ?? '');

return <Text style={[styles.textLabel, styles.colorMuted]}>{translate('common.noResultsFound')}</Text>;
return (
<Text
style={[styles.textLabel, styles.colorMuted]}
aria-hidden
>
{noResultsFoundText}
</Text>
);
}

function BaseEmojiPickerMenu({
Expand All @@ -90,6 +104,7 @@ function BaseEmojiPickerMenu({
stickyHeaderIndices = [],
extraData = [],
alwaysBounceVertical = false,
searchValue,
ref,
}: BaseEmojiPickerMenuProps) {
const styles = useThemeStyles();
Expand All @@ -111,7 +126,7 @@ function BaseEmojiPickerMenu({
keyExtractor={keyExtractor}
numColumns={CONST.EMOJI_NUM_PER_ROW}
stickyHeaderIndices={stickyHeaderIndices}
ListEmptyComponent={ListEmptyComponent}
ListEmptyComponent={<ListEmptyComponent searchValue={searchValue} />}
alwaysBounceVertical={alwaysBounceVertical}
contentContainerStyle={styles.ph4}
extraData={extraData}
Expand Down
22 changes: 15 additions & 7 deletions src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {ListRenderItem} from '@shopify/flash-list';
import lodashDebounce from 'lodash/debounce';
import React, {useCallback} from 'react';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import type {Emoji} from '@assets/emojis/types';
import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem';
Expand Down Expand Up @@ -42,6 +42,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
emojiListRef,
} = useEmojiPickerMenu();
const StyleUtils = useStyleUtils();
const [searchText, setSearchText] = useState('');

const updateEmojiList = (emojiData: EmojiPickerList | Emoji[], headerData: number[] = []) => {
setFilteredEmojis(emojiData);
Expand All @@ -55,18 +56,21 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
});
};

/**
* Filter the entire list of emojis to only emojis that have the search term in their keywords
*/
const filterEmojis = lodashDebounce((searchTerm: string) => {
const filterCallbackRef = useRef<(searchTerm: string) => void>(undefined);
filterCallbackRef.current = (searchTerm: string) => {
const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm);

if (normalizedSearchTerm === '') {
updateEmojiList(allEmojis, headerRowIndices);
} else {
updateEmojiList(newFilteredEmojiList ?? [], []);
}
}, 300);
};

// Stable debounced function that delegates to the latest callback via ref,
// preventing re-renders from recreating the debounce timer.
// eslint-disable-next-line react-hooks/exhaustive-deps
const filterEmojis = useMemo(() => lodashDebounce((text: string) => filterCallbackRef.current?.(text), 300), []);

const scrollToHeader = useCallback(
(headerIndex: number) => {
Expand Down Expand Up @@ -124,7 +128,10 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
label={translate('common.search')}
accessibilityLabel={translate('common.search')}
role={CONST.ROLE.PRESENTATION}
onChangeText={filterEmojis}
onChangeText={(text: string) => {
setSearchText(text);
filterEmojis(text);
Comment on lines +131 to +133

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve native emoji-search debouncing across input changes

Updating searchText inside onChangeText forces a re-render on every keystroke, which recreates filterEmojis (a lodashDebounce defined in render scope) before its previous timer fires. That means each rapid keypress schedules independent stale callbacks, so the list can momentarily render outdated results (a then ab before abc) and do extra filtering work instead of honoring a single debounced update after typing pauses.

Useful? React with 👍 / 👎.

}}
submitBehavior={filteredEmojis.length > 0 ? 'blurAndSubmit' : 'submit'}
sentryLabel={CONST.SENTRY_LABEL.EMOJI_PICKER.SEARCH_INPUT}
/>
Expand All @@ -145,6 +152,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
extraData={[filteredEmojis, preferredSkinTone]}
stickyHeaderIndices={headerIndices}
alwaysBounceVertical={filteredEmojis.length !== 0}
searchValue={searchText}
/>
</View>
);
Expand Down
17 changes: 14 additions & 3 deletions src/components/EmojiPicker/EmojiPickerMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
// prevent auto focus when open picker for mobile device
const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();

const [searchText, setSearchText] = useState('');
const [arePointerEventsDisabled, setArePointerEventsDisabled] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false);
Expand Down Expand Up @@ -110,7 +111,8 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
allowNegativeIndexes: true,
});

const filterEmojis = throttle((searchTerm: string) => {
const filterCallbackRef = useRef<(searchTerm: string) => void>(undefined);
filterCallbackRef.current = (searchTerm: string) => {
const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm);

emojiListRef.current?.scrollToOffset({offset: 0, animated: false});
Expand All @@ -128,7 +130,12 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
setHeaderIndices([]);
setHighlightFirstEmoji(true);
setIsUsingKeyboardMovement(false);
}, throttleTime);
};

// Stable throttled function that delegates to the latest callback via ref,
// preventing re-renders from recreating the throttle timer.
// eslint-disable-next-line react-hooks/exhaustive-deps
const filterEmojis = useMemo(() => throttle((text: string) => filterCallbackRef.current?.(text), throttleTime), []);

const keyDownHandler = useCallback(
(keyBoardEvent: KeyboardEvent) => {
Expand Down Expand Up @@ -330,7 +337,10 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
label={translate('common.search')}
accessibilityLabel={translate('common.search')}
role={CONST.ROLE.PRESENTATION}
onChangeText={filterEmojis}
onChangeText={(text: string) => {
setSearchText(text);
filterEmojis(text);
}}
defaultValue=""
ref={searchInputRef}
autoFocus={shouldFocusInputOnScreenFocus}
Expand All @@ -355,6 +365,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
renderItem={renderItem}
extraData={[focusedIndex, preferredSkinTone]}
stickyHeaderIndices={headerIndices}
searchValue={searchText}
/>
</View>
);
Expand Down
14 changes: 12 additions & 2 deletions src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
Expand All @@ -25,6 +26,10 @@ function SearchBar({label, style, icon, inputValue, onChangeText, onSubmitEditin
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {translate} = useLocalize();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass']);
const noResultsMessage = translate('common.noResultsFoundMatching', inputValue);
const shouldAnnounceNoResults = !!shouldShowEmptyState && inputValue.length !== 0;

useDebouncedAccessibilityAnnouncement(noResultsMessage, shouldAnnounceNoResults, inputValue);

return (
<>
Expand All @@ -45,9 +50,14 @@ function SearchBar({label, style, icon, inputValue, onChangeText, onSubmitEditin
shouldHideClearButton={!inputValue?.length}
/>
</View>
{!!shouldShowEmptyState && inputValue.length !== 0 && (
{shouldAnnounceNoResults && (
<View style={[styles.ph5, styles.pt3, styles.pb5]}>
<Text style={[styles.textNormal, styles.colorMuted]}>{translate('common.noResultsFoundMatching', inputValue)}</Text>
<Text
style={[styles.textNormal, styles.colorMuted]}
aria-hidden
>
{noResultsMessage}
</Text>
</View>
)}
</>
Expand Down
12 changes: 8 additions & 4 deletions src/components/SelectionList/components/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {TextInputOptions} from '@components/SelectionList/types';
import Text from '@components/Text';
import BaseTextInput from '@components/TextInput';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement';
import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import mergeRefs from '@libs/mergeRefs';
Expand Down Expand Up @@ -71,9 +71,8 @@ function TextInput({
const isNoResultsFoundMessage = headerMessage === noResultsFoundText;
const noData = dataLength === 0 && !shouldShowLoadingPlaceholder;
const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData);
const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage;

useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true});
useDebouncedAccessibilityAnnouncement(headerMessage ?? '', shouldShowHeaderMessage, value ?? '');

const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const mergedRef = mergeRefs<BaseTextInputRef>(ref, optionsRef);
Expand Down Expand Up @@ -145,7 +144,12 @@ function TextInput({
</View>
{shouldShowHeaderMessage && (
<View style={[styles.ph5, styles.pb5, style?.headerMessageStyle]}>
<Text style={[styles.textLabel, styles.colorMuted, styles.minHeight5]}>{headerMessage}</Text>
<Text
style={[styles.textLabel, styles.colorMuted, styles.minHeight5]}
aria-hidden
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does aria-hidden fix anything? I don't see any changes in behavior, same as production.
On web, text is not readable.
On native, text is readable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-hidden prevents potential double announcements on web.

>
{headerMessage}
</Text>
</View>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import SectionList from '@components/SectionList';
import {getListboxRole} from '@components/SelectionList/utils/getListboxRole';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement';
import useActiveElementRole from '@hooks/useActiveElementRole';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useKeyboardState from '@hooks/useKeyboardState';
import useLocalize from '@hooks/useLocalize';
Expand Down Expand Up @@ -1011,14 +1011,18 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
const noResultsFoundText = translate('common.noResultsFound');
const isNoResultsFoundMessage = headerMessage === noResultsFoundText;
const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || (flattenedSections.allOptions.length === 0 && !shouldShowLoadingPlaceholder));
const shouldAnnounceNoResults = shouldShowHeaderMessage && isNoResultsFoundMessage;

useAccessibilityAnnouncement(headerMessage, shouldAnnounceNoResults, {shouldAnnounceOnNative: true});
useDebouncedAccessibilityAnnouncement(headerMessage, shouldShowHeaderMessage, textInputValue);

const headerMessageContent = () =>
shouldShowHeaderMessage && (
<View style={headerMessageStyle ?? [styles.ph5, styles.pb5]}>
<Text style={[styles.textLabel, styles.colorMuted, styles.minHeight5]}>{headerMessage}</Text>
<Text
style={[styles.textLabel, styles.colorMuted, styles.minHeight5]}
aria-hidden
>
{headerMessage}
</Text>
</View>
);

Expand Down
10 changes: 9 additions & 1 deletion src/components/Table/TableBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
import {View} from 'react-native';
import type {StyleProp, ViewProps, ViewStyle} from 'react-native';
import Text from '@components/Text';
import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {useTableContext} from './TableContext';
Expand Down Expand Up @@ -62,9 +63,16 @@ function TableBody<T>({contentContainerStyle, ...props}: TableBodyProps) {

const message = getEmptyMessage();

useDebouncedAccessibilityAnnouncement(message, isEmptyResult, activeSearchString);

const EmptyResultComponent = (
<View style={[styles.ph5, styles.pt3, styles.pb5]}>
<Text style={[styles.textNormal, styles.colorMuted]}>{message}</Text>
<Text
style={[styles.textNormal, styles.colorMuted]}
aria-hidden
>
{message}
</Text>
</View>
);

Expand Down
5 changes: 1 addition & 4 deletions src/hooks/useAccessibilityAnnouncement/index.ios.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type {ReactNode} from 'react';
import {useEffect, useRef} from 'react';
import {AccessibilityInfo} from 'react-native';

type UseAccessibilityAnnouncementOptions = {
shouldAnnounceOnNative?: boolean;
};
import type UseAccessibilityAnnouncementOptions from './types';

const DELAY_FOR_ACCESSIBILITY_TREE_SYNC = 100;

Expand Down
5 changes: 1 addition & 4 deletions src/hooks/useAccessibilityAnnouncement/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type {ReactNode} from 'react';
import {useEffect, useRef} from 'react';
import {AccessibilityInfo} from 'react-native';

type UseAccessibilityAnnouncementOptions = {
shouldAnnounceOnNative?: boolean;
};
import type UseAccessibilityAnnouncementOptions from './types';

function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, options?: UseAccessibilityAnnouncementOptions) {
const previousAnnouncedMessageRef = useRef('');
Expand Down
Loading
Loading