diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 80c7e0bf78f06..19ac153e9a5c1 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -76,6 +76,8 @@ function BaseSelectionList({ shouldPreventDefaultFocusOnSelectRow = false, shouldShowTextInput = !!textInputOptions?.label, shouldHighlightSelectedItem = true, + shouldDisableHoverStyle = false, + setShouldDisableHoverStyle = () => {}, }: SelectionListProps) { const styles = useThemeStyles(); const isFocused = useIsFocused(); @@ -152,7 +154,11 @@ function BaseSelectionList({ const debouncedScrollToIndex = useDebounce(scrollToIndex, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME, {leading: true, trailing: true}); - const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + const onArrowUpDownCallback = useCallback(() => { + setShouldDisableHoverStyle(true); + }, [setShouldDisableHoverStyle]); + + const [focusedIndex, setFocusedIndex, currentHoverIndexRef] = useArrowKeyFocusManager({ initialFocusedIndex, maxIndex: data.length - 1, disabledIndexes: dataDetails.disabledArrowKeyIndexes, @@ -166,6 +172,7 @@ function BaseSelectionList({ }, ...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}), isFocused, + onArrowUpDownCallback, }); const selectRow = useCallback( @@ -287,37 +294,58 @@ function BaseSelectionList({ ); }; + const setCurrentHoverIndex = useCallback( + (hoverIndex: number | null) => { + if (shouldDisableHoverStyle) { + return; + } + currentHoverIndexRef.current = hoverIndex; + }, + [currentHoverIndexRef, shouldDisableHoverStyle], + ); + const renderItem: ListRenderItem = ({item, index}: ListRenderItemInfo) => { const isItemDisabled = isDisabled || item.isDisabled; const selected = isItemSelected(item); const isItemFocused = (!isDisabled || selected) && focusedIndex === index; return ( - 1} - alternateTextNumberOfLines={alternateNumberOfSupportedLines} - shouldIgnoreFocus={shouldIgnoreFocus} - wrapperStyle={style?.listItemWrapperStyle} - titleStyles={style?.listItemTitleStyles} - singleExecution={singleExecution} - shouldHighlightSelectedItem={shouldHighlightSelectedItem} - shouldSyncFocus={!isTextInputFocusedRef.current && hasKeyBeenPressed.current} - /> + setCurrentHoverIndex(index)} + onMouseEnter={() => setCurrentHoverIndex(index)} + onMouseLeave={(e) => { + e.stopPropagation(); + setCurrentHoverIndex(null); + }} + > + 1} + alternateTextNumberOfLines={alternateNumberOfSupportedLines} + shouldIgnoreFocus={shouldIgnoreFocus} + wrapperStyle={style?.listItemWrapperStyle} + titleStyles={style?.listItemTitleStyles} + singleExecution={singleExecution} + shouldHighlightSelectedItem={shouldHighlightSelectedItem} + shouldSyncFocus={!isTextInputFocusedRef.current && hasKeyBeenPressed.current} + shouldDisableHoverStyle={shouldDisableHoverStyle} + shouldStopMouseLeavePropagation={false} + /> + ); }; diff --git a/src/components/SelectionList/ListItem/BaseListItem.tsx b/src/components/SelectionList/ListItem/BaseListItem.tsx index dff7151d4175e..20735da37f5fc 100644 --- a/src/components/SelectionList/ListItem/BaseListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseListItem.tsx @@ -2,6 +2,7 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; +// eslint-disable-next-line no-restricted-imports import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; @@ -42,6 +43,8 @@ function BaseListItem({ testID, shouldUseDefaultRightHandSideCheckmark = true, shouldHighlightSelectedItem = true, + shouldDisableHoverStyle, + shouldStopMouseLeavePropagation = true, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -55,7 +58,9 @@ function BaseListItem({ useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); const handleMouseLeave = (e: React.MouseEvent) => { bind.onMouseLeave(); - e.stopPropagation(); + if (shouldStopMouseLeavePropagation) { + e.stopPropagation(); + } setMouseUp(); }; @@ -103,7 +108,7 @@ function BaseListItem({ isNested hoverDimmingValue={1} pressDimmingValue={item.isInteractive === false ? 1 : variables.pressDimValue} - hoverStyle={[!item.isDisabled && item.isInteractive !== false && styles.hoveredComponentBG, hoverStyle]} + hoverStyle={!shouldDisableHoverStyle ? [!item.isDisabled && item.isInteractive !== false && styles.hoveredComponentBG, hoverStyle] : undefined} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: shouldShowBlueBorderOnFocus}} onMouseDown={(e) => e.preventDefault()} id={keyForList ?? ''} diff --git a/src/components/SelectionList/ListItem/ListItemRenderer.tsx b/src/components/SelectionList/ListItem/ListItemRenderer.tsx index 45aa83229cf69..432b56a65eaf8 100644 --- a/src/components/SelectionList/ListItem/ListItemRenderer.tsx +++ b/src/components/SelectionList/ListItem/ListItemRenderer.tsx @@ -47,6 +47,8 @@ function ListItemRenderer({ titleContainerStyles, shouldUseDefaultRightHandSideCheckmark, shouldHighlightSelectedItem, + shouldDisableHoverStyle, + shouldStopMouseLeavePropagation, }: ListItemRendererProps) { const handleOnCheckboxPress = () => { if (isTransactionGroupListItemType(item)) { @@ -98,6 +100,8 @@ function ListItemRenderer({ titleContainerStyles={titleContainerStyles} shouldUseDefaultRightHandSideCheckmark={shouldUseDefaultRightHandSideCheckmark} shouldHighlightSelectedItem={shouldHighlightSelectedItem} + shouldDisableHoverStyle={shouldDisableHoverStyle} + shouldStopMouseLeavePropagation={shouldStopMouseLeavePropagation} /> {item.footerContent && item.footerContent} diff --git a/src/components/SelectionList/ListItem/RadioListItem.tsx b/src/components/SelectionList/ListItem/RadioListItem.tsx index 91f4433730628..063f3be7251fa 100644 --- a/src/components/SelectionList/ListItem/RadioListItem.tsx +++ b/src/components/SelectionList/ListItem/RadioListItem.tsx @@ -24,6 +24,8 @@ function RadioListItem({ wrapperStyle, titleStyles, shouldHighlightSelectedItem = true, + shouldDisableHoverStyle, + shouldStopMouseLeavePropagation, }: RadioListItemProps) { const styles = useThemeStyles(); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; @@ -47,6 +49,8 @@ function RadioListItem({ shouldSyncFocus={shouldSyncFocus} pendingAction={item.pendingAction} shouldHighlightSelectedItem={shouldHighlightSelectedItem} + shouldDisableHoverStyle={shouldDisableHoverStyle} + shouldStopMouseLeavePropagation={shouldStopMouseLeavePropagation} > <> {!!item.leftElement && item.leftElement} diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index 53fc743f14251..2fbb2ed10f1ac 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -236,6 +236,12 @@ type ListItemProps = CommonListItemProps & { /** Whether to highlight the selected item */ shouldHighlightSelectedItem?: boolean; + + /** Whether to disable the hover style of the item */ + shouldDisableHoverStyle?: boolean; + + /** Whether to call stopPropagation on the mouseleave event in BaseListItem */ + shouldStopMouseLeavePropagation?: boolean; }; type ValidListItem = @@ -268,6 +274,12 @@ type BaseListItemProps = CommonListItemProps & { shouldShowRightCaret?: boolean; /** Whether to highlight the selected item */ shouldHighlightSelectedItem?: boolean; + + /** Whether to disable the hover style of the item */ + shouldDisableHoverStyle?: boolean; + + /** Whether to call stopPropagation on the mouseleave event in BaseListItem */ + shouldStopMouseLeavePropagation?: boolean; }; type RadioListItemProps = ListItemProps; diff --git a/src/components/SelectionList/index.tsx b/src/components/SelectionList/index.tsx index 61163eabeb74f..5724cab99f9a6 100644 --- a/src/components/SelectionList/index.tsx +++ b/src/components/SelectionList/index.tsx @@ -9,6 +9,7 @@ import type {SelectionListProps} from './types'; function SelectionList({ref, ...props}: SelectionListProps) { const [isScreenTouched, setIsScreenTouched] = useState(false); const [shouldDebounceScrolling, setShouldDebounceScrolling] = useState(false); + const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); const touchStart = () => setIsScreenTouched(true); const touchEnd = () => setIsScreenTouched(false); @@ -52,6 +53,35 @@ function SelectionList({ref, ...props}: SelectionListPro }; }, []); + useEffect(() => { + if (canUseTouchScreen()) { + return; + } + + let lastClientX = 0; + let lastClientY = 0; + const mouseMoveHandler = (event: MouseEvent) => { + // On Safari, scrolling can also trigger a mousemove event, + // so this comparison is needed to filter out cases where the mouse hasn't actually moved. + if (event.clientX === lastClientX && event.clientY === lastClientY) { + return; + } + + lastClientX = event.clientX; + lastClientY = event.clientY; + + setShouldDisableHoverStyle(false); + }; + const wheelHandler = () => setShouldDisableHoverStyle(false); + + document.addEventListener('mousemove', mouseMoveHandler, {passive: true}); + document.addEventListener('wheel', wheelHandler, {passive: true}); + return () => { + document.removeEventListener('mousemove', mouseMoveHandler); + document.removeEventListener('wheel', wheelHandler); + }; + }, []); + return ( ({ref, ...props}: SelectionListPro // For example, a long press will trigger a focus event on mobile chrome. shouldIgnoreFocus={isMobileChrome() && isScreenTouched} shouldDebounceScrolling={shouldDebounceScrolling} + shouldDisableHoverStyle={shouldDisableHoverStyle} + setShouldDisableHoverStyle={setShouldDisableHoverStyle} /> ); } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index fddf503cdf2a6..307ab5c30d674 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -143,6 +143,10 @@ type SelectionListProps = { /** Whether to highlight the selected item */ shouldHighlightSelectedItem?: boolean; + + /** Whether hover style should be disabled */ + shouldDisableHoverStyle?: boolean; + setShouldDisableHoverStyle?: React.Dispatch>; }; type TextInputOptions = { diff --git a/src/components/SelectionListWithSections/BaseListItem.tsx b/src/components/SelectionListWithSections/BaseListItem.tsx index 1a9a15e1ce54a..d90ed080f7c72 100644 --- a/src/components/SelectionListWithSections/BaseListItem.tsx +++ b/src/components/SelectionListWithSections/BaseListItem.tsx @@ -2,6 +2,7 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; +// eslint-disable-next-line no-restricted-imports import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; @@ -44,6 +45,8 @@ function BaseListItem({ forwardedFSClass, shouldShowRightCaret = false, shouldHighlightSelectedItem = true, + shouldDisableHoverStyle, + shouldStopMouseLeavePropagation = true, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -57,7 +60,9 @@ function BaseListItem({ useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); const handleMouseLeave = (e: React.MouseEvent) => { bind.onMouseLeave(); - e.stopPropagation(); + if (shouldStopMouseLeavePropagation) { + e.stopPropagation(); + } setMouseUp(); }; @@ -105,7 +110,7 @@ function BaseListItem({ isNested hoverDimmingValue={1} pressDimmingValue={item.isInteractive === false ? 1 : variables.pressDimValue} - hoverStyle={[!item.isDisabled && item.isInteractive !== false && styles.hoveredComponentBG, hoverStyle]} + hoverStyle={!shouldDisableHoverStyle ? [!item.isDisabled && item.isInteractive !== false && styles.hoveredComponentBG, hoverStyle] : undefined} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: shouldShowBlueBorderOnFocus}} onMouseDown={(e) => e.preventDefault()} id={keyForList ?? ''} diff --git a/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx b/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx index dd30c5fed2d93..715607e618ec7 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx @@ -59,6 +59,8 @@ function BaseSelectionListItemRenderer({ userBillingFundID, shouldShowRightCaret, shouldHighlightSelectedItem = true, + shouldDisableHoverStyle, + shouldStopMouseLeavePropagation, }: BaseSelectionListItemRendererProps) { const handleOnCheckboxPress = () => { if (isTransactionGroupListItemType(item)) { @@ -118,6 +120,8 @@ function BaseSelectionListItemRenderer({ shouldShowRightCaret={shouldShowRightCaret} shouldHighlightSelectedItem={shouldHighlightSelectedItem} sectionIndex={sectionIndex} + shouldDisableHoverStyle={shouldDisableHoverStyle} + shouldStopMouseLeavePropagation={shouldStopMouseLeavePropagation} /> {item.footerContent && item.footerContent} diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 076f08c37ab2f..8aaa75da1cdc7 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -143,6 +143,8 @@ function BaseSelectionListWithSections({ renderScrollComponent, shouldShowRightCaret, shouldHighlightSelectedItem = true, + shouldDisableHoverStyle = false, + setShouldDisableHoverStyle = () => {}, ref, }: SelectionListProps) { const styles = useThemeStyles(); @@ -419,8 +421,12 @@ function BaseSelectionListWithSections({ hasKeyBeenPressed.current = true; }, []); + const onArrowUpDownCallback = useCallback(() => { + setShouldDisableHoverStyle(true); + }, [setShouldDisableHoverStyle]); + // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member - const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + const [focusedIndex, setFocusedIndex, currentHoverIndexRef] = useArrowKeyFocusManager({ initialFocusedIndex: flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey), maxIndex: Math.min(flattenedSections.allOptions.length - 1, CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage - 1), disabledIndexes: disabledArrowKeyIndexes, @@ -436,6 +442,7 @@ function BaseSelectionListWithSections({ }, ...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}), isFocused, + onArrowUpDownCallback, }); useEffect(() => { @@ -636,6 +643,16 @@ function BaseSelectionListWithSections({ ); + const setCurrentHoverIndex = useCallback( + (hoverIndex: number | null) => { + if (shouldDisableHoverStyle) { + return; + } + currentHoverIndexRef.current = hoverIndex; + }, + [currentHoverIndexRef, shouldDisableHoverStyle], + ); + const renderItem = ({item, index, section}: SectionListRenderItemInfo>) => { const normalizedIndex = index + (section?.indexOffset ?? 0); const isDisabled = !!section.isDisabled || item.isDisabled; @@ -644,7 +661,15 @@ function BaseSelectionListWithSections({ const isItemHighlighted = !!itemsToHighlight?.has(item.keyForList ?? ''); return ( - onItemLayout(event, item?.keyForList)}> + onItemLayout(event, item?.keyForList)} + onMouseMove={() => setCurrentHoverIndex(normalizedIndex)} + onMouseEnter={() => setCurrentHoverIndex(normalizedIndex)} + onMouseLeave={(e) => { + e.stopPropagation(); + setCurrentHoverIndex(null); + }} + > ({ titleContainerStyles={listItemTitleContainerStyles} canShowProductTrainingTooltip={canShowProductTrainingTooltipMemo} shouldShowRightCaret={shouldShowRightCaret} + shouldDisableHoverStyle={shouldDisableHoverStyle} + shouldStopMouseLeavePropagation={false} /> ); diff --git a/src/components/SelectionListWithSections/RadioListItem.tsx b/src/components/SelectionListWithSections/RadioListItem.tsx index 91f4433730628..063f3be7251fa 100644 --- a/src/components/SelectionListWithSections/RadioListItem.tsx +++ b/src/components/SelectionListWithSections/RadioListItem.tsx @@ -24,6 +24,8 @@ function RadioListItem({ wrapperStyle, titleStyles, shouldHighlightSelectedItem = true, + shouldDisableHoverStyle, + shouldStopMouseLeavePropagation, }: RadioListItemProps) { const styles = useThemeStyles(); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; @@ -47,6 +49,8 @@ function RadioListItem({ shouldSyncFocus={shouldSyncFocus} pendingAction={item.pendingAction} shouldHighlightSelectedItem={shouldHighlightSelectedItem} + shouldDisableHoverStyle={shouldDisableHoverStyle} + shouldStopMouseLeavePropagation={shouldStopMouseLeavePropagation} > <> {!!item.leftElement && item.leftElement} diff --git a/src/components/SelectionListWithSections/index.tsx b/src/components/SelectionListWithSections/index.tsx index c50bbaf076b2a..cabc649e05417 100644 --- a/src/components/SelectionListWithSections/index.tsx +++ b/src/components/SelectionListWithSections/index.tsx @@ -8,6 +8,7 @@ import type {ListItem, SelectionListProps} from './types'; function SelectionListWithSections({onScroll, shouldHideKeyboardOnScroll = true, ref, ...props}: SelectionListProps) { const [isScreenTouched, setIsScreenTouched] = useState(false); + const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); const touchStart = () => setIsScreenTouched(true); const touchEnd = () => setIsScreenTouched(false); @@ -64,6 +65,35 @@ function SelectionListWithSections({onScroll, shouldHide Keyboard.dismiss(); }; + useEffect(() => { + if (canUseTouchScreen()) { + return; + } + + let lastClientX = 0; + let lastClientY = 0; + const mouseMoveHandler = (event: MouseEvent) => { + // On Safari, scrolling can also trigger a mousemove event, + // so this comparison is needed to filter out cases where the mouse hasn't actually moved. + if (event.clientX === lastClientX && event.clientY === lastClientY) { + return; + } + + lastClientX = event.clientX; + lastClientY = event.clientY; + + setShouldDisableHoverStyle(false); + }; + const wheelHandler = () => setShouldDisableHoverStyle(false); + + document.addEventListener('mousemove', mouseMoveHandler, {passive: true}); + document.addEventListener('wheel', wheelHandler, {passive: true}); + return () => { + document.removeEventListener('mousemove', mouseMoveHandler); + document.removeEventListener('wheel', wheelHandler); + }; + }, []); + return ( ({onScroll, shouldHide // For example, a long press will trigger a focus event on mobile chrome. shouldIgnoreFocus={isMobileChrome() && isScreenTouched} shouldDebounceScrolling={shouldDebounceScrolling} + shouldDisableHoverStyle={shouldDisableHoverStyle} + setShouldDisableHoverStyle={setShouldDisableHoverStyle} /> ); } diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index e946888e7d553..1c78136807a2a 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -113,6 +113,12 @@ type CommonListItemProps = { /** Whether to highlight the selected item */ shouldHighlightSelectedItem?: boolean; + + /** Whether to disable the hover style of the item */ + shouldDisableHoverStyle?: boolean; + + /** Whether to call stopPropagation on the mouseleave event in BaseListItem */ + shouldStopMouseLeavePropagation?: boolean; } & TRightHandSideComponent; type ListItemFocusEventHandler = (event: NativeSyntheticEvent) => void; @@ -973,6 +979,10 @@ type SelectionListProps = Partial & { /** Whether to highlight the selected item */ shouldHighlightSelectedItem?: boolean; + + /** Whether hover style should be disabled */ + shouldDisableHoverStyle?: boolean; + setShouldDisableHoverStyle?: React.Dispatch>; } & TRightHandSideComponent; type SelectionListHandle = { diff --git a/src/hooks/useArrowKeyFocusManager.ts b/src/hooks/useArrowKeyFocusManager.ts index e56baa6249c15..74877438f0a90 100644 --- a/src/hooks/useArrowKeyFocusManager.ts +++ b/src/hooks/useArrowKeyFocusManager.ts @@ -1,4 +1,5 @@ -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {RefObject} from 'react'; import CONST from '@src/CONST'; import useKeyboardShortcut from './useKeyboardShortcut'; import usePrevious from './usePrevious'; @@ -16,9 +17,10 @@ type Config = { allowNegativeIndexes?: boolean; isFocused?: boolean; setHasKeyBeenPressed?: () => void; + onArrowUpDownCallback?: () => void; }; -type UseArrowKeyFocusManager = [number, (index: number) => void]; +type UseArrowKeyFocusManager = [number, (index: number) => void, RefObject]; /** * A hook that makes it easy to use the arrow keys to manage focus of items in a list @@ -52,9 +54,11 @@ export default function useArrowKeyFocusManager({ allowNegativeIndexes = false, isFocused = true, setHasKeyBeenPressed, + onArrowUpDownCallback = () => {}, }: Config): UseArrowKeyFocusManager { const [focusedIndex, setFocusedIndex] = useState(initialFocusedIndex); const prevIsFocusedIndex = usePrevious(focusedIndex); + const currentHoverIndexRef = useRef(null); const arrowConfig = useMemo( () => ({ excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], @@ -85,7 +89,8 @@ export default function useArrowKeyFocusManager({ } const nextIndex = disableCyclicTraversal ? -1 : maxIndex; setHasKeyBeenPressed?.(); - setFocusedIndex((actualIndex) => { + setFocusedIndex((actualIndexParam) => { + const actualIndex = currentHoverIndexRef.current ?? actualIndexParam; const currentFocusedIndex = actualIndex > 0 ? actualIndex - (itemsPerRow ?? 1) : nextIndex; let newFocusedIndex = currentFocusedIndex; @@ -107,7 +112,10 @@ export default function useArrowKeyFocusManager({ } return newFocusedIndex; }); - }, [maxIndex, isFocused, disableCyclicTraversal, itemsPerRow, disabledIndexes, allowNegativeIndexes, setHasKeyBeenPressed]); + + currentHoverIndexRef.current = null; + onArrowUpDownCallback(); + }, [maxIndex, isFocused, disableCyclicTraversal, itemsPerRow, disabledIndexes, allowNegativeIndexes, setHasKeyBeenPressed, onArrowUpDownCallback]); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_UP, arrowUpCallback, arrowConfig); @@ -119,8 +127,9 @@ export default function useArrowKeyFocusManager({ const nextIndex = disableCyclicTraversal ? maxIndex : 0; - setFocusedIndex((actualIndex) => { + setFocusedIndex((actualIndexParam) => { let currentFocusedIndex = -1; + const actualIndex = currentHoverIndexRef.current ?? actualIndexParam; if (actualIndex === -1) { currentFocusedIndex = 0; @@ -153,7 +162,10 @@ export default function useArrowKeyFocusManager({ } return newFocusedIndex; }); - }, [disableCyclicTraversal, disabledIndexes, isFocused, itemsPerRow, maxIndex, setHasKeyBeenPressed]); + + currentHoverIndexRef.current = null; + onArrowUpDownCallback(); + }, [disableCyclicTraversal, disabledIndexes, isFocused, itemsPerRow, maxIndex, setHasKeyBeenPressed, onArrowUpDownCallback]); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN, arrowDownCallback, arrowConfig); @@ -210,5 +222,5 @@ export default function useArrowKeyFocusManager({ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, horizontalArrowConfig); // Note: you don't need to manually manage focusedIndex in the parent. setFocusedIndex is only exposed in case you want to reset focusedIndex or focus a specific item - return [focusedIndex, setFocusedIndex]; + return [focusedIndex, setFocusedIndex, currentHoverIndexRef]; } diff --git a/tests/unit/BaseSelectionListSectionsTest.tsx b/tests/unit/BaseSelectionListSectionsTest.tsx new file mode 100644 index 0000000000000..a80d60fb531b6 --- /dev/null +++ b/tests/unit/BaseSelectionListSectionsTest.tsx @@ -0,0 +1,324 @@ +import * as NativeNavigation from '@react-navigation/native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import {useState} from 'react'; +import {SectionList} from 'react-native'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import BaseSelectionList from '@components/SelectionListWithSections/BaseSelectionListWithSections'; +import RadioListItem from '@components/SelectionListWithSections/RadioListItem'; +import type {ListItem, SelectionListProps} from '@components/SelectionListWithSections/types'; +import type Navigation from '@libs/Navigation/Navigation'; +import colors from '@styles/theme/colors'; +import CONST from '@src/CONST'; + +type BaseSelectionListSections = { + sections: SelectionListProps['sections']; + canSelectMultiple?: boolean; + initialNumToRender?: number; + searchText?: string; + setSearchText?: (searchText: string) => void; +}; + +const mockSections = Array.from({length: 10}, (_, index) => ({ + text: `Item ${index}`, + keyForList: `${index}`, + isSelected: index === 1, +})); + +const largeMockSections = Array.from({length: 100}, (_, index) => ({ + text: `Item ${index}`, + keyForList: `${index}`, + isSelected: index === 1, +})); + +const largeMockSectionsWithSelectedItemFromSecondPage = Array.from({length: 100}, (_, index) => ({ + text: `Item ${index}`, + keyForList: `${index}`, + isSelected: index === 70, +})); + +jest.mock('@src/components/ConfirmedRoute.tsx'); +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: jest.fn(), + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string) => key), + numberFormat: jest.fn((num: number) => num.toString()), + })), +); + +let arrowUpCallback = () => {}; +let arrowDownCallback = () => {}; +jest.mock('@hooks/useKeyboardShortcut', () => (key: {shortcutKey: string}, callback: () => void) => { + if (key.shortcutKey === 'ArrowUp') { + arrowUpCallback = callback; + } else if (key.shortcutKey === 'ArrowDown') { + arrowDownCallback = callback; + } +}); + +describe('BaseSelectionList', () => { + const onSelectRowMock = jest.fn(); + + function BaseListItemRenderer(props: BaseSelectionListSections) { + const {sections, canSelectMultiple, initialNumToRender, setSearchText, searchText} = props; + const focusedKey = sections[0].data.find((item) => item.isSelected)?.keyForList; + return ( + + + + ); + } + + it('should not trigger item press if screen is not focused', () => { + (NativeNavigation.useIsFocused as jest.Mock).mockReturnValue(false); + render(); + fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`)); + expect(onSelectRowMock).toHaveBeenCalledTimes(0); + }); + + it('should handle item press correctly', () => { + (NativeNavigation.useIsFocused as jest.Mock).mockReturnValue(true); + render(); + + fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`)); + expect(onSelectRowMock).toHaveBeenCalledWith({ + ...mockSections.at(1), + shouldAnimateInHighlight: false, + }); + }); + + it('should update focused item when sections are updated from BE', () => { + (NativeNavigation.useIsFocused as jest.Mock).mockReturnValue(true); + const updatedMockSections = mockSections.map((section) => ({ + ...section, + isSelected: section.keyForList === '2', + })); + const {rerender} = render(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`)).toBeSelected(); + rerender(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}2`)).toBeSelected(); + }); + + it('should scroll to top when selecting a multi option list', () => { + const spy = jest.spyOn(SectionList.prototype, 'scrollToLocation'); + render( + , + ); + fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({itemIndex: 0})); + }); + + it('should show only elements from first page when items exceed page limit', () => { + render( + , + ); + + // Should render first page (items 0-49, so 50 items total) + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}49`)).toBeTruthy(); + + // Should NOT render items from second page + expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}50`)).toBeFalsy(); + expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}99`)).toBeFalsy(); + }); + + it('should render all items when they fit within initial render limit', () => { + render( + , + ); + + // Should render all 10 items since they fit within the initialNumToRender limit + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}9`)).toBeTruthy(); + }); + + it('should load more items when scrolled to end', () => { + render( + , + ); + + // Should initially show first page items (0-48, 49 items total) + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}48`)).toBeTruthy(); + + // Items beyond first page should not be initially visible + expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}49`)).toBeFalsy(); + + // Note: Scroll-based loading in test environment might not work as expected + // This test verifies the initial state - actual scroll behavior would need integration testing + }); + + it('should search for first item then scroll back to preselected item when search is cleared', () => { + function SearchableListWrapper() { + const [searchText, setSearchText] = useState(''); + + // Filter sections based on search text + const filteredSections = searchText + ? largeMockSectionsWithSelectedItemFromSecondPage.filter((item) => item.text.toLowerCase().includes(searchText.toLowerCase())) + : largeMockSectionsWithSelectedItemFromSecondPage; + + return ( + + ); + } + + render(); + + // Initially should show item 70 as selected and visible + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeSelected(); + + // Search for "Item 0" + fireEvent.changeText(screen.getByTestId('selection-list-text-input'), 'Item 0'); + + // Should show only the first item (Item 0) in search results + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); + expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeFalsy(); + expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`)).toBeFalsy(); + + // Clear the search text + fireEvent.changeText(screen.getByTestId('selection-list-text-input'), ''); + + // Should show the preselected item 70 + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeSelected(); + }); + + it('does not reset page when only selectedOptions changes', () => { + const {rerender} = render( + , + ); + + // Should show first page items + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}48`)).toBeTruthy(); + + // Rerender with only selection change + rerender( + ({...item, isSelected: index === 3}))}]} + canSelectMultiple={false} + initialNumToRender={50} + />, + ); + + // Should still show the same items (no pagination reset) + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}48`)).toBeTruthy(); + // Item 3 should now be selected + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`)).toBeSelected(); + }); + + it('should reset current page when text input changes', () => { + const {rerender} = render( + , + ); + + // Should show first page items initially + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}48`)).toBeTruthy(); + + // Rerender with search text - should still show items (filtered or not) + rerender( + ({...item, isSelected: index === 3}))}]} + canSelectMultiple={false} + searchText="Item" + initialNumToRender={50} + />, + ); + + // Search functionality should work - items should still be visible + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`)).toBeTruthy(); + }); + + it('should focus next/previous item relative to hovered item when arrow keys are pressed', async () => { + render( + , + ); + + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`)).toHaveStyle({backgroundColor: colors.productDark400}); + + // Trigger a mouse move event to hover the item + fireEvent(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}10`), 'mouseMove', {stopPropagation: () => {}}); + + // eslint-disable-next-line testing-library/no-unnecessary-act + act(() => { + arrowDownCallback(); + }); + + // The item that gets focused will be the one following the hovered item + await waitFor(() => { + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}11`)).toHaveStyle({backgroundColor: colors.productDark300}); + }); + + act(() => { + arrowUpCallback(); + arrowUpCallback(); + }); + + await waitFor(() => { + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}9`)).toHaveStyle({backgroundColor: colors.productDark300}); + }); + + act(() => { + arrowDownCallback(); + }); + + await waitFor(() => { + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}10`)).toHaveStyle({backgroundColor: colors.productDark300}); + }); + }); +}); diff --git a/tests/unit/BaseSelectionListTest.tsx b/tests/unit/BaseSelectionListTest.tsx index 8ca2994bb3941..f4e5ec51fcbd4 100644 --- a/tests/unit/BaseSelectionListTest.tsx +++ b/tests/unit/BaseSelectionListTest.tsx @@ -1,38 +1,22 @@ -import * as NativeNavigation from '@react-navigation/native'; -import {fireEvent, render, screen} from '@testing-library/react-native'; -import {useState} from 'react'; -import {SectionList} from 'react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import BaseSelectionList from '@components/SelectionListWithSections/BaseSelectionListWithSections'; -import RadioListItem from '@components/SelectionListWithSections/RadioListItem'; -import type {ListItem, SelectionListProps} from '@components/SelectionListWithSections/types'; +import BaseSelectionList from '@components/SelectionList/BaseSelectionList'; +import type BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; +import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import type {ListItem} from '@components/SelectionList/types'; import type Navigation from '@libs/Navigation/Navigation'; +import colors from '@styles/theme/colors'; import CONST from '@src/CONST'; type BaseSelectionListSections = { - sections: SelectionListProps['sections']; + data: TItem[]; canSelectMultiple?: boolean; - initialNumToRender?: number; - searchText?: string; - setSearchText?: (searchText: string) => void; }; const mockSections = Array.from({length: 10}, (_, index) => ({ text: `Item ${index}`, keyForList: `${index}`, - isSelected: index === 1, -})); - -const largeMockSections = Array.from({length: 100}, (_, index) => ({ - text: `Item ${index}`, - keyForList: `${index}`, - isSelected: index === 1, -})); - -const largeMockSectionsWithSelectedItemFromSecondPage = Array.from({length: 100}, (_, index) => ({ - text: `Item ${index}`, - keyForList: `${index}`, - isSelected: index === 70, + isSelected: index === 0, })); jest.mock('@src/components/ConfirmedRoute.tsx'); @@ -52,220 +36,117 @@ jest.mock('@hooks/useLocalize', () => })), ); +let arrowUpCallback = () => {}; +let arrowDownCallback = () => {}; +jest.mock('@hooks/useKeyboardShortcut', () => (key: {shortcutKey: string}, callback: () => void) => { + if (key.shortcutKey === 'ArrowUp') { + arrowUpCallback = callback; + } else if (key.shortcutKey === 'ArrowDown') { + arrowDownCallback = callback; + } +}); + +let mockShouldStopMouseLeavePropagation = false; +jest.mock('@components/SelectionList/ListItem/BaseListItem', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const ActualBaseListItem = jest.requireActual('@components/SelectionList/ListItem/BaseListItem').default; + + return ((props) => ( + + {props.children} + + )) as typeof BaseListItem; +}); + describe('BaseSelectionList', () => { const onSelectRowMock = jest.fn(); function BaseListItemRenderer(props: BaseSelectionListSections) { - const {sections, canSelectMultiple, initialNumToRender, setSearchText, searchText} = props; - const focusedKey = sections[0].data.find((item) => item.isSelected)?.keyForList; + const {data, canSelectMultiple} = props; + const focusedKey = data.find((item) => item.isSelected)?.keyForList ?? undefined; return ( ); } - it('should not trigger item press if screen is not focused', () => { - (NativeNavigation.useIsFocused as jest.Mock).mockReturnValue(false); - render(); - fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`)); - expect(onSelectRowMock).toHaveBeenCalledTimes(0); - }); - - it('should handle item press correctly', () => { - (NativeNavigation.useIsFocused as jest.Mock).mockReturnValue(true); - render(); - - fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`)); - expect(onSelectRowMock).toHaveBeenCalledWith({ - ...mockSections.at(1), - shouldAnimateInHighlight: false, - }); - }); - - it('should update focused item when sections are updated from BE', () => { - (NativeNavigation.useIsFocused as jest.Mock).mockReturnValue(true); - const updatedMockSections = mockSections.map((section) => ({ - ...section, - isSelected: section.keyForList === '2', - })); - const {rerender} = render(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`)).toBeSelected(); - rerender(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}2`)).toBeSelected(); - }); - - it('should scroll to top when selecting a multi option list', () => { - const spy = jest.spyOn(SectionList.prototype, 'scrollToLocation'); - render( - , - ); - fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({itemIndex: 0})); - }); - - it('should show only elements from first page when items exceed page limit', () => { + it('should focus next/previous item relative to hovered item when arrow keys are pressed', async () => { render( , ); - // Should render first page (items 0-49, so 50 items total) - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}49`)).toBeTruthy(); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toHaveStyle({backgroundColor: colors.productDark400}); - // Should NOT render items from second page - expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}50`)).toBeFalsy(); - expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}99`)).toBeFalsy(); - }); - - it('should render all items when they fit within initial render limit', () => { - render( - , - ); + // Trigger a mouse move event to hover the item + fireEvent(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}8`), 'mouseMove', {stopPropagation: () => {}}); - // Should render all 10 items since they fit within the initialNumToRender limit - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}9`)).toBeTruthy(); - }); - - it('should load more items when scrolled to end', () => { - render( - , - ); - - // Should initially show first page items (0-48, 49 items total) - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}48`)).toBeTruthy(); - - // Items beyond first page should not be initially visible - expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}49`)).toBeFalsy(); - - // Note: Scroll-based loading in test environment might not work as expected - // This test verifies the initial state - actual scroll behavior would need integration testing - }); - - it('should search for first item then scroll back to preselected item when search is cleared', () => { - function SearchableListWrapper() { - const [searchText, setSearchText] = useState(''); - - // Filter sections based on search text - const filteredSections = searchText - ? largeMockSectionsWithSelectedItemFromSecondPage.filter((item) => item.text.toLowerCase().includes(searchText.toLowerCase())) - : largeMockSectionsWithSelectedItemFromSecondPage; - - return ( - - ); - } - - render(); + // eslint-disable-next-line testing-library/no-unnecessary-act + act(() => { + arrowDownCallback(); + }); - // Initially should show item 70 as selected and visible - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeTruthy(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeSelected(); + // The item that gets focused will be the one following the hovered item + await waitFor(() => { + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}9`)).toHaveStyle({backgroundColor: colors.productDark300}); + }); - // Search for "Item 0" - fireEvent.changeText(screen.getByTestId('selection-list-text-input'), 'Item 0'); + act(() => { + arrowUpCallback(); + arrowUpCallback(); + }); - // Should show only the first item (Item 0) in search results - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); - expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeFalsy(); - expect(screen.queryByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}1`)).toBeFalsy(); + await waitFor(() => { + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}7`)).toHaveStyle({backgroundColor: colors.productDark300}); + }); - // Clear the search text - fireEvent.changeText(screen.getByTestId('selection-list-text-input'), ''); + act(() => { + arrowDownCallback(); + }); - // Should show the preselected item 70 - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeTruthy(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}70`)).toBeSelected(); + await waitFor(() => { + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}8`)).toHaveStyle({backgroundColor: colors.productDark300}); + }); }); - it('does not reset page when only selectedOptions changes', () => { - const {rerender} = render( + it("the stopPropagation from the BaseListItem's mouseLeave event does not trigger if shouldStopMouseLeavePropagation === false", () => { + mockShouldStopMouseLeavePropagation = false; + render( , ); - // Should show first page items - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}48`)).toBeTruthy(); + const mockStopPropagation = jest.fn(); + fireEvent(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`), 'mouseLeave', {stopPropagation: mockStopPropagation}); - // Rerender with only selection change - rerender( - ({...item, isSelected: index === 3}))}]} - canSelectMultiple={false} - initialNumToRender={50} - />, - ); + expect(mockStopPropagation).toHaveBeenCalledTimes(0); - // Should still show the same items (no pagination reset) - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}48`)).toBeTruthy(); - // Item 3 should now be selected - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`)).toBeSelected(); - }); - - it('should reset current page when text input changes', () => { - const {rerender} = render( + mockShouldStopMouseLeavePropagation = true; + render( , ); - // Should show first page items initially - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}48`)).toBeTruthy(); - - // Rerender with search text - should still show items (filtered or not) - rerender( - ({...item, isSelected: index === 3}))}]} - canSelectMultiple={false} - searchText="Item" - initialNumToRender={50} - />, - ); + fireEvent(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`), 'mouseLeave', {stopPropagation: mockStopPropagation}); - // Search functionality should work - items should still be visible - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`)).toBeTruthy(); + expect(mockStopPropagation).toHaveBeenCalledTimes(1); }); });