diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index f2d66c6656c65..6e603bd49600a 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -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,
diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx
index b5e4b95472128..1b6ca1d1af9d7 100644
--- a/src/components/AddressSearch/index.tsx
+++ b/src/components/AddressSearch/index.tsx
@@ -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';
@@ -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 (
+
+ {noResultsFoundText}
+
+ );
+}
+
function AddressSearch({
canUseCurrentLocation = false,
containerStyles,
@@ -327,10 +345,7 @@ function AddressSearch({
return predefinedPlaces?.filter((predefinedPlace) => isPlaceMatchForSearch(searchValue, predefinedPlace)) ?? [];
}, [predefinedPlaces, searchValue, shouldHidePredefinedPlaces]);
- const listEmptyComponent = useMemo(
- () => (!isTyping ? undefined : {translate('common.noResultsFound')}),
- [isTyping, styles, translate],
- );
+ const listEmptyComponent = isTyping ? : undefined;
const listLoader = useMemo(() => {
const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'AddressSearch.listLoader'};
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx
index f6161b8f7dc60..fcad7f0c132a0 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx
+++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx
@@ -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';
@@ -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>;
};
@@ -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 {translate('common.noResultsFound')};
+ return (
+
+ {noResultsFoundText}
+
+ );
}
function BaseEmojiPickerMenu({
@@ -90,6 +104,7 @@ function BaseEmojiPickerMenu({
stickyHeaderIndices = [],
extraData = [],
alwaysBounceVertical = false,
+ searchValue,
ref,
}: BaseEmojiPickerMenuProps) {
const styles = useThemeStyles();
@@ -111,7 +126,7 @@ function BaseEmojiPickerMenu({
keyExtractor={keyExtractor}
numColumns={CONST.EMOJI_NUM_PER_ROW}
stickyHeaderIndices={stickyHeaderIndices}
- ListEmptyComponent={ListEmptyComponent}
+ ListEmptyComponent={}
alwaysBounceVertical={alwaysBounceVertical}
contentContainerStyle={styles.ph4}
extraData={extraData}
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
index 718a9ff6b5b56..c520b7d3d216d 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
@@ -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';
@@ -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);
@@ -55,10 +56,8 @@ 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 === '') {
@@ -66,7 +65,12 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
} 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) => {
@@ -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);
+ }}
submitBehavior={filteredEmojis.length > 0 ? 'blurAndSubmit' : 'submit'}
sentryLabel={CONST.SENTRY_LABEL.EMOJI_PICKER.SEARCH_INPUT}
/>
@@ -145,6 +152,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
extraData={[filteredEmojis, preferredSkinTone]}
stickyHeaderIndices={headerIndices}
alwaysBounceVertical={filteredEmojis.length !== 0}
+ searchValue={searchText}
/>
);
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx
index 7029740133bd7..bf314e91a6f00 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx
@@ -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);
@@ -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});
@@ -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) => {
@@ -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}
@@ -355,6 +365,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
renderItem={renderItem}
extraData={[focusedIndex, preferredSkinTone]}
stickyHeaderIndices={headerIndices}
+ searchValue={searchText}
/>
);
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx
index 49c49713e7312..bb4b49599c32e 100644
--- a/src/components/SearchBar.tsx
+++ b/src/components/SearchBar.tsx
@@ -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';
@@ -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 (
<>
@@ -45,9 +50,14 @@ function SearchBar({label, style, icon, inputValue, onChangeText, onSubmitEditin
shouldHideClearButton={!inputValue?.length}
/>
- {!!shouldShowEmptyState && inputValue.length !== 0 && (
+ {shouldAnnounceNoResults && (
- {translate('common.noResultsFoundMatching', inputValue)}
+
+ {noResultsMessage}
+
)}
>
diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx
index 7eacdd4016559..9ad040dc3f1be 100644
--- a/src/components/SelectionList/components/TextInput.tsx
+++ b/src/components/SelectionList/components/TextInput.tsx
@@ -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';
@@ -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(null);
const mergedRef = mergeRefs(ref, optionsRef);
@@ -145,7 +144,12 @@ function TextInput({
{shouldShowHeaderMessage && (
- {headerMessage}
+
+ {headerMessage}
+
)}
>
diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx
index 13d9469a1f17d..a5a472dff9111 100644
--- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx
+++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx
@@ -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';
@@ -1011,14 +1011,18 @@ function BaseSelectionListWithSections({
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 && (
- {headerMessage}
+
+ {headerMessage}
+
);
diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx
index a9fd8c99943d6..76587f776fc27 100644
--- a/src/components/Table/TableBody.tsx
+++ b/src/components/Table/TableBody.tsx
@@ -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';
@@ -62,9 +63,16 @@ function TableBody({contentContainerStyle, ...props}: TableBodyProps) {
const message = getEmptyMessage();
+ useDebouncedAccessibilityAnnouncement(message, isEmptyResult, activeSearchString);
+
const EmptyResultComponent = (
- {message}
+
+ {message}
+
);
diff --git a/src/hooks/useAccessibilityAnnouncement/index.ios.ts b/src/hooks/useAccessibilityAnnouncement/index.ios.ts
index 3d21af2b7b7b1..ee479d2d37ca5 100644
--- a/src/hooks/useAccessibilityAnnouncement/index.ios.ts
+++ b/src/hooks/useAccessibilityAnnouncement/index.ios.ts
@@ -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;
diff --git a/src/hooks/useAccessibilityAnnouncement/index.native.ts b/src/hooks/useAccessibilityAnnouncement/index.native.ts
index b66f9540f3abe..80f307820bb9a 100644
--- a/src/hooks/useAccessibilityAnnouncement/index.native.ts
+++ b/src/hooks/useAccessibilityAnnouncement/index.native.ts
@@ -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('');
diff --git a/src/hooks/useAccessibilityAnnouncement/index.ts b/src/hooks/useAccessibilityAnnouncement/index.ts
index c853cec379be8..bc104b6f9d467 100644
--- a/src/hooks/useAccessibilityAnnouncement/index.ts
+++ b/src/hooks/useAccessibilityAnnouncement/index.ts
@@ -1,10 +1,77 @@
import type {ReactNode} from 'react';
+import {useEffect, useRef} from 'react';
+import type UseAccessibilityAnnouncementOptions from './types';
-type UseAccessibilityAnnouncementOptions = {
- shouldAnnounceOnNative?: boolean;
+const VISUALLY_HIDDEN_STYLE: Partial = {
+ position: 'absolute',
+ width: '1px',
+ height: '1px',
+ margin: '-1px',
+ padding: '0',
+ overflow: 'hidden',
+ clip: 'rect(0, 0, 0, 0)',
+ whiteSpace: 'nowrap',
+ border: '0',
};
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-function useAccessibilityAnnouncement(_message: string | ReactNode, _shouldAnnounceMessage: boolean, _options?: UseAccessibilityAnnouncementOptions) {}
+/**
+ * VoiceOver on Mac echoes the completed word ~500-750ms after the last keystroke
+ * and takes ~300-500ms to speak it. Combined with the 1000ms debounce in
+ * useDebouncedAccessibilityAnnouncement, this delay ensures the announcement
+ * fires after VoiceOver finishes the word echo (~1300ms total from last keystroke).
+ */
+const ANNOUNCEMENT_DELAY_MS = 300;
+
+let wrapper: HTMLDivElement | null = null;
+
+function getWrapper(): HTMLDivElement {
+ if (wrapper && document.body.contains(wrapper)) {
+ return wrapper;
+ }
+
+ wrapper = document.createElement('div');
+ wrapper.setAttribute('aria-live', 'assertive');
+ wrapper.setAttribute('aria-atomic', 'true');
+ Object.assign(wrapper.style, VISUALLY_HIDDEN_STYLE);
+ document.body.appendChild(wrapper);
+
+ return wrapper;
+}
+
+function useAccessibilityAnnouncement(message: string | ReactNode, shouldAnnounceMessage: boolean, options?: UseAccessibilityAnnouncementOptions) {
+ const shouldAnnounceOnWeb = options?.shouldAnnounceOnWeb ?? false;
+ const prevShouldAnnounceRef = useRef(false);
+
+ useEffect(() => {
+ if (!shouldAnnounceMessage) {
+ prevShouldAnnounceRef.current = false;
+ return;
+ }
+
+ if (prevShouldAnnounceRef.current || !shouldAnnounceOnWeb || typeof message !== 'string' || !message.trim()) {
+ return;
+ }
+
+ prevShouldAnnounceRef.current = true;
+
+ const container = getWrapper();
+
+ while (container.firstChild) {
+ container.removeChild(container.firstChild);
+ }
+
+ const timer = setTimeout(() => {
+ const node = document.createElement('div');
+ node.setAttribute('role', 'alert');
+ node.textContent = message;
+ container.appendChild(node);
+ }, ANNOUNCEMENT_DELAY_MS);
+
+ return () => {
+ clearTimeout(timer);
+ prevShouldAnnounceRef.current = false;
+ };
+ }, [message, shouldAnnounceMessage, shouldAnnounceOnWeb]);
+}
export default useAccessibilityAnnouncement;
diff --git a/src/hooks/useAccessibilityAnnouncement/types.ts b/src/hooks/useAccessibilityAnnouncement/types.ts
new file mode 100644
index 0000000000000..fa7a8e4c1c537
--- /dev/null
+++ b/src/hooks/useAccessibilityAnnouncement/types.ts
@@ -0,0 +1,6 @@
+type UseAccessibilityAnnouncementOptions = {
+ shouldAnnounceOnNative?: boolean;
+ shouldAnnounceOnWeb?: boolean;
+};
+
+export default UseAccessibilityAnnouncementOptions;
diff --git a/src/hooks/useDebouncedAccessibilityAnnouncement.ts b/src/hooks/useDebouncedAccessibilityAnnouncement.ts
new file mode 100644
index 0000000000000..72cf6e39f34ad
--- /dev/null
+++ b/src/hooks/useDebouncedAccessibilityAnnouncement.ts
@@ -0,0 +1,20 @@
+import CONST from '@src/CONST';
+import useAccessibilityAnnouncement from './useAccessibilityAnnouncement';
+import useDebouncedValue from './useDebouncedValue';
+
+/**
+ * Encapsulates the debounced accessibility announcement pattern:
+ * waits for a typing pause before announcing a message via screen reader.
+ *
+ * On web, announcements are made via a hidden role="alert" element.
+ * On native, announcements are made programmatically via AccessibilityInfo.
+ */
+function useDebouncedAccessibilityAnnouncement(message: string, shouldAnnounce: boolean, searchValue: string) {
+ const debouncedSearchValue = useDebouncedValue(searchValue, CONST.TIMING.ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME);
+ const hasFinishedTyping = searchValue === debouncedSearchValue;
+ const shouldAnnounceNow = shouldAnnounce && hasFinishedTyping;
+
+ useAccessibilityAnnouncement(message, shouldAnnounceNow, {shouldAnnounceOnNative: true, shouldAnnounceOnWeb: true});
+}
+
+export default useDebouncedAccessibilityAnnouncement;
diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts
new file mode 100644
index 0000000000000..e6aa265935f15
--- /dev/null
+++ b/src/hooks/useDebouncedValue.ts
@@ -0,0 +1,23 @@
+import {useEffect, useState} from 'react';
+import CONST from '@src/CONST';
+
+/**
+ * Returns a debounced version of the given value. The returned value only updates
+ * after the source value has stopped changing for the specified delay.
+ *
+ * Initializes as undefined so the first debounce cycle must complete before
+ * `value === debouncedValue` can be true. This prevents false positives
+ * on mount when using the hook to detect typing pauses.
+ */
+function useDebouncedValue(value: T, delay: number = CONST.TIMING.USE_DEBOUNCED_STATE_DELAY): T {
+ const [debouncedValue, setDebouncedValue] = useState(undefined);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+
+ return debouncedValue as T;
+}
+
+export default useDebouncedValue;
diff --git a/src/pages/iou/request/ImportContactButton/index.native.tsx b/src/pages/iou/request/ImportContactButton/index.native.tsx
index d5f1cc47bc4d1..c19bdda9a6c32 100644
--- a/src/pages/iou/request/ImportContactButton/index.native.tsx
+++ b/src/pages/iou/request/ImportContactButton/index.native.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import {View} from 'react-native';
import Text from '@components/Text';
+import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import goToSettings from '@libs/goToSettings';
@@ -14,11 +15,15 @@ type ImportContactButtonProps = {
function ImportContactButton({showImportContacts, inputHelperText, isInSearch = false}: ImportContactButtonProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const noResultsFoundText = translate('common.noResultsFound');
+
+ const shouldAnnounce = !!isInSearch && !!showImportContacts && !!inputHelperText;
+ useAccessibilityAnnouncement(noResultsFoundText, shouldAnnounce, {shouldAnnounceOnNative: true});
return showImportContacts && inputHelperText ? (
- {isInSearch ? `${translate('common.noResultsFound')}. ` : null}
+ {isInSearch ? `${noResultsFoundText}. ` : null}
;
type CurrencyType = TupleToUnion;
+function WorkflowNoResultsView({message, shouldShow, searchValue}: {message: string; shouldShow: boolean; searchValue: string}) {
+ const styles = useThemeStyles();
+
+ useDebouncedAccessibilityAnnouncement(message, shouldShow, searchValue);
+
+ if (!shouldShow) {
+ return null;
+ }
+
+ return (
+
+
+ {message}
+
+
+ );
+}
+
function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
useWorkspaceDocumentTitle(policy?.name, 'workspace.common.workflows');
const {translate, localeCompare} = useLocalize();
@@ -323,11 +345,11 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
style={[styles.mt6, {marginHorizontal: 0}]}
/>
)}
- {searchFilteredWorkflows.length === 0 && workflowSearchInput.length > 0 && (
-
- {translate('common.noResultsFoundMatching', workflowSearchInput)}
-
- )}
+ 0}
+ searchValue={workflowSearchInput}
+ />
{searchFilteredWorkflows.map((workflow) => (
{
const input = screen.getByTestId('selection-list-text-input');
fireEvent.changeText(input, 'Should show no results found');
- expect(await screen.findByText('No results found')).toBeOnTheScreen();
+ expect(await screen.findByText('No results found', {includeHiddenElements: true})).toBeOnTheScreen();
});
});