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(); }); });