From 09890aa8eb5149ba6c3e8056cf0a114e1f724d1a Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Fri, 31 Oct 2025 23:03:33 +0700 Subject: [PATCH 01/17] Fix - Two dates appear highlighted at the same time --- .../BaseListItem.tsx | 4 +- .../BaseSelectionListItemRenderer.tsx | 2 + .../BaseSelectionListWithSections.tsx | 38 ++++++++++++++++++- .../RadioListItem.tsx | 2 + src/hooks/useArrowKeyFocusManager.ts | 26 +++++++++---- 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/components/SelectionListWithSections/BaseListItem.tsx b/src/components/SelectionListWithSections/BaseListItem.tsx index 3c106b93e7fe1..f1079b54bc094 100644 --- a/src/components/SelectionListWithSections/BaseListItem.tsx +++ b/src/components/SelectionListWithSections/BaseListItem.tsx @@ -43,6 +43,7 @@ function BaseListItem({ shouldUseDefaultRightHandSideCheckmark = true, forwardedFSClass, shouldShowRightCaret = false, + isHoverStyleDisabled, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -56,7 +57,6 @@ function BaseListItem({ useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); const handleMouseLeave = (e: React.MouseEvent) => { bind.onMouseLeave(); - e.stopPropagation(); setMouseUp(); }; @@ -104,7 +104,7 @@ function BaseListItem({ isNested hoverDimmingValue={1} pressDimmingValue={item.isInteractive === false ? 1 : variables.pressDimValue} - hoverStyle={[!item.isDisabled && item.isInteractive !== false && styles.hoveredComponentBG, hoverStyle]} + hoverStyle={!isHoverStyleDisabled ? [!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 e61d61450a821..da5755acacea8 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx @@ -58,6 +58,7 @@ function BaseSelectionListItemRenderer({ personalDetails, userBillingFundID, shouldShowRightCaret, + isHoverStyleDisabled, }: BaseSelectionListItemRendererProps) { const handleOnCheckboxPress = () => { if (isTransactionGroupListItemType(item)) { @@ -116,6 +117,7 @@ function BaseSelectionListItemRenderer({ index={index} shouldShowRightCaret={shouldShowRightCaret} sectionIndex={sectionIndex} + isHoverStyleDisabled={isHoverStyleDisabled} /> {item.footerContent && item.footerContent} diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index b68ac6c9f0e61..9745d0a22b139 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -416,8 +416,10 @@ function BaseSelectionListWithSections({ hasKeyBeenPressed.current = true; }, []); + const [isHoverStyleDisabled, setIsHoverStyleDisabled] = useState(false); + // 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, @@ -433,6 +435,9 @@ function BaseSelectionListWithSections({ }, ...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}), isFocused, + onArrowUpDownCallback: () => { + setIsHoverStyleDisabled(true); + }, }); useEffect(() => { @@ -633,6 +638,20 @@ function BaseSelectionListWithSections({ ); + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + + const handler = () => { + setIsHoverStyleDisabled(false); + }; + document.addEventListener('mousemove', handler, true); + return () => { + document.removeEventListener('mousemove', handler, true); + }; + }, []); + const renderItem = ({item, index, section}: SectionListRenderItemInfo>) => { const normalizedIndex = index + (section?.indexOffset ?? 0); const isDisabled = !!section.isDisabled || item.isDisabled; @@ -641,7 +660,21 @@ function BaseSelectionListWithSections({ const isItemHighlighted = !!itemsToHighlight?.has(item.keyForList ?? ''); return ( - onItemLayout(event, item?.keyForList)}> + onItemLayout(event, item?.keyForList)} + onMouseMove={() => { + if (isHoverStyleDisabled) { + return; + } + currentHoverIndexRef.current = normalizedIndex; + }} + onMouseLeave={() => { + if (isHoverStyleDisabled) { + return; + } + currentHoverIndexRef.current = null; + }} + > ({ titleContainerStyles={listItemTitleContainerStyles} canShowProductTrainingTooltip={canShowProductTrainingTooltipMemo} shouldShowRightCaret={shouldShowRightCaret} + isHoverStyleDisabled={isHoverStyleDisabled} /> ); diff --git a/src/components/SelectionListWithSections/RadioListItem.tsx b/src/components/SelectionListWithSections/RadioListItem.tsx index 256c3b0a876fe..789067cc5558b 100644 --- a/src/components/SelectionListWithSections/RadioListItem.tsx +++ b/src/components/SelectionListWithSections/RadioListItem.tsx @@ -23,6 +23,7 @@ function RadioListItem({ shouldSyncFocus, wrapperStyle, titleStyles, + isHoverStyleDisabled, }: RadioListItemProps) { const styles = useThemeStyles(); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; @@ -45,6 +46,7 @@ function RadioListItem({ onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} pendingAction={item.pendingAction} + isHoverStyleDisabled={isHoverStyleDisabled} > <> {!!item.leftElement && item.leftElement} 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]; } From e314bf8aa4bada2c8f6b91ade731ad5ead6b069b Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 5 Nov 2025 16:46:47 +0700 Subject: [PATCH 02/17] Fix - Two dates appear highlighted at the same time --- .../BaseListItem.tsx | 6 +- .../BaseSelectionListItemRenderer.tsx | 4 +- .../BaseSelectionListWithSections.tsx | 29 +++++----- .../RadioListItem.tsx | 4 +- .../SelectionListWithSections/types.ts | 3 + src/hooks/useArrowKeyFocusManager.ts | 8 +++ tests/unit/BaseSelectionListTest.tsx | 55 ++++++++++++++++++- 7 files changed, 85 insertions(+), 24 deletions(-) diff --git a/src/components/SelectionListWithSections/BaseListItem.tsx b/src/components/SelectionListWithSections/BaseListItem.tsx index 13b2cb2526ca9..59b2d2b680301 100644 --- a/src/components/SelectionListWithSections/BaseListItem.tsx +++ b/src/components/SelectionListWithSections/BaseListItem.tsx @@ -44,7 +44,7 @@ function BaseListItem({ forwardedFSClass, shouldShowRightCaret = false, shouldHighlightSelectedItem = true, - isHoverStyleDisabled, + shouldDisableHoverStyle, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -56,7 +56,7 @@ function BaseListItem({ // Sync focus on an item useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); - const handleMouseLeave = (e: React.MouseEvent) => { + const handleMouseLeave = () => { bind.onMouseLeave(); setMouseUp(); }; @@ -105,7 +105,7 @@ function BaseListItem({ isNested hoverDimmingValue={1} pressDimmingValue={item.isInteractive === false ? 1 : variables.pressDimValue} - hoverStyle={!isHoverStyleDisabled ? [!item.isDisabled && item.isInteractive !== false && styles.hoveredComponentBG, hoverStyle] : undefined} + 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 0f15ed6eef7f5..4d6e56d30fc70 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx @@ -59,7 +59,7 @@ function BaseSelectionListItemRenderer({ userBillingFundID, shouldShowRightCaret, shouldHighlightSelectedItem = true, - isHoverStyleDisabled, + shouldDisableHoverStyle, }: BaseSelectionListItemRendererProps) { const handleOnCheckboxPress = () => { if (isTransactionGroupListItemType(item)) { @@ -119,7 +119,7 @@ function BaseSelectionListItemRenderer({ shouldShowRightCaret={shouldShowRightCaret} shouldHighlightSelectedItem={shouldHighlightSelectedItem} sectionIndex={sectionIndex} - isHoverStyleDisabled={isHoverStyleDisabled} + shouldDisableHoverStyle={shouldDisableHoverStyle} /> {item.footerContent && item.footerContent} diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 5bd29a3adad87..f51735aefbd83 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -417,7 +417,7 @@ function BaseSelectionListWithSections({ hasKeyBeenPressed.current = true; }, []); - const [isHoverStyleDisabled, setIsHoverStyleDisabled] = useState(false); + const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member const [focusedIndex, setFocusedIndex, currentHoverIndexRef] = useArrowKeyFocusManager({ @@ -437,7 +437,7 @@ function BaseSelectionListWithSections({ ...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}), isFocused, onArrowUpDownCallback: () => { - setIsHoverStyleDisabled(true); + setShouldDisableHoverStyle(true); }, }); @@ -645,7 +645,7 @@ function BaseSelectionListWithSections({ } const handler = () => { - setIsHoverStyleDisabled(false); + setShouldDisableHoverStyle(false); }; document.addEventListener('mousemove', handler, true); return () => { @@ -659,22 +659,19 @@ function BaseSelectionListWithSections({ const selected = isItemSelected(item); const isItemFocused = (!isDisabled || selected) && focusedIndex === normalizedIndex; const isItemHighlighted = !!itemsToHighlight?.has(item.keyForList ?? ''); + const setCurrentHoverIndex = (e: React.MouseEvent, hoverIndex: number | null) => { + if (shouldDisableHoverStyle) { + return; + } + e.stopPropagation(); + currentHoverIndexRef.current = hoverIndex; + }; return ( onItemLayout(event, item?.keyForList)} - onMouseMove={() => { - if (isHoverStyleDisabled) { - return; - } - currentHoverIndexRef.current = normalizedIndex; - }} - onMouseLeave={() => { - if (isHoverStyleDisabled) { - return; - } - currentHoverIndexRef.current = null; - }} + onMouseMove={(e) => setCurrentHoverIndex(e, normalizedIndex)} + onMouseLeave={(e) => setCurrentHoverIndex(e, null)} > ({ titleContainerStyles={listItemTitleContainerStyles} canShowProductTrainingTooltip={canShowProductTrainingTooltipMemo} shouldShowRightCaret={shouldShowRightCaret} - isHoverStyleDisabled={isHoverStyleDisabled} + shouldDisableHoverStyle={shouldDisableHoverStyle} /> ); diff --git a/src/components/SelectionListWithSections/RadioListItem.tsx b/src/components/SelectionListWithSections/RadioListItem.tsx index d9eec57031621..435bc25b2ac4d 100644 --- a/src/components/SelectionListWithSections/RadioListItem.tsx +++ b/src/components/SelectionListWithSections/RadioListItem.tsx @@ -24,7 +24,7 @@ function RadioListItem({ wrapperStyle, titleStyles, shouldHighlightSelectedItem = true, - isHoverStyleDisabled, + shouldDisableHoverStyle, }: RadioListItemProps) { const styles = useThemeStyles(); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; @@ -48,7 +48,7 @@ function RadioListItem({ shouldSyncFocus={shouldSyncFocus} pendingAction={item.pendingAction} shouldHighlightSelectedItem={shouldHighlightSelectedItem} - isHoverStyleDisabled={isHoverStyleDisabled} + shouldDisableHoverStyle={shouldDisableHoverStyle} > <> {!!item.leftElement && item.leftElement} diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index f16b92a19b02f..0c75f56794a9c 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -117,6 +117,9 @@ type CommonListItemProps = { /** Whether to highlight the selected item */ shouldHighlightSelectedItem?: boolean; + + /** Whether to disable the hover style of the item */ + shouldDisableHoverStyle?: boolean; } & TRightHandSideComponent; type ListItemFocusEventHandler = (event: NativeSyntheticEvent) => void; diff --git a/src/hooks/useArrowKeyFocusManager.ts b/src/hooks/useArrowKeyFocusManager.ts index 74877438f0a90..820c032182d0f 100644 --- a/src/hooks/useArrowKeyFocusManager.ts +++ b/src/hooks/useArrowKeyFocusManager.ts @@ -92,7 +92,11 @@ export default function useArrowKeyFocusManager({ setFocusedIndex((actualIndexParam) => { const actualIndex = currentHoverIndexRef.current ?? actualIndexParam; const currentFocusedIndex = actualIndex > 0 ? actualIndex - (itemsPerRow ?? 1) : nextIndex; + // If the hover index is different from the focused index, then focus the hover item; otherwise, focus the previous item. let newFocusedIndex = currentFocusedIndex; + if (currentHoverIndexRef.current !== null && currentHoverIndexRef.current !== actualIndexParam) { + newFocusedIndex = currentHoverIndexRef.current; + } while (disabledIndexes.includes(newFocusedIndex)) { newFocusedIndex -= itemsPerRow ?? 1; @@ -141,7 +145,11 @@ export default function useArrowKeyFocusManager({ return actualIndex; } + // If the hover index is different from the focused index, then focus the hover item; otherwise, focus the next item. let newFocusedIndex = currentFocusedIndex; + if (currentHoverIndexRef.current !== null && currentHoverIndexRef.current !== actualIndexParam) { + newFocusedIndex = currentHoverIndexRef.current; + } while (disabledIndexes.includes(newFocusedIndex)) { if (actualIndex < 0) { newFocusedIndex += 1; diff --git a/tests/unit/BaseSelectionListTest.tsx b/tests/unit/BaseSelectionListTest.tsx index 8ca2994bb3941..3c68d82b4fd10 100644 --- a/tests/unit/BaseSelectionListTest.tsx +++ b/tests/unit/BaseSelectionListTest.tsx @@ -1,5 +1,5 @@ import * as NativeNavigation from '@react-navigation/native'; -import {fireEvent, render, screen} from '@testing-library/react-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'; @@ -7,6 +7,7 @@ import BaseSelectionList from '@components/SelectionListWithSections/BaseSelecti 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 = { @@ -52,6 +53,16 @@ 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; + } +}); + describe('BaseSelectionList', () => { const onSelectRowMock = jest.fn(); @@ -268,4 +279,46 @@ describe('BaseSelectionList', () => { expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).toBeTruthy(); expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`)).toBeTruthy(); }); + + it('the hovered item should be focused when the up or down arrow key is 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 focused item will be the hovered item + await waitFor(() => { + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}10`)).toHaveStyle({backgroundColor: colors.productDark300}); + }); + + act(() => { + arrowDownCallback(); + arrowDownCallback(); + }); + + await waitFor(() => { + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}12`)).toHaveStyle({backgroundColor: colors.productDark300}); + }); + + act(() => { + arrowUpCallback(); + }); + + await waitFor(() => { + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}11`)).toHaveStyle({backgroundColor: colors.productDark300}); + }); + }); }); From fc5a58efc35c3f63ef50b01558b251aca69c825d Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 5 Nov 2025 17:49:40 +0700 Subject: [PATCH 03/17] Fix - Two dates appear highlighted at the same time --- .../BaseSelectionListWithSections.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index f51735aefbd83..8809b50444009 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -23,6 +23,7 @@ import useScrollEnabled from '@hooks/useScrollEnabled'; import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; import Log from '@libs/Log'; @@ -640,11 +641,22 @@ function BaseSelectionListWithSections({ ); useEffect(() => { - if (typeof document === 'undefined') { + if (!canUseTouchScreen() && typeof document === 'undefined') { return; } - const handler = () => { + let lastClientX = 0; + let lastClientY = 0; + const handler = (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); }; document.addEventListener('mousemove', handler, true); From 09806e872dcdcbd72a0c3c57c8cf970e439dd02f Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 5 Nov 2025 18:06:49 +0700 Subject: [PATCH 04/17] Fix - Two dates appear highlighted at the same time --- .../BaseSelectionListWithSections.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 8809b50444009..46c2977d77915 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -419,6 +419,9 @@ function BaseSelectionListWithSections({ }, []); const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); + const onArrowUpDownCallback = useCallback(() => { + setShouldDisableHoverStyle(true); + }, []); // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member const [focusedIndex, setFocusedIndex, currentHoverIndexRef] = useArrowKeyFocusManager({ @@ -437,9 +440,7 @@ function BaseSelectionListWithSections({ }, ...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}), isFocused, - onArrowUpDownCallback: () => { - setShouldDisableHoverStyle(true); - }, + onArrowUpDownCallback, }); useEffect(() => { @@ -665,19 +666,23 @@ function BaseSelectionListWithSections({ }; }, []); + const setCurrentHoverIndex = useCallback( + (e: React.MouseEvent, hoverIndex: number | null) => { + if (shouldDisableHoverStyle) { + return; + } + e.stopPropagation(); + currentHoverIndexRef.current = hoverIndex; + }, + [currentHoverIndexRef, shouldDisableHoverStyle], + ); + const renderItem = ({item, index, section}: SectionListRenderItemInfo>) => { const normalizedIndex = index + (section?.indexOffset ?? 0); const isDisabled = !!section.isDisabled || item.isDisabled; const selected = isItemSelected(item); const isItemFocused = (!isDisabled || selected) && focusedIndex === normalizedIndex; const isItemHighlighted = !!itemsToHighlight?.has(item.keyForList ?? ''); - const setCurrentHoverIndex = (e: React.MouseEvent, hoverIndex: number | null) => { - if (shouldDisableHoverStyle) { - return; - } - e.stopPropagation(); - currentHoverIndexRef.current = hoverIndex; - }; return ( Date: Wed, 5 Nov 2025 18:13:25 +0700 Subject: [PATCH 05/17] Fix - Two dates appear highlighted at the same time --- .../BaseSelectionListWithSections.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 46c2977d77915..4f53fc9f1f4fd 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -24,6 +24,7 @@ import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; import Log from '@libs/Log'; @@ -642,7 +643,7 @@ function BaseSelectionListWithSections({ ); useEffect(() => { - if (!canUseTouchScreen() && typeof document === 'undefined') { + if (canUseTouchScreen() || getPlatform() === CONST.PLATFORM.ANDROID || getPlatform() === CONST.PLATFORM.IOS) { return; } From 76b631a1fb59d6049e7129eb6af7ad12d53b1fde Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 5 Nov 2025 18:14:45 +0700 Subject: [PATCH 06/17] fix lint --- src/components/SelectionListWithSections/types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index 0c75f56794a9c..49b3afdcb3753 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -35,7 +35,6 @@ import type { SearchMemberGroup, SearchPersonalDetails, SearchPolicy, - SearchReport, SearchReportAction, SearchTask, SearchTransaction, @@ -372,7 +371,7 @@ type TransactionGroupListItemType = ListItem & { transactionsQueryJSON?: SearchQueryJSON; }; -type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} & SearchReport & { +type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} & Report & { /** The personal details of the user requesting money */ from: SearchPersonalDetails; From 0f0edb5c7237a13b146fc5df77f5f22e8264f73c Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 5 Nov 2025 18:20:51 +0700 Subject: [PATCH 07/17] fix typecheck --- src/components/SelectionListWithSections/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index 49b3afdcb3753..0c75f56794a9c 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -35,6 +35,7 @@ import type { SearchMemberGroup, SearchPersonalDetails, SearchPolicy, + SearchReport, SearchReportAction, SearchTask, SearchTransaction, @@ -371,7 +372,7 @@ type TransactionGroupListItemType = ListItem & { transactionsQueryJSON?: SearchQueryJSON; }; -type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} & Report & { +type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} & SearchReport & { /** The personal details of the user requesting money */ from: SearchPersonalDetails; From bc3b846c704bfbe8245b40e246dec6d78f7bf703 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Sat, 8 Nov 2025 17:08:04 +0700 Subject: [PATCH 08/17] Fix - Two dates appear highlighted at the same time --- .../BaseListItem.tsx | 6 ++- .../BaseSelectionListItemRenderer.tsx | 2 + .../BaseSelectionListWithSections.tsx | 44 +++++-------------- .../RadioListItem.tsx | 2 + .../SelectionListWithSections/index.tsx | 28 ++++++++++++ .../SelectionListWithSections/types.ts | 6 +++ src/hooks/useArrowKeyFocusManager.ts | 8 ---- tests/unit/BaseSelectionListTest.tsx | 14 +++--- 8 files changed, 61 insertions(+), 49 deletions(-) diff --git a/src/components/SelectionListWithSections/BaseListItem.tsx b/src/components/SelectionListWithSections/BaseListItem.tsx index 59b2d2b680301..31e33451eb58b 100644 --- a/src/components/SelectionListWithSections/BaseListItem.tsx +++ b/src/components/SelectionListWithSections/BaseListItem.tsx @@ -45,6 +45,7 @@ function BaseListItem({ shouldShowRightCaret = false, shouldHighlightSelectedItem = true, shouldDisableHoverStyle, + shouldStopMouseLeavePropagation = true, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -56,8 +57,11 @@ function BaseListItem({ // Sync focus on an item useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); - const handleMouseLeave = () => { + const handleMouseLeave = (e: React.MouseEvent) => { bind.onMouseLeave(); + if (shouldStopMouseLeavePropagation) { + e.stopPropagation(); + } setMouseUp(); }; diff --git a/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx b/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx index 4d6e56d30fc70..715607e618ec7 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx @@ -60,6 +60,7 @@ function BaseSelectionListItemRenderer({ shouldShowRightCaret, shouldHighlightSelectedItem = true, shouldDisableHoverStyle, + shouldStopMouseLeavePropagation, }: BaseSelectionListItemRendererProps) { const handleOnCheckboxPress = () => { if (isTransactionGroupListItemType(item)) { @@ -120,6 +121,7 @@ function BaseSelectionListItemRenderer({ 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 4f53fc9f1f4fd..77cae2f5e546a 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -23,8 +23,6 @@ import useScrollEnabled from '@hooks/useScrollEnabled'; import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import getPlatform from '@libs/getPlatform'; import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; import Log from '@libs/Log'; @@ -145,6 +143,8 @@ function BaseSelectionListWithSections({ renderScrollComponent, shouldShowRightCaret, shouldHighlightSelectedItem = true, + shouldDisableHoverStyle = false, + setShouldDisableHoverStyle = () => {}, ref, }: SelectionListProps) { const styles = useThemeStyles(); @@ -419,10 +419,9 @@ function BaseSelectionListWithSections({ hasKeyBeenPressed.current = true; }, []); - const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); 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, currentHoverIndexRef] = useArrowKeyFocusManager({ @@ -642,37 +641,11 @@ function BaseSelectionListWithSections({ ); - useEffect(() => { - if (canUseTouchScreen() || getPlatform() === CONST.PLATFORM.ANDROID || getPlatform() === CONST.PLATFORM.IOS) { - return; - } - - let lastClientX = 0; - let lastClientY = 0; - const handler = (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); - }; - document.addEventListener('mousemove', handler, true); - return () => { - document.removeEventListener('mousemove', handler, true); - }; - }, []); - const setCurrentHoverIndex = useCallback( - (e: React.MouseEvent, hoverIndex: number | null) => { + (hoverIndex: number | null) => { if (shouldDisableHoverStyle) { return; } - e.stopPropagation(); currentHoverIndexRef.current = hoverIndex; }, [currentHoverIndexRef, shouldDisableHoverStyle], @@ -688,8 +661,12 @@ function BaseSelectionListWithSections({ return ( onItemLayout(event, item?.keyForList)} - onMouseMove={(e) => setCurrentHoverIndex(e, normalizedIndex)} - onMouseLeave={(e) => setCurrentHoverIndex(e, null)} + onMouseMove={() => setCurrentHoverIndex(normalizedIndex)} + onMouseEnter={() => setCurrentHoverIndex(normalizedIndex)} + onMouseLeave={(e) => { + e.stopPropagation(); + setCurrentHoverIndex(null); + }} > ({ canShowProductTrainingTooltip={canShowProductTrainingTooltipMemo} shouldShowRightCaret={shouldShowRightCaret} shouldDisableHoverStyle={shouldDisableHoverStyle} + shouldStopMouseLeavePropagation={false} /> ); diff --git a/src/components/SelectionListWithSections/RadioListItem.tsx b/src/components/SelectionListWithSections/RadioListItem.tsx index 435bc25b2ac4d..063f3be7251fa 100644 --- a/src/components/SelectionListWithSections/RadioListItem.tsx +++ b/src/components/SelectionListWithSections/RadioListItem.tsx @@ -25,6 +25,7 @@ function RadioListItem({ titleStyles, shouldHighlightSelectedItem = true, shouldDisableHoverStyle, + shouldStopMouseLeavePropagation, }: RadioListItemProps) { const styles = useThemeStyles(); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; @@ -49,6 +50,7 @@ function RadioListItem({ 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..dfb45998c279d 100644 --- a/src/components/SelectionListWithSections/index.tsx +++ b/src/components/SelectionListWithSections/index.tsx @@ -64,6 +64,32 @@ function SelectionListWithSections({onScroll, shouldHide Keyboard.dismiss(); }; + const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); + useEffect(() => { + if (canUseTouchScreen()) { + return; + } + + let lastClientX = 0; + let lastClientY = 0; + const handler = (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); + }; + document.addEventListener('mousemove', handler, {passive: true}); + return () => { + document.removeEventListener('mousemove', handler); + }; + }, []); + 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 bab9eff8e98ad..d2ec5911d061f 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -118,6 +118,8 @@ type CommonListItemProps = { /** Whether to disable the hover style of the item */ shouldDisableHoverStyle?: boolean; + + shouldStopMouseLeavePropagation?: boolean; } & TRightHandSideComponent; type ListItemFocusEventHandler = (event: NativeSyntheticEvent) => void; @@ -968,6 +970,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 820c032182d0f..74877438f0a90 100644 --- a/src/hooks/useArrowKeyFocusManager.ts +++ b/src/hooks/useArrowKeyFocusManager.ts @@ -92,11 +92,7 @@ export default function useArrowKeyFocusManager({ setFocusedIndex((actualIndexParam) => { const actualIndex = currentHoverIndexRef.current ?? actualIndexParam; const currentFocusedIndex = actualIndex > 0 ? actualIndex - (itemsPerRow ?? 1) : nextIndex; - // If the hover index is different from the focused index, then focus the hover item; otherwise, focus the previous item. let newFocusedIndex = currentFocusedIndex; - if (currentHoverIndexRef.current !== null && currentHoverIndexRef.current !== actualIndexParam) { - newFocusedIndex = currentHoverIndexRef.current; - } while (disabledIndexes.includes(newFocusedIndex)) { newFocusedIndex -= itemsPerRow ?? 1; @@ -145,11 +141,7 @@ export default function useArrowKeyFocusManager({ return actualIndex; } - // If the hover index is different from the focused index, then focus the hover item; otherwise, focus the next item. let newFocusedIndex = currentFocusedIndex; - if (currentHoverIndexRef.current !== null && currentHoverIndexRef.current !== actualIndexParam) { - newFocusedIndex = currentHoverIndexRef.current; - } while (disabledIndexes.includes(newFocusedIndex)) { if (actualIndex < 0) { newFocusedIndex += 1; diff --git a/tests/unit/BaseSelectionListTest.tsx b/tests/unit/BaseSelectionListTest.tsx index 3c68d82b4fd10..3e3afafae7588 100644 --- a/tests/unit/BaseSelectionListTest.tsx +++ b/tests/unit/BaseSelectionListTest.tsx @@ -299,26 +299,26 @@ describe('BaseSelectionList', () => { arrowDownCallback(); }); - // The focused item will be the hovered item + // The item that gets focused will be the one following the hovered item await waitFor(() => { - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}10`)).toHaveStyle({backgroundColor: colors.productDark300}); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}11`)).toHaveStyle({backgroundColor: colors.productDark300}); }); act(() => { - arrowDownCallback(); - arrowDownCallback(); + arrowUpCallback(); + arrowUpCallback(); }); await waitFor(() => { - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}12`)).toHaveStyle({backgroundColor: colors.productDark300}); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}9`)).toHaveStyle({backgroundColor: colors.productDark300}); }); act(() => { - arrowUpCallback(); + arrowDownCallback(); }); await waitFor(() => { - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}11`)).toHaveStyle({backgroundColor: colors.productDark300}); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}10`)).toHaveStyle({backgroundColor: colors.productDark300}); }); }); }); From dfc57af83e0b45d05a99ec4b80c08e0a93ff668e Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Sat, 8 Nov 2025 17:19:08 +0700 Subject: [PATCH 09/17] Fix - Two dates appear highlighted at the same time --- src/components/SelectionListWithSections/index.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/SelectionListWithSections/index.tsx b/src/components/SelectionListWithSections/index.tsx index dfb45998c279d..21491cf6e2476 100644 --- a/src/components/SelectionListWithSections/index.tsx +++ b/src/components/SelectionListWithSections/index.tsx @@ -72,7 +72,7 @@ function SelectionListWithSections({onScroll, shouldHide let lastClientX = 0; let lastClientY = 0; - const handler = (event: MouseEvent) => { + 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) { @@ -84,9 +84,13 @@ function SelectionListWithSections({onScroll, shouldHide setShouldDisableHoverStyle(false); }; - document.addEventListener('mousemove', handler, {passive: true}); + const wheelHandler = () => setShouldDisableHoverStyle(false); + + document.addEventListener('mousemove', mouseMovehandler, {passive: true}); + document.addEventListener('wheel', wheelHandler, {passive: true}); return () => { - document.removeEventListener('mousemove', handler); + document.removeEventListener('mousemove', mouseMovehandler); + document.removeEventListener('wheel', wheelHandler); }; }, []); From 195bc3f88f723492f9f1da8020fc6331158bc4f2 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Sat, 8 Nov 2025 17:31:06 +0700 Subject: [PATCH 10/17] Fix - Two dates appear highlighted at the same time --- src/components/SelectionListWithSections/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/SelectionListWithSections/index.tsx b/src/components/SelectionListWithSections/index.tsx index 21491cf6e2476..c4877d11ccfb8 100644 --- a/src/components/SelectionListWithSections/index.tsx +++ b/src/components/SelectionListWithSections/index.tsx @@ -72,7 +72,7 @@ function SelectionListWithSections({onScroll, shouldHide let lastClientX = 0; let lastClientY = 0; - const mouseMovehandler = (event: MouseEvent) => { + 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) { @@ -86,10 +86,10 @@ function SelectionListWithSections({onScroll, shouldHide }; const wheelHandler = () => setShouldDisableHoverStyle(false); - document.addEventListener('mousemove', mouseMovehandler, {passive: true}); + document.addEventListener('mousemove', mouseMoveHandler, {passive: true}); document.addEventListener('wheel', wheelHandler, {passive: true}); return () => { - document.removeEventListener('mousemove', mouseMovehandler); + document.removeEventListener('mousemove', mouseMoveHandler); document.removeEventListener('wheel', wheelHandler); }; }, []); From 53bb4b635f74f7f23759457557c6fb51b5a76ec0 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 13 Nov 2025 16:48:51 +0700 Subject: [PATCH 11/17] Fix - Two dates highlighted when scrolling date list with keyboard arrows on Freq list --- .../SelectionList/BaseSelectionList.tsx | 80 +++++++++++++------ .../SelectionList/ListItem/BaseListItem.tsx | 8 +- .../ListItem/ListItemRenderer.tsx | 4 + .../SelectionList/ListItem/RadioListItem.tsx | 4 + .../SelectionList/ListItem/types.ts | 12 +++ src/components/SelectionList/index.tsx | 32 ++++++++ src/components/SelectionList/types.ts | 4 + .../SelectionListWithSections/index.tsx | 2 +- .../SelectionListWithSections/types.ts | 1 + 9 files changed, 118 insertions(+), 29 deletions(-) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 60934952ad425..c8278e7fa4f0a 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( @@ -295,37 +302,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..d51892a68843e 100644 --- a/src/components/SelectionList/ListItem/BaseListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseListItem.tsx @@ -42,6 +42,8 @@ function BaseListItem({ testID, shouldUseDefaultRightHandSideCheckmark = true, shouldHighlightSelectedItem = true, + shouldDisableHoverStyle, + shouldStopMouseLeavePropagation = true, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -55,7 +57,9 @@ function BaseListItem({ useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus); const handleMouseLeave = (e: React.MouseEvent) => { bind.onMouseLeave(); - e.stopPropagation(); + if (shouldStopMouseLeavePropagation) { + e.stopPropagation(); + } setMouseUp(); }; @@ -103,7 +107,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 eece8f12754cc..a8ca803beed4d 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/index.tsx b/src/components/SelectionListWithSections/index.tsx index c4877d11ccfb8..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,7 +65,6 @@ function SelectionListWithSections({onScroll, shouldHide Keyboard.dismiss(); }; - const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); useEffect(() => { if (canUseTouchScreen()) { return; diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index c3bbeaef7a5e6..7cd72e13534d9 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -117,6 +117,7 @@ type CommonListItemProps = { /** Whether to disable the hover style of the item */ shouldDisableHoverStyle?: boolean; + /** Whether to call stopPropagation on the mouseleave event in BaseListItem */ shouldStopMouseLeavePropagation?: boolean; } & TRightHandSideComponent; From 9fa55440cde8918d7bf7cbc71fc9cc5ea599fd22 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Mon, 17 Nov 2025 18:36:44 +0700 Subject: [PATCH 12/17] fix test Co-authored-by: Linh Vo --- tests/unit/BaseSelectionListTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/BaseSelectionListTest.tsx b/tests/unit/BaseSelectionListTest.tsx index 3e3afafae7588..a80d60fb531b6 100644 --- a/tests/unit/BaseSelectionListTest.tsx +++ b/tests/unit/BaseSelectionListTest.tsx @@ -280,7 +280,7 @@ describe('BaseSelectionList', () => { expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`)).toBeTruthy(); }); - it('the hovered item should be focused when the up or down arrow key is pressed', async () => { + it('should focus next/previous item relative to hovered item when arrow keys are pressed', async () => { render( Date: Mon, 17 Nov 2025 19:24:23 +0700 Subject: [PATCH 13/17] fix lint --- src/components/SelectionList/ListItem/BaseListItem.tsx | 7 ++++--- .../SelectionListWithSections/BaseListItem.tsx | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/SelectionList/ListItem/BaseListItem.tsx b/src/components/SelectionList/ListItem/BaseListItem.tsx index d51892a68843e..4baa3cf929961 100644 --- a/src/components/SelectionList/ListItem/BaseListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseListItem.tsx @@ -2,10 +2,10 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useHover from '@hooks/useHover'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import {useMouseContext} from '@hooks/useMouseContext'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; @@ -50,6 +50,7 @@ function BaseListItem({ const StyleUtils = useStyleUtils(); const {hovered, bind} = useHover(); const {isMouseDownOnInput, setMouseUp} = useMouseContext(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Checkmark', 'DotIndicator']); const pressableRef = useRef(null); @@ -142,7 +143,7 @@ function BaseListItem({ > @@ -152,7 +153,7 @@ function BaseListItem({ diff --git a/src/components/SelectionListWithSections/BaseListItem.tsx b/src/components/SelectionListWithSections/BaseListItem.tsx index 31e33451eb58b..520b918424975 100644 --- a/src/components/SelectionListWithSections/BaseListItem.tsx +++ b/src/components/SelectionListWithSections/BaseListItem.tsx @@ -2,10 +2,10 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useHover from '@hooks/useHover'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import {useMouseContext} from '@hooks/useMouseContext'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; @@ -52,6 +52,7 @@ function BaseListItem({ const StyleUtils = useStyleUtils(); const {hovered, bind} = useHover(); const {isMouseDownOnInput, setMouseUp} = useMouseContext(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Checkmark', 'DotIndicator', 'ArrowRight']); const pressableRef = useRef(null); @@ -145,7 +146,7 @@ function BaseListItem({ > @@ -155,7 +156,7 @@ function BaseListItem({ @@ -165,7 +166,7 @@ function BaseListItem({ {shouldShowRightCaret && ( Date: Tue, 18 Nov 2025 09:56:32 +0700 Subject: [PATCH 14/17] Revert "fix lint" This reverts commit f50a560376a2cf82404a614a076b1d36a5526866. --- src/components/SelectionList/ListItem/BaseListItem.tsx | 7 +++---- .../SelectionListWithSections/BaseListItem.tsx | 9 ++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/SelectionList/ListItem/BaseListItem.tsx b/src/components/SelectionList/ListItem/BaseListItem.tsx index 4baa3cf929961..d51892a68843e 100644 --- a/src/components/SelectionList/ListItem/BaseListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseListItem.tsx @@ -2,10 +2,10 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useHover from '@hooks/useHover'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import {useMouseContext} from '@hooks/useMouseContext'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; @@ -50,7 +50,6 @@ function BaseListItem({ const StyleUtils = useStyleUtils(); const {hovered, bind} = useHover(); const {isMouseDownOnInput, setMouseUp} = useMouseContext(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Checkmark', 'DotIndicator']); const pressableRef = useRef(null); @@ -143,7 +142,7 @@ function BaseListItem({ > @@ -153,7 +152,7 @@ function BaseListItem({ diff --git a/src/components/SelectionListWithSections/BaseListItem.tsx b/src/components/SelectionListWithSections/BaseListItem.tsx index 520b918424975..31e33451eb58b 100644 --- a/src/components/SelectionListWithSections/BaseListItem.tsx +++ b/src/components/SelectionListWithSections/BaseListItem.tsx @@ -2,10 +2,10 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import {getButtonRole} from '@components/Button/utils'; import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useHover from '@hooks/useHover'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import {useMouseContext} from '@hooks/useMouseContext'; import useStyleUtils from '@hooks/useStyleUtils'; import useSyncFocus from '@hooks/useSyncFocus'; @@ -52,7 +52,6 @@ function BaseListItem({ const StyleUtils = useStyleUtils(); const {hovered, bind} = useHover(); const {isMouseDownOnInput, setMouseUp} = useMouseContext(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Checkmark', 'DotIndicator', 'ArrowRight']); const pressableRef = useRef(null); @@ -146,7 +145,7 @@ function BaseListItem({ > @@ -156,7 +155,7 @@ function BaseListItem({ @@ -166,7 +165,7 @@ function BaseListItem({ {shouldShowRightCaret && ( Date: Tue, 18 Nov 2025 10:41:44 +0700 Subject: [PATCH 15/17] fix lint --- src/components/SelectionList/ListItem/BaseListItem.tsx | 1 + src/components/SelectionListWithSections/BaseListItem.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/components/SelectionList/ListItem/BaseListItem.tsx b/src/components/SelectionList/ListItem/BaseListItem.tsx index d51892a68843e..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'; diff --git a/src/components/SelectionListWithSections/BaseListItem.tsx b/src/components/SelectionListWithSections/BaseListItem.tsx index 31e33451eb58b..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'; From 7e0980aebb2f50da843621dca7e20fca617d29b2 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 20 Nov 2025 18:01:26 +0700 Subject: [PATCH 16/17] Fix - Two dates highlighted when scrolling date list with keyboard arrows on Freq list --- tests/unit/BaseSelectionListSectionsTest.tsx | 324 +++++++++++++++++++ tests/unit/BaseSelectionListTest.tsx | 246 +------------- 2 files changed, 339 insertions(+), 231 deletions(-) create mode 100644 tests/unit/BaseSelectionListSectionsTest.tsx 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 a80d60fb531b6..1cca0fd3ffd21 100644 --- a/tests/unit/BaseSelectionListTest.tsx +++ b/tests/unit/BaseSelectionListTest.tsx @@ -1,39 +1,21 @@ -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 BaseSelectionList from '@components/SelectionList/BaseSelectionList'; +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'); @@ -67,232 +49,34 @@ 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', () => { - 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}); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}0`)).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: () => {}}); + fireEvent(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}8`), 'mouseMove', {stopPropagation: () => {}}); // eslint-disable-next-line testing-library/no-unnecessary-act act(() => { @@ -301,7 +85,7 @@ describe('BaseSelectionList', () => { // 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}); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}9`)).toHaveStyle({backgroundColor: colors.productDark300}); }); act(() => { @@ -310,7 +94,7 @@ describe('BaseSelectionList', () => { }); await waitFor(() => { - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}9`)).toHaveStyle({backgroundColor: colors.productDark300}); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}7`)).toHaveStyle({backgroundColor: colors.productDark300}); }); act(() => { @@ -318,7 +102,7 @@ describe('BaseSelectionList', () => { }); await waitFor(() => { - expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}10`)).toHaveStyle({backgroundColor: colors.productDark300}); + expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}8`)).toHaveStyle({backgroundColor: colors.productDark300}); }); }); }); From 6abe191f9f050d53819f60ae59712544196d9c0f Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 20 Nov 2025 18:51:11 +0700 Subject: [PATCH 17/17] Fix - Two dates highlighted when scrolling date list with keyboard arrows on Freq list --- tests/unit/BaseSelectionListTest.tsx | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/unit/BaseSelectionListTest.tsx b/tests/unit/BaseSelectionListTest.tsx index 1cca0fd3ffd21..f4e5ec51fcbd4 100644 --- a/tests/unit/BaseSelectionListTest.tsx +++ b/tests/unit/BaseSelectionListTest.tsx @@ -1,6 +1,7 @@ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; 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'; @@ -45,6 +46,22 @@ jest.mock('@hooks/useKeyboardShortcut', () => (key: {shortcutKey: string}, callb } }); +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(); @@ -105,4 +122,31 @@ describe('BaseSelectionList', () => { expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}8`)).toHaveStyle({backgroundColor: colors.productDark300}); }); }); + + it("the stopPropagation from the BaseListItem's mouseLeave event does not trigger if shouldStopMouseLeavePropagation === false", () => { + mockShouldStopMouseLeavePropagation = false; + render( + , + ); + + const mockStopPropagation = jest.fn(); + fireEvent(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`), 'mouseLeave', {stopPropagation: mockStopPropagation}); + + expect(mockStopPropagation).toHaveBeenCalledTimes(0); + + mockShouldStopMouseLeavePropagation = true; + render( + , + ); + + fireEvent(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}3`), 'mouseLeave', {stopPropagation: mockStopPropagation}); + + expect(mockStopPropagation).toHaveBeenCalledTimes(1); + }); });