From 3265456246edf6d0ad0c0a7a2908532f17733df5 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 5 May 2026 13:34:24 +0430 Subject: [PATCH 01/12] Move pre-selected list items to top of list --- src/CONST/index.ts | 1 + .../CountryPicker/CountrySelectorModal.tsx | 12 +- .../PushRowWithModal/PushRowModal.tsx | 12 +- .../SelectionList/BaseSelectionList.tsx | 6 +- src/components/SelectionList/types.ts | 3 + .../StatePicker/StateSelectorModal.tsx | 11 +- .../ValuePicker/ValueSelectionList.tsx | 21 +- .../ValuePicker/ValueSelectorModal.tsx | 1 + src/components/ValuePicker/types.ts | 5 +- src/hooks/useInitialSelection.ts | 56 +++++ src/libs/SelectionListOrderUtils.ts | 45 ++++ .../DynamicCountrySelectionPage.tsx | 19 +- .../PersonalDetails/StateSelectionPage.tsx | 19 +- .../settings/Wallet/CountrySelectionList.tsx | 23 +- .../companyCards/addNew/SelectCountryStep.tsx | 15 +- ...BankAccountPurposeCountrySelectionTest.tsx | 89 +++++++ tests/ui/CountrySelectionListTest.tsx | 221 ++++++++++++++++++ tests/ui/CountrySelectorModalTest.tsx | 111 +++++++++ tests/ui/DynamicCountrySelectionPageTest.tsx | 105 +++++++++ tests/ui/PushRowModalTest.tsx | 112 +++++++++ tests/ui/SelectCountryStepTest.tsx | 180 ++++++++++++++ tests/ui/StateSelectionPageTest.tsx | 101 ++++++++ tests/ui/StateSelectorModalTest.tsx | 117 ++++++++++ tests/ui/ValueSelectionListTest.tsx | 86 +++++++ tests/ui/ValueSelectorModalTest.tsx | 35 +++ tests/unit/SelectionListOrderUtilsTest.ts | 31 +++ .../SelectionList/useSearchFocusSync.test.ts | 44 ++++ 27 files changed, 1444 insertions(+), 37 deletions(-) create mode 100644 src/hooks/useInitialSelection.ts create mode 100644 src/libs/SelectionListOrderUtils.ts create mode 100644 tests/ui/BankAccountPurposeCountrySelectionTest.tsx create mode 100644 tests/ui/CountrySelectionListTest.tsx create mode 100644 tests/ui/CountrySelectorModalTest.tsx create mode 100644 tests/ui/DynamicCountrySelectionPageTest.tsx create mode 100644 tests/ui/PushRowModalTest.tsx create mode 100644 tests/ui/SelectCountryStepTest.tsx create mode 100644 tests/ui/StateSelectionPageTest.tsx create mode 100644 tests/ui/StateSelectorModalTest.tsx create mode 100644 tests/ui/ValueSelectionListTest.tsx create mode 100644 tests/ui/ValueSelectorModalTest.tsx create mode 100644 tests/unit/SelectionListOrderUtilsTest.ts create mode 100644 tests/unit/components/SelectionList/useSearchFocusSync.test.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6cfe90209277..dd3414052e80 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -259,6 +259,7 @@ const CONST = { POPOVER_DROPDOWN_MAX_HEIGHT: 416, POPOVER_MENU_MAX_HEIGHT: 496, POPOVER_MENU_MAX_HEIGHT_MOBILE: 432, + MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD: 8, POPOVER_DATE_WIDTH: 338, POPOVER_DATE_RANGE_WIDTH: 672, POPOVER_DATE_MAX_HEIGHT: 366, diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index a2f7e0fffeeb..1b6760db5ddc 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -5,10 +5,12 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -36,6 +38,9 @@ type CountrySelectorModalProps = { function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onClose, label, onBackdropPress}: CountrySelectorModalProps) { const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValue = useInitialSelection(currentCountry || undefined, {isVisible}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedCountry = initialSelectedValue; const countries = useMemo( () => @@ -51,8 +56,8 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC }), [translate, currentCountry], ); - - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); @@ -89,9 +94,10 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index a2f94504d49a..18d456bd5461 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -5,8 +5,10 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import searchOptions from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -44,6 +46,9 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValue = useInitialSelection(selectedOption || undefined, {isVisible}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedOption = initialSelectedValue; const options = useMemo( () => @@ -57,6 +62,8 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio [optionsList, selectedOption], ); + const orderedOptions = moveInitialSelectionToTopByValue(options, initialSelectedValues); + const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); onClose(); @@ -67,7 +74,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio setSearchValue(''); }; - const searchResults = searchOptions(debouncedSearchValue, options); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? options : orderedOptions); const textInputOptions = useMemo( () => ({ @@ -102,7 +109,8 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio ListItem={RadioListItem} onSelectRow={handleSelectRow} textInputOptions={textInputOptions} - initiallyFocusedItemKey={selectedOption} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initiallyFocusedOption} disableMaintainingScrollPosition shouldShowTooltips={false} showScrollIndicator diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 6a09fe2c4963..0afd129f26eb 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -42,6 +42,7 @@ function BaseSelectionList({ ref, ListItem, textInputOptions, + searchValueForFocusSync, initiallyFocusedItemKey, onSelectRow, onSelectAll, @@ -205,6 +206,7 @@ function BaseSelectionList({ // Including data.length ensures FlashList resets its layout cache when the list size changes // This prevents "index out of bounds" errors when filtering reduces the list size const extraData = useMemo(() => [data.length], [data.length]); + const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; const selectRow = useCallback( (item: TItem, indexToFocus?: number) => { @@ -495,12 +497,12 @@ function BaseSelectionList({ initiallyFocusedItemKey, isItemSelected, focusedIndex, - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, setFocusedIndex, }); useSearchFocusSync({ - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, data, selectedOptionsCount: dataDetails.selectedOptions.length, isItemSelected, diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 7d24527f3ace..9c9672d4acf4 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -56,6 +56,9 @@ type BaseSelectionListProps = { /** Configuration options for the text input */ textInputOptions?: TextInputOptions; + /** Search value used for focus synchronization. Defaults to textInputOptions.value */ + searchValueForFocusSync?: string; + /** Whether to show the text input */ shouldShowTextInput?: boolean; diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index d03e8183ca30..c275c1ae39be 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -6,10 +6,12 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -39,6 +41,9 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const styles = useThemeStyles(); + const initialSelectedValue = useInitialSelection(currentState || undefined, {isVisible}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedState = initialSelectedValue; const countryStates = useMemo( () => @@ -57,7 +62,8 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const searchResults = searchOptions(debouncedSearchValue, countryStates); + const orderedCountryStates = moveInitialSelectionToTopByValue(countryStates, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const textInputOptions = useMemo( () => ({ @@ -93,7 +99,8 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, ListItem={RadioListItem} onSelectRow={onStateSelected} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentState} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initiallyFocusedState} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldStopPropagation diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index cde868808598..b2eca190e4a4 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -1,6 +1,8 @@ import React, {useMemo} from 'react'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useInitialSelection from '@hooks/useInitialSelection'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import type {ValueSelectionListProps} from './types'; function ValueSelectionList({ @@ -11,20 +13,27 @@ function ValueSelectionList({ addBottomSafeAreaPadding = true, disableKeyboardShortcuts = false, alternateNumberOfSupportedLines, + isVisible, }: ValueSelectionListProps) { - const options = useMemo( - () => items.map((item) => ({value: item.value, alternateText: item.description, text: item.label ?? '', isSelected: item === selectedItem, keyForList: item.value ?? ''})), - [items, selectedItem], - ); + const initialSelectedValue = useInitialSelection(selectedItem?.value ? selectedItem.value : undefined, isVisible === undefined ? {resetOnFocus: true} : {isVisible}); + const initiallyFocusedItemKey = initialSelectedValue; + + const options = useMemo(() => { + const mappedOptions = items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})); + const orderedOptions = moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValue ? [initialSelectedValue] : []); + + return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})); + }, [initialSelectedValue, items, selectedItem?.value]); return ( onItemSelected?.(item)} - initiallyFocusedItemKey={selectedItem?.value} + initiallyFocusedItemKey={initiallyFocusedItemKey} shouldStopPropagation shouldShowTooltips={shouldShowTooltips} - shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} ListItem={RadioListItem} addBottomSafeAreaPadding={addBottomSafeAreaPadding} disableKeyboardShortcuts={disableKeyboardShortcuts} diff --git a/src/components/ValuePicker/ValueSelectorModal.tsx b/src/components/ValuePicker/ValueSelectorModal.tsx index 4aa1ba794ced..24afba16afe7 100644 --- a/src/components/ValuePicker/ValueSelectorModal.tsx +++ b/src/components/ValuePicker/ValueSelectorModal.tsx @@ -42,6 +42,7 @@ function ValueSelectorModal({ ; +> & { + /** Whether the parent modal is visible */ + isVisible?: boolean; +}; type ValuePickerProps = ForwardedFSClassProps & { /** Item to display */ diff --git a/src/hooks/useInitialSelection.ts b/src/hooks/useInitialSelection.ts new file mode 100644 index 000000000000..d3f85dd36b16 --- /dev/null +++ b/src/hooks/useInitialSelection.ts @@ -0,0 +1,56 @@ +import {useFocusEffect} from '@react-navigation/native'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +type UseInitialSelectionOptions = { + /** Whether the current cycle is visible; refresh the snapshot when it becomes visible */ + isVisible?: boolean; + + /** Whether to refresh the snapshot whenever the screen gains focus */ + resetOnFocus?: boolean; +}; + +/** + * Keeps an immutable snapshot of the initial selection for the current open/focus cycle. + * Callers can refresh the snapshot when a modal becomes visible or via screen focus. + */ +function useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) { + const {isVisible, resetOnFocus = false} = options; + const [initialSelection, setInitialSelection] = useState(selection); + const latestSelectionRef = useRef(selection); + const previousIsVisibleRef = useRef(isVisible); + + const updateInitialSelection = useCallback((nextSelection: T) => { + setInitialSelection((previousSelection) => (Object.is(previousSelection, nextSelection) ? previousSelection : nextSelection)); + }, []); + + useEffect(() => { + latestSelectionRef.current = selection; + }, [selection]); + + useEffect(() => { + const wasVisible = previousIsVisibleRef.current; + previousIsVisibleRef.current = isVisible; + + if (isVisible === undefined || !isVisible || wasVisible === isVisible) { + return; + } + + // Refresh only when a new visible cycle starts. + // Live selection changes while the picker stays open should not repin or refocus the list. + updateInitialSelection(latestSelectionRef.current); + }, [isVisible, updateInitialSelection]); + + useFocusEffect( + useCallback(() => { + if (!resetOnFocus) { + return; + } + + updateInitialSelection(latestSelectionRef.current); + }, [resetOnFocus, updateInitialSelection]), + ); + + return initialSelection; +} + +export default useInitialSelection; diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts new file mode 100644 index 000000000000..29cccec119b3 --- /dev/null +++ b/src/libs/SelectionListOrderUtils.ts @@ -0,0 +1,45 @@ +import CONST from '@src/CONST'; + +function moveInitialSelectionToTopByKey(keys: string[], initialSelectedKeys: string[]): string[] { + if (initialSelectedKeys.length === 0 || keys.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return keys; + } + + const selectedKeys = new Set(initialSelectedKeys); + const selected: string[] = []; + const remaining: string[] = []; + + for (const key of keys) { + if (selectedKeys.has(key)) { + selected.push(key); + continue; + } + + remaining.push(key); + } + + return [...selected, ...remaining]; +} + +function moveInitialSelectionToTopByValue(items: T[], initialSelectedValues: string[]): T[] { + if (initialSelectedValues.length === 0 || items.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return items; + } + + const selectedValues = new Set(initialSelectedValues); + const selected: T[] = []; + const remaining: T[] = []; + + for (const item of items) { + if (selectedValues.has(item.value)) { + selected.push(item); + continue; + } + + remaining.push(item); + } + + return [...selected, ...remaining]; +} + +export {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue}; diff --git a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx index e83eecb21dd9..210fe5586283 100644 --- a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx @@ -1,15 +1,18 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import type {Option} from '@libs/searchOptions'; import searchOptions from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import {appendParam} from '@libs/Url'; import CONST from '@src/CONST'; @@ -20,10 +23,12 @@ import type SCREENS from '@src/SCREENS'; type DynamicCountrySelectionPageProps = PlatformStackScreenProps; function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) { - const [searchValue, setSearchValue] = useState(''); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const {translate} = useLocalize(); const currentCountry = route.params.country; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.ADDRESS_COUNTRY.path); + const initialSelectedValue = useInitialSelection(currentCountry ?? undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const countries = useMemo( () => @@ -40,7 +45,8 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) [translate, currentCountry], ); - const searchResults = searchOptions(searchValue, countries); + const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const selectCountry = useCallback( (option: Option) => { @@ -51,12 +57,12 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) const textInputOptions = useMemo( () => ({ - headerMessage: searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', label: translate('common.country'), value: searchValue, onChangeText: setSearchValue, }), - [searchResults.length, searchValue, translate], + [debouncedSearchValue, searchResults.length, searchValue, translate, setSearchValue], ); return ( @@ -77,7 +83,8 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) ListItem={RadioListItem} onSelectRow={selectCountry} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentCountry} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initialSelectedValue} shouldSingleExecuteRowSelect addBottomSafeAreaPadding /> diff --git a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx index 27148e19171c..684b0ae223f6 100644 --- a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx @@ -1,15 +1,18 @@ import {useRoute} from '@react-navigation/native'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import {appendParam} from '@libs/Url'; import type {Route} from '@src/ROUTES'; @@ -26,10 +29,12 @@ function StateSelectionPage() { const route = useRoute(); const {translate} = useLocalize(); - const [searchValue, setSearchValue] = useState(''); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const params = route.params as RouteParams | undefined; const currentState = params?.state; const label = params?.label; + const initialSelectedValue = useInitialSelection(currentState ?? undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const countryStates = useMemo( () => @@ -48,8 +53,9 @@ function StateSelectionPage() { [translate, currentState], ); - const searchResults = searchOptions(searchValue, countryStates); - const headerMessage = searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; + const orderedCountryStates = moveInitialSelectionToTopByValue(countryStates, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); + const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const selectCountryState = useCallback( (option: Option) => { @@ -75,7 +81,7 @@ function StateSelectionPage() { value: searchValue, onChangeText: setSearchValue, }), - [headerMessage, label, searchValue, translate], + [headerMessage, label, searchValue, setSearchValue, translate], ); return ( @@ -106,7 +112,8 @@ function StateSelectionPage() { ListItem={RadioListItem} onSelectRow={selectCountryState} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentState} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initialSelectedValue} shouldSingleExecuteRowSelect disableMaintainingScrollPosition addBottomSafeAreaPadding diff --git a/src/pages/settings/Wallet/CountrySelectionList.tsx b/src/pages/settings/Wallet/CountrySelectionList.tsx index 30c39317c1f6..4cca3f023422 100644 --- a/src/pages/settings/Wallet/CountrySelectionList.tsx +++ b/src/pages/settings/Wallet/CountrySelectionList.tsx @@ -1,13 +1,16 @@ -import React, {useState} from 'react'; +import React from 'react'; import {View} from 'react-native'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Text from '@src/components/Text'; import type {TranslationPaths} from '@src/languages/types'; @@ -36,7 +39,9 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS const {translate} = useLocalize(); const {isOffline} = useNetwork(); const styles = useThemeStyles(); - const [searchValue, setSearchValue] = useState(''); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValue = useInitialSelection(selectedCountry ?? undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const onSelectionChange = (country: Option) => { onCountrySelected(country.value); @@ -53,13 +58,14 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS }; }); - const searchResults = searchOptions(searchValue, countriesList); + const orderedCountries = moveInitialSelectionToTopByValue(countriesList, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countriesList : orderedCountries); const textInputOptions = { label: translate('common.search'), value: searchValue, onChangeText: setSearchValue, - headerMessage: searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', }; const confirmButtonOptions = { @@ -75,16 +81,21 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS {translate('addPersonalBankAccount.countrySelectionStepHeader')} diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index 6aac0599a93b..342a7a5d4d8b 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -9,12 +9,14 @@ import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import Text from '@components/Text'; import {useCurrencyListState} from '@hooks/useCurrencyList'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {getPlaidCountry, isPlaidSupportedCountry} from '@libs/CardUtils'; import searchOptions from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; @@ -42,6 +44,9 @@ function SelectCountryStep({policyID}: CountryStepProps) { const [selectedCountry, setSelectedCountry] = useState(null); const currentCountry = selectedCountry ?? addNewCard?.data?.selectedCountry ?? getPlaidCountry(policy?.outputCurrency, currencyList, countryByIp); + const initialSelectedValue = useInitialSelection(currentCountry || undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedCountry = initialSelectedValue; const [hasError, setHasError] = useState(false); const doesCountrySupportPlaid = isPlaidSupportedCountry(currentCountry); @@ -84,8 +89,9 @@ function SelectCountryStep({policyID}: CountryStepProps) { searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), }; }); - - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const filteredCountries = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); + const searchResults = filteredCountries.map((country) => ({...country, isSelected: currentCountry === country.value})); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const textInputOptions = { @@ -121,11 +127,14 @@ function SelectCountryStep({policyID}: CountryStepProps) { setSelectedCountry(countryOption.value ?? null); }} textInputOptions={textInputOptions} + searchValueForFocusSync={debouncedSearchValue} confirmButtonOptions={confirmButtonOptions} - initiallyFocusedItemKey={currentCountry} + initiallyFocusedItemKey={initiallyFocusedCountry} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} addBottomSafeAreaPadding shouldStopPropagation > diff --git a/tests/ui/BankAccountPurposeCountrySelectionTest.tsx b/tests/ui/BankAccountPurposeCountrySelectionTest.tsx new file mode 100644 index 000000000000..c3d9bac2ac65 --- /dev/null +++ b/tests/ui/BankAccountPurposeCountrySelectionTest.tsx @@ -0,0 +1,89 @@ +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import {View} from 'react-native'; +import CountrySelection from '@pages/settings/Wallet/BankAccountPurposePage/substeps/CountrySelection'; +import CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList'; +import {clearReimbursementAccount, clearReimbursementAccountDraft, navigateToBankAccountRoute, updateReimbursementAccountDraft} from '@userActions/ReimbursementAccount'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +jest.mock('@components/FormAlertWithSubmitButton', () => jest.fn(() => null)); +jest.mock('@pages/settings/Wallet/CountrySelectionList', () => jest.fn(() => null)); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, jest.fn()])); +jest.mock('@hooks/usePersonalPolicy', () => jest.fn(() => ({outputCurrency: undefined}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + mt5: {}, + })), +); +jest.mock('@userActions/ReimbursementAccount', () => ({ + clearReimbursementAccount: jest.fn(), + clearReimbursementAccountDraft: jest.fn(), + navigateToBankAccountRoute: jest.fn(), + updateReimbursementAccountDraft: jest.fn(), +})); + +describe('BankAccountPurpose CountrySelection', () => { + const mockedCountrySelectionList = jest.mocked(CountrySelectionList); + const mockedClearReimbursementAccount = jest.mocked(clearReimbursementAccount); + const mockedClearReimbursementAccountDraft = jest.mocked(clearReimbursementAccountDraft); + const mockedNavigateToBankAccountRoute = jest.mocked(navigateToBankAccountRoute); + const mockedUpdateReimbursementAccountDraft = jest.mocked(updateReimbursementAccountDraft); + + let mockMountCount = 0; + let mockUnmountCount = 0; + + beforeEach(() => { + mockMountCount = 0; + mockUnmountCount = 0; + mockedCountrySelectionList.mockClear(); + mockedClearReimbursementAccount.mockClear(); + mockedClearReimbursementAccountDraft.mockClear(); + mockedNavigateToBankAccountRoute.mockClear(); + mockedUpdateReimbursementAccountDraft.mockClear(); + mockedCountrySelectionList.mockImplementation(() => { + React.useEffect(() => { + mockMountCount += 1; + + return () => { + mockUnmountCount += 1; + }; + }, []); + + return ; + }); + }); + + it('keeps the child list mounted while persisting the selected country and navigating', () => { + render(); + + const initialProps = mockedCountrySelectionList.mock.lastCall?.[0]; + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + + act(() => { + initialProps?.onCountrySelected('LT'); + }); + + const updatedSelectionProps = mockedCountrySelectionList.mock.lastCall?.[0]; + expect(updatedSelectionProps?.selectedCountry).toBe('LT'); + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + + act(() => { + updatedSelectionProps?.onConfirm(); + }); + + expect(mockedClearReimbursementAccount).toHaveBeenCalled(); + expect(mockedClearReimbursementAccountDraft).toHaveBeenCalled(); + expect(mockedUpdateReimbursementAccountDraft).toHaveBeenCalledWith({country: 'LT', currency: CONST.BBA_COUNTRY_CURRENCY_MAP.LT}); + expect(mockedNavigateToBankAccountRoute).toHaveBeenCalledWith({backTo: ROUTES.SETTINGS_BANK_ACCOUNT_PURPOSE}); + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + }); +}); diff --git a/tests/ui/CountrySelectionListTest.tsx b/tests/ui/CountrySelectionListTest.tsx new file mode 100644 index 000000000000..f80ccd40e6ac --- /dev/null +++ b/tests/ui/CountrySelectionListTest.tsx @@ -0,0 +1,221 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import {View} from 'react-native'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList'; +import CONST from '@src/CONST'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; +let mockFocusEffectCallbacks: Array<() => void> = []; +let mockMountCount = 0; +let mockUnmountCount = 0; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn((callback: () => void) => { + mockFocusEffectCallbacks.push(callback); + }), + }; +}); + +jest.mock('@components/BlockingViews/FullPageOfflineBlockingView', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + ph5: {}, + textHeadlineLineHeightXXL: {}, + mb6: {}, + mt5: {}, + })), +); +jest.mock('@src/components/Text', () => jest.fn(() => null)); + +describe('CountrySelectionList', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const countries = Object.keys(CONST.ALL_COUNTRIES).slice(0, CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2); + const initialCountry = countries.at(-1) ?? ''; + const updatedCountry = countries.at(-2) ?? ''; + + beforeEach(() => { + mockFocusEffectCallbacks = []; + mockMountCount = 0; + mockUnmountCount = 0; + mockedSelectionList.mockClear(); + mockedSelectionList.mockImplementation(() => { + React.useEffect(() => { + mockMountCount += 1; + + return () => { + mockUnmountCount += 1; + }; + }, []); + + return ; + }); + }); + + it('pins the saved country to the top on reopen and disables focus-driven scroll', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: initialCountry, + value: initialCountry, + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(initialCountry); + expect(selectionListProps?.searchValueForFocusSync).toBe(''); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBe(true); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + }); + + it('remounts the inner selection list when the pinned initial selection changes on focus return', () => { + const {rerender} = render( + , + ); + + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + + rerender( + , + ); + + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + + act(() => { + for (const callback of mockFocusEffectCallbacks.slice(-1)) { + callback(); + } + }); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: updatedCountry, + value: updatedCountry, + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(updatedCountry); + expect(mockMountCount).toBe(2); + expect(mockUnmountCount).toBe(1); + }); + + it('keeps the initially pinned country at the top while the live selection changes during the same mount', () => { + const {rerender} = render( + , + ); + + rerender( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: initialCountry, + isSelected: false, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(initialCountry); + expect(selectionListProps?.data.find((item) => item.keyForList === updatedCountry)).toEqual( + expect.objectContaining({ + keyForList: updatedCountry, + isSelected: true, + }), + ); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + countries.map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: countryISO === initialCountry, + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + expect(searchedProps?.searchValueForFocusSync).toBe('Uni'); + }); +}); diff --git a/tests/ui/CountrySelectorModalTest.tsx b/tests/ui/CountrySelectorModalTest.tsx new file mode 100644 index 000000000000..c51ff2f39da7 --- /dev/null +++ b/tests/ui/CountrySelectorModalTest.tsx @@ -0,0 +1,111 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import CountrySelectorModal from '@components/CountryPicker/CountrySelectorModal'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import CONST from '@src/CONST'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + pb0: {}, + })), +); + +describe('CountrySelectorModal', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved country to the top on reopen', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + value: 'US', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('US'); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: countryISO === 'US', + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/DynamicCountrySelectionPageTest.tsx b/tests/ui/DynamicCountrySelectionPageTest.tsx new file mode 100644 index 000000000000..f1fcad19e2e6 --- /dev/null +++ b/tests/ui/DynamicCountrySelectionPageTest.tsx @@ -0,0 +1,105 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import DynamicCountrySelectionPage from '@pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage'; +import CONST from '@src/CONST'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => 'settings/profile/address')); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), +})); + +describe('DynamicCountrySelectionPage', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved country to the top on reopen and wires debounced focus sync', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + value: 'US', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('US'); + expect(selectionListProps?.searchValueForFocusSync).toBe(''); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: countryISO === 'US', + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + expect(searchedProps?.searchValueForFocusSync).toBe('Uni'); + }); +}); diff --git a/tests/ui/PushRowModalTest.tsx b/tests/ui/PushRowModalTest.tsx new file mode 100644 index 000000000000..5ab2d4370d59 --- /dev/null +++ b/tests/ui/PushRowModalTest.tsx @@ -0,0 +1,112 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import PushRowModal from '@src/components/PushRowWithModal/PushRowModal'; + +const mockUseState = React.useState; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); + +describe('PushRowModal', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const optionsList = { + one: 'Option 1', + two: 'Option 2', + three: 'Option 3', + four: 'Option 4', + five: 'Option 5', + six: 'Option 6', + seven: 'Option 7', + eight: 'Option 8', + nine: 'Option 9', + ten: 'Option 10', + }; + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved option to the top on reopen', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'ten', + value: 'ten', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('ten'); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Option 1'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Option 1', + Object.entries(optionsList).map(([key, value]) => ({ + value: key, + keyForList: key, + text: value, + isSelected: key === 'ten', + searchValue: StringUtils.sanitizeString(value), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/SelectCountryStepTest.tsx b/tests/ui/SelectCountryStepTest.tsx new file mode 100644 index 000000000000..a67a5d2cd29a --- /dev/null +++ b/tests/ui/SelectCountryStepTest.tsx @@ -0,0 +1,180 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import useOnyx from '@hooks/useOnyx'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import SelectCountryStep from '@pages/workspace/companyCards/addNew/SelectCountryStep'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + useRoute: jest.fn(() => ({params: {backTo: ''}})), + }; +}); + +jest.mock('@components/FormHelpMessage', () => jest.fn(() => null)); +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/Text', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListState: jest.fn(() => ({ + currencyList: {}, + })), +})); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/usePolicy', () => jest.fn(() => ({outputCurrency: 'USD'}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + textHeadlineLineHeightXXL: {}, + ph5: {}, + mv3: {}, + ph3: {}, + mb3: {}, + })), +); +jest.mock('@libs/CardUtils', () => ({ + getPlaidCountry: jest.fn(() => 'US'), + isPlaidSupportedCountry: jest.fn(() => true), +})); +jest.mock('@navigation/Navigation', () => ({ + goBack: jest.fn(), + navigate: jest.fn(), +})); +jest.mock('@userActions/CompanyCards', () => ({ + clearAddNewCardFlow: jest.fn(), + setAddNewCompanyCardStepAndData: jest.fn(), +})); + +describe('SelectCountryStep', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const mockedUseOnyx = jest.mocked(useOnyx); + + let addNewCardCountry: string | undefined; + + beforeEach(() => { + addNewCardCountry = undefined; + mockedSelectionList.mockClear(); + mockedUseOnyx.mockImplementation((key) => { + if (key === ONYXKEYS.COUNTRY) { + return ['US', jest.fn()] as never; + } + + if (key === ONYXKEYS.ADD_NEW_COMPANY_CARD) { + return [{data: {selectedCountry: addNewCardCountry}}, jest.fn()] as never; + } + + return [undefined, jest.fn()] as never; + }); + }); + + it('pins the saved country to the top on reopen and disables focus-driven scroll', () => { + addNewCardCountry = 'US'; + + render(); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + value: 'US', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('US'); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBe(true); + }); + + it('keeps the initially pinned country at the top while the live selection changes during the same mount', () => { + addNewCardCountry = 'US'; + + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + const selectedCountry = initialProps?.data.find((item) => item.keyForList === 'GB'); + + expect(selectedCountry).toBeDefined(); + + act(() => { + if (!selectedCountry) { + return; + } + + initialProps?.onSelectRow?.(selectedCountry); + }); + + const updatedProps = mockedSelectionList.mock.lastCall?.[0]; + expect(updatedProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + isSelected: false, + }), + ); + expect(updatedProps?.initiallyFocusedItemKey).toBe('US'); + expect(updatedProps?.data.find((item) => item.keyForList === 'GB')).toEqual( + expect.objectContaining({ + keyForList: 'GB', + isSelected: true, + }), + ); + }); + + it('keeps natural filtered ordering while search is active', () => { + addNewCardCountry = 'US'; + + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + Object.keys(CONST.ALL_COUNTRIES) + .filter((countryISO) => !CONST.PLAID_EXCLUDED_COUNTRIES.includes(countryISO)) + .map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: false, + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/StateSelectionPageTest.tsx b/tests/ui/StateSelectionPageTest.tsx new file mode 100644 index 000000000000..b08dcaa591a6 --- /dev/null +++ b/tests/ui/StateSelectionPageTest.tsx @@ -0,0 +1,101 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import {CONST as COMMON_CONST} from 'expensify-common'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import StateSelectionPage from '@pages/settings/Profile/PersonalDetails/StateSelectionPage'; + +const mockUseState = React.useState; +const mockStates = COMMON_CONST.STATES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + useRoute: jest.fn(() => ({params: {state: 'NY', label: 'State', backTo: ''}})), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (!key.startsWith('allStates.')) { + return key; + } + + const [, stateKey, property] = key.split('.'); + const state = mockStates[stateKey as keyof typeof mockStates]; + + if (property === 'stateName') { + return state.stateName; + } + + return state.stateISO; + }, + })), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), +})); + +describe('StateSelectionPage', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved state to the top on reopen and wires debounced focus sync', () => { + render(); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'NY', + value: 'NY', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('NY'); + expect(selectionListProps?.searchValueForFocusSync).toBe(''); + }); + + it('keeps natural filtered ordering while search is active', () => { + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('New'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'New', + Object.keys(mockStates).map((state) => ({ + value: mockStates[state as keyof typeof mockStates].stateISO, + keyForList: mockStates[state as keyof typeof mockStates].stateISO, + text: mockStates[state as keyof typeof mockStates].stateName, + isSelected: mockStates[state as keyof typeof mockStates].stateISO === 'NY', + searchValue: StringUtils.sanitizeString(`${mockStates[state as keyof typeof mockStates].stateISO}${mockStates[state as keyof typeof mockStates].stateName}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + expect(searchedProps?.searchValueForFocusSync).toBe('New'); + }); +}); diff --git a/tests/ui/StateSelectorModalTest.tsx b/tests/ui/StateSelectorModalTest.tsx new file mode 100644 index 000000000000..4df6f34618b2 --- /dev/null +++ b/tests/ui/StateSelectorModalTest.tsx @@ -0,0 +1,117 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import {CONST as COMMON_CONST} from 'expensify-common'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import StateSelectorModal from '@components/StatePicker/StateSelectorModal'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; + +const mockUseState = React.useState; +const mockStates = COMMON_CONST.STATES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (!key.startsWith('allStates.')) { + return key; + } + + const [, stateKey, property] = key.split('.'); + const state = mockStates[stateKey as keyof typeof mockStates]; + + if (property === 'stateName') { + return state.stateName; + } + + return state.stateISO; + }, + })), +); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + pb0: {}, + })), +); + +describe('StateSelectorModal', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved state to the top on reopen', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'NY', + value: 'NY', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('NY'); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('New'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'New', + Object.keys(mockStates).map((state) => ({ + value: mockStates[state as keyof typeof mockStates].stateISO, + keyForList: mockStates[state as keyof typeof mockStates].stateISO, + text: mockStates[state as keyof typeof mockStates].stateName, + isSelected: mockStates[state as keyof typeof mockStates].stateISO === 'NY', + searchValue: StringUtils.sanitizeString(`${mockStates[state as keyof typeof mockStates].stateISO}${mockStates[state as keyof typeof mockStates].stateName}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/ValueSelectionListTest.tsx b/tests/ui/ValueSelectionListTest.tsx new file mode 100644 index 000000000000..bfc4e9bad4f0 --- /dev/null +++ b/tests/ui/ValueSelectionListTest.tsx @@ -0,0 +1,86 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import ValueSelectionList from '@components/ValuePicker/ValueSelectionList'; +import CONST from '@src/CONST'; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); + +describe('ValueSelectionList', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => ({ + value: `value-${index}`, + label: `Label ${index}`, + description: `Description ${index}`, + })); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the initial value to the top and disables focus-driven scroll', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: items.at(-1)?.value, + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(items.at(-1)?.value); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBeUndefined(); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + }); + + it('keeps the initially pinned value at the top while the live selection changes during the same mount', () => { + const {rerender} = render( + , + ); + + rerender( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: items.at(-1)?.value, + isSelected: false, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(items.at(-1)?.value); + expect(selectionListProps?.data.find((item) => item.keyForList === items.at(-2)?.value)).toEqual( + expect.objectContaining({ + keyForList: items.at(-2)?.value, + isSelected: true, + }), + ); + }); +}); diff --git a/tests/ui/ValueSelectorModalTest.tsx b/tests/ui/ValueSelectorModalTest.tsx new file mode 100644 index 000000000000..77c71176f8bf --- /dev/null +++ b/tests/ui/ValueSelectorModalTest.tsx @@ -0,0 +1,35 @@ +import {render} from '@testing-library/react-native'; +import React from 'react'; +import ValueSelectionList from '@components/ValuePicker/ValueSelectionList'; +import ValueSelectorModal from '@components/ValuePicker/ValueSelectorModal'; + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ValuePicker/ValueSelectionList', () => jest.fn(() => null)); + +describe('ValueSelectorModal', () => { + const mockedValueSelectionList = jest.mocked(ValueSelectionList); + + beforeEach(() => { + mockedValueSelectionList.mockClear(); + }); + + it('forwards modal visibility to ValueSelectionList', () => { + render( + , + ); + + expect(mockedValueSelectionList.mock.lastCall?.[0]).toEqual(expect.objectContaining({isVisible: true})); + }); +}); diff --git a/tests/unit/SelectionListOrderUtilsTest.ts b/tests/unit/SelectionListOrderUtilsTest.ts new file mode 100644 index 000000000000..71c93d962755 --- /dev/null +++ b/tests/unit/SelectionListOrderUtilsTest.ts @@ -0,0 +1,31 @@ +import {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import CONST from '@src/CONST'; + +describe('SelectionListOrderUtils', () => { + it('does not reorder keys when there is no initial selection', () => { + const keys = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => `item-${index}`); + + expect(moveInitialSelectionToTopByKey(keys, [])).toEqual(keys); + }); + + it('does not reorder values when the list is under the global threshold', () => { + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}, (_, index) => ({ + value: `item-${index}`, + keyForList: `item-${index}`, + })); + + expect(moveInitialSelectionToTopByValue(items, ['item-3'])).toEqual(items); + }); + + it('moves the initially selected values to the top while preserving source order', () => { + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => ({ + value: `item-${index}`, + keyForList: `item-${index}`, + })); + const selectedValues = [`item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}`, `item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 1}`]; + + const reorderedItems = moveInitialSelectionToTopByValue(items, selectedValues); + + expect(reorderedItems.map((item) => item.value)).toEqual([...selectedValues, ...items.filter((item) => !selectedValues.includes(item.value)).map((item) => item.value)]); + }); +}); diff --git a/tests/unit/components/SelectionList/useSearchFocusSync.test.ts b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts new file mode 100644 index 000000000000..d0ac6349ab85 --- /dev/null +++ b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts @@ -0,0 +1,44 @@ +import {renderHook} from '@testing-library/react-native'; +import useSearchFocusSync from '@components/SelectionList/hooks/useSearchFocusSync'; + +type MockItem = { + keyForList: string; + isSelected?: boolean; +}; + +describe('useSearchFocusSync', () => { + it('focuses the selected row when the debounced search is cleared and the full list returns', () => { + const scrollToIndex = jest.fn(); + const setFocusedIndex = jest.fn(); + const filteredData: MockItem[] = [{keyForList: 'match'}]; + const fullData: MockItem[] = [{keyForList: 'a'}, {keyForList: 'b'}, {keyForList: 'selected', isSelected: true}, {keyForList: 'c'}]; + + const {rerender} = renderHook( + ({searchValue, data}: {searchValue: string; data: MockItem[]}) => + useSearchFocusSync({ + searchValue, + data, + selectedOptionsCount: data.filter((item) => item.isSelected).length, + isItemSelected: (item) => !!item.isSelected, + canSelectMultiple: false, + shouldUpdateFocusedIndex: false, + scrollToIndex, + setFocusedIndex, + }), + { + initialProps: { + searchValue: 'uni', + data: filteredData, + }, + }, + ); + + rerender({ + searchValue: '', + data: fullData, + }); + + expect(scrollToIndex).toHaveBeenCalledWith(2); + expect(setFocusedIndex).toHaveBeenCalledWith(2); + }); +}); From 92c42f61d7571261dc4972a5b7972749a87f4d9d Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Wed, 6 May 2026 15:03:11 +0430 Subject: [PATCH 02/12] refactor: update mock for SelectionList items to use SingleSelectListItem --- tests/ui/CountrySelectionListTest.tsx | 2 +- tests/ui/CountrySelectorModalTest.tsx | 2 +- tests/ui/DynamicCountrySelectionPageTest.tsx | 2 +- tests/ui/PushRowModalTest.tsx | 2 +- tests/ui/SelectCountryStepTest.tsx | 2 +- tests/ui/StateSelectionPageTest.tsx | 2 +- tests/ui/StateSelectorModalTest.tsx | 2 +- tests/ui/ValueSelectionListTest.tsx | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/ui/CountrySelectionListTest.tsx b/tests/ui/CountrySelectionListTest.tsx index f80ccd40e6ac..59bb755da00c 100644 --- a/tests/ui/CountrySelectionListTest.tsx +++ b/tests/ui/CountrySelectionListTest.tsx @@ -27,7 +27,7 @@ jest.mock('@react-navigation/native', () => { jest.mock('@components/BlockingViews/FullPageOfflineBlockingView', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); jest.mock('@hooks/useDebouncedState', () => jest.fn((initialValue: string) => { const [value, setValue] = mockUseState(initialValue); diff --git a/tests/ui/CountrySelectorModalTest.tsx b/tests/ui/CountrySelectorModalTest.tsx index c51ff2f39da7..799a5eac7a51 100644 --- a/tests/ui/CountrySelectorModalTest.tsx +++ b/tests/ui/CountrySelectorModalTest.tsx @@ -23,7 +23,7 @@ jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); jest.mock('@hooks/useDebouncedState', () => jest.fn((initialValue: string) => { const [value, setValue] = mockUseState(initialValue); diff --git a/tests/ui/DynamicCountrySelectionPageTest.tsx b/tests/ui/DynamicCountrySelectionPageTest.tsx index f1fcad19e2e6..7ebd267db89e 100644 --- a/tests/ui/DynamicCountrySelectionPageTest.tsx +++ b/tests/ui/DynamicCountrySelectionPageTest.tsx @@ -22,7 +22,7 @@ jest.mock('@react-navigation/native', () => { jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); jest.mock('@hooks/useDebouncedState', () => jest.fn((initialValue: string) => { const [value, setValue] = mockUseState(initialValue); diff --git a/tests/ui/PushRowModalTest.tsx b/tests/ui/PushRowModalTest.tsx index 5ab2d4370d59..30a39cccf0fb 100644 --- a/tests/ui/PushRowModalTest.tsx +++ b/tests/ui/PushRowModalTest.tsx @@ -21,7 +21,7 @@ jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); jest.mock('@hooks/useDebouncedState', () => jest.fn((initialValue: string) => { const [value, setValue] = mockUseState(initialValue); diff --git a/tests/ui/SelectCountryStepTest.tsx b/tests/ui/SelectCountryStepTest.tsx index a67a5d2cd29a..bc151dd74282 100644 --- a/tests/ui/SelectCountryStepTest.tsx +++ b/tests/ui/SelectCountryStepTest.tsx @@ -26,7 +26,7 @@ jest.mock('@components/FormHelpMessage', () => jest.fn(() => null)); jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); jest.mock('@components/Text', () => jest.fn(() => null)); jest.mock('@hooks/useCurrencyList', () => ({ useCurrencyListState: jest.fn(() => ({ diff --git a/tests/ui/StateSelectionPageTest.tsx b/tests/ui/StateSelectionPageTest.tsx index b08dcaa591a6..1f9c92a49c5d 100644 --- a/tests/ui/StateSelectionPageTest.tsx +++ b/tests/ui/StateSelectionPageTest.tsx @@ -23,7 +23,7 @@ jest.mock('@react-navigation/native', () => { jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); jest.mock('@hooks/useDebouncedState', () => jest.fn((initialValue: string) => { const [value, setValue] = mockUseState(initialValue); diff --git a/tests/ui/StateSelectorModalTest.tsx b/tests/ui/StateSelectorModalTest.tsx index 4df6f34618b2..3e484d67b8fa 100644 --- a/tests/ui/StateSelectorModalTest.tsx +++ b/tests/ui/StateSelectorModalTest.tsx @@ -23,7 +23,7 @@ jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); jest.mock('@hooks/useDebouncedState', () => jest.fn((initialValue: string) => { const [value, setValue] = mockUseState(initialValue); diff --git a/tests/ui/ValueSelectionListTest.tsx b/tests/ui/ValueSelectionListTest.tsx index bfc4e9bad4f0..4114ba4d9947 100644 --- a/tests/ui/ValueSelectionListTest.tsx +++ b/tests/ui/ValueSelectionListTest.tsx @@ -15,7 +15,7 @@ jest.mock('@react-navigation/native', () => { }); jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/RadioListItem', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); describe('ValueSelectionList', () => { const mockedSelectionList = jest.mocked(SelectionList); From 9dea8f002e183eea95c66dbe55aca1395abe7e4d Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Wed, 6 May 2026 15:15:39 +0430 Subject: [PATCH 03/12] test: update search focus sync scroll expectation --- tests/unit/components/SelectionList/useSearchFocusSync.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/components/SelectionList/useSearchFocusSync.test.ts b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts index d0ac6349ab85..b40b53e8ce2b 100644 --- a/tests/unit/components/SelectionList/useSearchFocusSync.test.ts +++ b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts @@ -38,7 +38,7 @@ describe('useSearchFocusSync', () => { data: fullData, }); - expect(scrollToIndex).toHaveBeenCalledWith(2); + expect(scrollToIndex).toHaveBeenCalledWith(2, false); expect(setFocusedIndex).toHaveBeenCalledWith(2); }); }); From 755e98043d39c8951497b1c1172c8924ecbf504b Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Wed, 6 May 2026 15:43:39 +0430 Subject: [PATCH 04/12] refactor: consolidate initial selection list ordering helper Co-authored-by: Copilot --- .../CountryPicker/CountrySelectorModal.tsx | 4 +-- .../PushRowWithModal/PushRowModal.tsx | 4 +-- .../StatePicker/StateSelectorModal.tsx | 4 +-- .../ValuePicker/ValueSelectionList.tsx | 4 +-- src/libs/SelectionListOrderUtils.ts | 31 +++---------------- .../DynamicCountrySelectionPage.tsx | 4 +-- .../PersonalDetails/StateSelectionPage.tsx | 4 +-- .../settings/Wallet/CountrySelectionList.tsx | 4 +-- .../companyCards/addNew/SelectCountryStep.tsx | 4 +-- tests/unit/SelectionListOrderUtilsTest.ts | 12 ++----- 10 files changed, 24 insertions(+), 51 deletions(-) diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index 2867981d8561..97b2302f5a87 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -10,7 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -56,7 +56,7 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC }), [translate, currentCountry], ); - const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues, (item) => item.value); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index 6600cd3717da..c46f5c6ad6a1 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -8,7 +8,7 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import searchOptions from '@libs/searchOptions'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -62,7 +62,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio [optionsList, selectedOption], ); - const orderedOptions = moveInitialSelectionToTopByValue(options, initialSelectedValues); + const orderedOptions = moveInitialSelectionToTop(options, initialSelectedValues, (item) => item.value); const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 566d884b549e..64153a99907d 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -11,7 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -62,7 +62,7 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const orderedCountryStates = moveInitialSelectionToTopByValue(countryStates, initialSelectedValues); + const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues, (item) => item.value); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const textInputOptions = useMemo( diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index e751ca155d7c..60119b10cbc5 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -2,7 +2,7 @@ import React, {useMemo} from 'react'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import useInitialSelection from '@hooks/useInitialSelection'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import type {ValueSelectionListProps} from './types'; function ValueSelectionList({ @@ -20,7 +20,7 @@ function ValueSelectionList({ const options = useMemo(() => { const mappedOptions = items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})); - const orderedOptions = moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValue ? [initialSelectedValue] : []); + const orderedOptions = moveInitialSelectionToTop(mappedOptions, initialSelectedValue ? [initialSelectedValue] : [], (item) => item.value); return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})); }, [initialSelectedValue, items, selectedItem?.value]); diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index 29cccec119b3..7fc423e7c2fc 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -1,37 +1,16 @@ import CONST from '@src/CONST'; -function moveInitialSelectionToTopByKey(keys: string[], initialSelectedKeys: string[]): string[] { - if (initialSelectedKeys.length === 0 || keys.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { - return keys; - } - - const selectedKeys = new Set(initialSelectedKeys); - const selected: string[] = []; - const remaining: string[] = []; - - for (const key of keys) { - if (selectedKeys.has(key)) { - selected.push(key); - continue; - } - - remaining.push(key); - } - - return [...selected, ...remaining]; -} - -function moveInitialSelectionToTopByValue(items: T[], initialSelectedValues: string[]): T[] { - if (initialSelectedValues.length === 0 || items.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { +function moveInitialSelectionToTop(items: T[], initialSelectedKeys: string[], getKey: (item: T) => string): T[] { + if (initialSelectedKeys.length === 0 || items.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { return items; } - const selectedValues = new Set(initialSelectedValues); + const selectedKeys = new Set(initialSelectedKeys); const selected: T[] = []; const remaining: T[] = []; for (const item of items) { - if (selectedValues.has(item.value)) { + if (selectedKeys.has(getKey(item))) { selected.push(item); continue; } @@ -42,4 +21,4 @@ function moveInitialSelectionToTopByValue(items: T[], return [...selected, ...remaining]; } -export {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue}; +export default moveInitialSelectionToTop; diff --git a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx index adcda6e40923..0b469e100206 100644 --- a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx @@ -12,7 +12,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import type {Option} from '@libs/searchOptions'; import searchOptions from '@libs/searchOptions'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import {appendParam} from '@libs/Url'; import CONST from '@src/CONST'; @@ -45,7 +45,7 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) [translate, currentCountry], ); - const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues, (item) => item.value); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const selectCountry = useCallback( diff --git a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx index 87266acfb63e..cc207792bcf4 100644 --- a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx @@ -12,7 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import {appendParam} from '@libs/Url'; import type {Route} from '@src/ROUTES'; @@ -53,7 +53,7 @@ function StateSelectionPage() { [translate, currentState], ); - const orderedCountryStates = moveInitialSelectionToTopByValue(countryStates, initialSelectedValues); + const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues, (item) => item.value); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; diff --git a/src/pages/settings/Wallet/CountrySelectionList.tsx b/src/pages/settings/Wallet/CountrySelectionList.tsx index de5c025c5da2..986cd47aa81e 100644 --- a/src/pages/settings/Wallet/CountrySelectionList.tsx +++ b/src/pages/settings/Wallet/CountrySelectionList.tsx @@ -10,7 +10,7 @@ import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Text from '@src/components/Text'; import type {TranslationPaths} from '@src/languages/types'; @@ -58,7 +58,7 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS }; }); - const orderedCountries = moveInitialSelectionToTopByValue(countriesList, initialSelectedValues); + const orderedCountries = moveInitialSelectionToTop(countriesList, initialSelectedValues, (item) => item.value); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countriesList : orderedCountries); const textInputOptions = { diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index 24e034f1ef8c..bb1807b25f59 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -16,7 +16,7 @@ import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {getPlaidCountry, isPlaidSupportedCountry} from '@libs/CardUtils'; import searchOptions from '@libs/searchOptions'; -import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; @@ -89,7 +89,7 @@ function SelectCountryStep({policyID}: CountryStepProps) { searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), }; }); - const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues, (item) => item.value); const filteredCountries = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const searchResults = filteredCountries.map((country) => ({...country, isSelected: currentCountry === country.value})); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; diff --git a/tests/unit/SelectionListOrderUtilsTest.ts b/tests/unit/SelectionListOrderUtilsTest.ts index 71c93d962755..a58ffcef1f0c 100644 --- a/tests/unit/SelectionListOrderUtilsTest.ts +++ b/tests/unit/SelectionListOrderUtilsTest.ts @@ -1,20 +1,14 @@ -import {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; describe('SelectionListOrderUtils', () => { - it('does not reorder keys when there is no initial selection', () => { - const keys = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => `item-${index}`); - - expect(moveInitialSelectionToTopByKey(keys, [])).toEqual(keys); - }); - it('does not reorder values when the list is under the global threshold', () => { const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}, (_, index) => ({ value: `item-${index}`, keyForList: `item-${index}`, })); - expect(moveInitialSelectionToTopByValue(items, ['item-3'])).toEqual(items); + expect(moveInitialSelectionToTop(items, ['item-3'], (item) => item.value)).toEqual(items); }); it('moves the initially selected values to the top while preserving source order', () => { @@ -24,7 +18,7 @@ describe('SelectionListOrderUtils', () => { })); const selectedValues = [`item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}`, `item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 1}`]; - const reorderedItems = moveInitialSelectionToTopByValue(items, selectedValues); + const reorderedItems = moveInitialSelectionToTop(items, selectedValues, (item) => item.value); expect(reorderedItems.map((item) => item.value)).toEqual([...selectedValues, ...items.filter((item) => !selectedValues.includes(item.value)).map((item) => item.value)]); }); From 1e044e6b7fd5ce2a3bc8e0f5b3f32ae0eb242064 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Wed, 6 May 2026 21:01:59 +0430 Subject: [PATCH 05/12] simplify moveInitialSelectionToTop function by removing unused key extraction --- src/components/CountryPicker/CountrySelectorModal.tsx | 2 +- src/components/PushRowWithModal/PushRowModal.tsx | 2 +- src/components/StatePicker/StateSelectorModal.tsx | 2 +- src/components/ValuePicker/ValueSelectionList.tsx | 2 +- src/libs/SelectionListOrderUtils.ts | 8 ++++---- .../PersonalDetails/DynamicCountrySelectionPage.tsx | 2 +- .../Profile/PersonalDetails/StateSelectionPage.tsx | 2 +- src/pages/settings/Wallet/CountrySelectionList.tsx | 2 +- .../workspace/companyCards/addNew/SelectCountryStep.tsx | 2 +- tests/unit/SelectionListOrderUtilsTest.ts | 4 ++-- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index 97b2302f5a87..8c800d6ec70d 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -56,7 +56,7 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC }), [translate, currentCountry], ); - const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues, (item) => item.value); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index c46f5c6ad6a1..8bb173e65a63 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -62,7 +62,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio [optionsList, selectedOption], ); - const orderedOptions = moveInitialSelectionToTop(options, initialSelectedValues, (item) => item.value); + const orderedOptions = moveInitialSelectionToTop(options, initialSelectedValues); const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 64153a99907d..f0f0754cbe00 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -62,7 +62,7 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues, (item) => item.value); + const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const textInputOptions = useMemo( diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index 60119b10cbc5..e9d1d187e216 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -20,7 +20,7 @@ function ValueSelectionList({ const options = useMemo(() => { const mappedOptions = items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})); - const orderedOptions = moveInitialSelectionToTop(mappedOptions, initialSelectedValue ? [initialSelectedValue] : [], (item) => item.value); + const orderedOptions = moveInitialSelectionToTop(mappedOptions, initialSelectedValue ? [initialSelectedValue] : []); return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})); }, [initialSelectedValue, items, selectedItem?.value]); diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index 7fc423e7c2fc..ec0e2ee261ec 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -1,16 +1,16 @@ import CONST from '@src/CONST'; -function moveInitialSelectionToTop(items: T[], initialSelectedKeys: string[], getKey: (item: T) => string): T[] { - if (initialSelectedKeys.length === 0 || items.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { +function moveInitialSelectionToTop(items: T[], initialSelectedValues: string[]): T[] { + if (initialSelectedValues.length === 0 || items.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { return items; } - const selectedKeys = new Set(initialSelectedKeys); + const selectedValues = new Set(initialSelectedValues); const selected: T[] = []; const remaining: T[] = []; for (const item of items) { - if (selectedKeys.has(getKey(item))) { + if (selectedValues.has(item.value)) { selected.push(item); continue; } diff --git a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx index 0b469e100206..c6d5153b25ee 100644 --- a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx @@ -45,7 +45,7 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) [translate, currentCountry], ); - const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues, (item) => item.value); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const selectCountry = useCallback( diff --git a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx index cc207792bcf4..261ab7a8e015 100644 --- a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx @@ -53,7 +53,7 @@ function StateSelectionPage() { [translate, currentState], ); - const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues, (item) => item.value); + const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; diff --git a/src/pages/settings/Wallet/CountrySelectionList.tsx b/src/pages/settings/Wallet/CountrySelectionList.tsx index 986cd47aa81e..cf38e518b339 100644 --- a/src/pages/settings/Wallet/CountrySelectionList.tsx +++ b/src/pages/settings/Wallet/CountrySelectionList.tsx @@ -58,7 +58,7 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS }; }); - const orderedCountries = moveInitialSelectionToTop(countriesList, initialSelectedValues, (item) => item.value); + const orderedCountries = moveInitialSelectionToTop(countriesList, initialSelectedValues); const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countriesList : orderedCountries); const textInputOptions = { diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index bb1807b25f59..47407fafa99f 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -89,7 +89,7 @@ function SelectCountryStep({policyID}: CountryStepProps) { searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), }; }); - const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues, (item) => item.value); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); const filteredCountries = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const searchResults = filteredCountries.map((country) => ({...country, isSelected: currentCountry === country.value})); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; diff --git a/tests/unit/SelectionListOrderUtilsTest.ts b/tests/unit/SelectionListOrderUtilsTest.ts index a58ffcef1f0c..4f926634e9d9 100644 --- a/tests/unit/SelectionListOrderUtilsTest.ts +++ b/tests/unit/SelectionListOrderUtilsTest.ts @@ -8,7 +8,7 @@ describe('SelectionListOrderUtils', () => { keyForList: `item-${index}`, })); - expect(moveInitialSelectionToTop(items, ['item-3'], (item) => item.value)).toEqual(items); + expect(moveInitialSelectionToTop(items, ['item-3'])).toEqual(items); }); it('moves the initially selected values to the top while preserving source order', () => { @@ -18,7 +18,7 @@ describe('SelectionListOrderUtils', () => { })); const selectedValues = [`item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}`, `item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 1}`]; - const reorderedItems = moveInitialSelectionToTop(items, selectedValues, (item) => item.value); + const reorderedItems = moveInitialSelectionToTop(items, selectedValues); expect(reorderedItems.map((item) => item.value)).toEqual([...selectedValues, ...items.filter((item) => !selectedValues.includes(item.value)).map((item) => item.value)]); }); From 8ed456e1848418488e7b0a72e48c8cc397da37f9 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Wed, 6 May 2026 21:35:26 +0430 Subject: [PATCH 06/12] fix: support focus sync search value in sectioned lists --- .../BaseSelectionListWithSections.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index d8d3c55a80ec..906d4fc8d20f 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -42,6 +42,7 @@ function BaseSelectionListWithSections({ ref, ListItem, textInputOptions, + searchValueForFocusSync, initiallyFocusedItemKey, confirmButtonOptions, initialScrollIndex, @@ -249,6 +250,7 @@ function BaseSelectionListWithSections({ // Disable `Enter` shortcut if the active element is a button, checkbox, or switch const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX, CONST.ROLE.SWITCH].includes(activeElementRole as InteractiveElementRoles); + const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedItem, { captureOnInputs: true, @@ -287,7 +289,7 @@ function BaseSelectionListWithSections({ initiallyFocusedItemKey, isItemSelected, focusedIndex, - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, setFocusedIndex, }); @@ -296,7 +298,7 @@ function BaseSelectionListWithSections({ }; useSearchFocusSync({ - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, data: flattenedData, selectedOptionsCount: selectedItems.length, isItemSelected, From fa28b1ccff1ec3702b170d15335dd89d54a1d89d Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Fri, 8 May 2026 10:32:52 +0430 Subject: [PATCH 07/12] add highlighting for initially focused and selected items in SelectionList --- src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx | 2 ++ tests/ui/SelectCountryStepTest.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index 47407fafa99f..8e4a880cb131 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -135,6 +135,8 @@ function SelectCountryStep({policyID}: CountryStepProps) { shouldUpdateFocusedIndex shouldScrollToFocusedIndex={false} shouldScrollToFocusedIndexOnMount={false} + shouldHighlightInitiallyFocusedItem + shouldHighlightSelectedItem addBottomSafeAreaPadding shouldStopPropagation > diff --git a/tests/ui/SelectCountryStepTest.tsx b/tests/ui/SelectCountryStepTest.tsx index bc151dd74282..31eccb158fef 100644 --- a/tests/ui/SelectCountryStepTest.tsx +++ b/tests/ui/SelectCountryStepTest.tsx @@ -114,6 +114,8 @@ describe('SelectCountryStep', () => { expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); expect(selectionListProps?.shouldUpdateFocusedIndex).toBe(true); + expect(selectionListProps?.shouldHighlightInitiallyFocusedItem).toBe(true); + expect(selectionListProps?.shouldHighlightSelectedItem).toBe(true); }); it('keeps the initially pinned country at the top while the live selection changes during the same mount', () => { From df4a0376c79955798003b87481ee6bbc0a2110d1 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Fri, 8 May 2026 10:49:09 +0430 Subject: [PATCH 08/12] remove redundant alias variables for initiallyFocusedItemKey --- src/components/CountryPicker/CountrySelectorModal.tsx | 3 +-- src/components/PushRowWithModal/PushRowModal.tsx | 3 +-- src/components/StatePicker/StateSelectorModal.tsx | 3 +-- src/components/ValuePicker/ValueSelectionList.tsx | 3 +-- src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index 8c800d6ec70d..af7e163f0737 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -40,7 +40,6 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const initialSelectedValue = useInitialSelection(currentCountry || undefined, {isVisible}); const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; - const initiallyFocusedCountry = initialSelectedValue; const countries = useMemo( () => @@ -97,7 +96,7 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC searchValueForFocusSync={debouncedSearchValue} onSelectRow={onCountrySelected} ListItem={SingleSelectListItem} - initiallyFocusedItemKey={initiallyFocusedCountry} + initiallyFocusedItemKey={initialSelectedValue} shouldSingleExecuteRowSelect shouldStopPropagation /> diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index 8bb173e65a63..4a93f370e6ff 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -48,7 +48,6 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const initialSelectedValue = useInitialSelection(selectedOption || undefined, {isVisible}); const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; - const initiallyFocusedOption = initialSelectedValue; const options = useMemo( () => @@ -110,7 +109,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio onSelectRow={handleSelectRow} textInputOptions={textInputOptions} searchValueForFocusSync={debouncedSearchValue} - initiallyFocusedItemKey={initiallyFocusedOption} + initiallyFocusedItemKey={initialSelectedValue} disableMaintainingScrollPosition shouldShowTooltips={false} showScrollIndicator diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index f0f0754cbe00..ee7bc9a6202e 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -43,7 +43,6 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, const styles = useThemeStyles(); const initialSelectedValue = useInitialSelection(currentState || undefined, {isVisible}); const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; - const initiallyFocusedState = initialSelectedValue; const countryStates = useMemo( () => @@ -100,7 +99,7 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, onSelectRow={onStateSelected} textInputOptions={textInputOptions} searchValueForFocusSync={debouncedSearchValue} - initiallyFocusedItemKey={initiallyFocusedState} + initiallyFocusedItemKey={initialSelectedValue} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldStopPropagation diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index e9d1d187e216..f6979c79d410 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -16,7 +16,6 @@ function ValueSelectionList({ isVisible, }: ValueSelectionListProps) { const initialSelectedValue = useInitialSelection(selectedItem?.value ? selectedItem.value : undefined, isVisible === undefined ? {resetOnFocus: true} : {isVisible}); - const initiallyFocusedItemKey = initialSelectedValue; const options = useMemo(() => { const mappedOptions = items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})); @@ -29,7 +28,7 @@ function ValueSelectionList({ onItemSelected?.(item)} - initiallyFocusedItemKey={initiallyFocusedItemKey} + initiallyFocusedItemKey={initialSelectedValue} shouldStopPropagation shouldShowTooltips={shouldShowTooltips} shouldScrollToFocusedIndex={false} diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index 8e4a880cb131..00aaabe85472 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -46,7 +46,6 @@ function SelectCountryStep({policyID}: CountryStepProps) { const currentCountry = selectedCountry ?? addNewCard?.data?.selectedCountry ?? getPlaidCountry(policy?.outputCurrency, currencyList, countryByIp); const initialSelectedValue = useInitialSelection(currentCountry || undefined, {resetOnFocus: true}); const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; - const initiallyFocusedCountry = initialSelectedValue; const [hasError, setHasError] = useState(false); const doesCountrySupportPlaid = isPlaidSupportedCountry(currentCountry); @@ -129,7 +128,7 @@ function SelectCountryStep({policyID}: CountryStepProps) { textInputOptions={textInputOptions} searchValueForFocusSync={debouncedSearchValue} confirmButtonOptions={confirmButtonOptions} - initiallyFocusedItemKey={initiallyFocusedCountry} + initiallyFocusedItemKey={initialSelectedValue} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldUpdateFocusedIndex From d28a9a1918a96eaf453dfb3b638d928bb1092e73 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Fri, 8 May 2026 14:18:28 +0430 Subject: [PATCH 09/12] remove highlighting for selected items --- src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index 00aaabe85472..a7e188994c86 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -134,8 +134,6 @@ function SelectCountryStep({policyID}: CountryStepProps) { shouldUpdateFocusedIndex shouldScrollToFocusedIndex={false} shouldScrollToFocusedIndexOnMount={false} - shouldHighlightInitiallyFocusedItem - shouldHighlightSelectedItem addBottomSafeAreaPadding shouldStopPropagation > From 75b570b39852f9a6256c3d44156185a5f6cfbe39 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Fri, 8 May 2026 14:33:30 +0430 Subject: [PATCH 10/12] remove unnecessary tests --- tests/ui/SelectCountryStepTest.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/ui/SelectCountryStepTest.tsx b/tests/ui/SelectCountryStepTest.tsx index 31eccb158fef..bc151dd74282 100644 --- a/tests/ui/SelectCountryStepTest.tsx +++ b/tests/ui/SelectCountryStepTest.tsx @@ -114,8 +114,6 @@ describe('SelectCountryStep', () => { expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); expect(selectionListProps?.shouldUpdateFocusedIndex).toBe(true); - expect(selectionListProps?.shouldHighlightInitiallyFocusedItem).toBe(true); - expect(selectionListProps?.shouldHighlightSelectedItem).toBe(true); }); it('keeps the initially pinned country at the top while the live selection changes during the same mount', () => { From ea4b42f4686f72cdc4e73523c66dfb570571f58b Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Fri, 8 May 2026 15:19:43 +0430 Subject: [PATCH 11/12] apply fix in personal card country picker --- .../PersonalCards/steps/SelectCountryStep.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx b/src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx index bb7557b3ec8b..91a7dc9d5c85 100644 --- a/src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx +++ b/src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx @@ -9,12 +9,14 @@ import Text from '@components/Text'; import {useCurrencyListState} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getPlaidCountry} from '@libs/CardUtils'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; import {clearAddNewPersonalCardFlow, setAddNewPersonalCardStepAndData} from '@userActions/PersonalCards'; @@ -41,6 +43,9 @@ function SelectCountryStep({disableAutoFocus}: {disableAutoFocus?: boolean}) { }; const [currentCountry, setCurrentCountry] = useState(getCountry); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const initialSelectedValue = useInitialSelection(currentCountry || undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const [hasError, setHasError] = useState(false); const isUS = currentCountry === CONST.COUNTRY.US; @@ -87,7 +92,9 @@ function SelectCountryStep({disableAutoFocus}: {disableAutoFocus?: boolean}) { const countries = getCountries(); - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); + const filteredCountries = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); + const searchResults = filteredCountries.map((country) => ({...country, isSelected: currentCountry === country.value})); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; return ( @@ -114,15 +121,18 @@ function SelectCountryStep({disableAutoFocus}: {disableAutoFocus?: boolean}) { onChangeText: setSearchValue, disableAutoFocus, }} + searchValueForFocusSync={debouncedSearchValue} confirmButtonOptions={{ onConfirm: submit, showButton: true, text: translate('common.next'), }} - initiallyFocusedItemKey={currentCountry} + initiallyFocusedItemKey={initialSelectedValue} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} addBottomSafeAreaPadding shouldStopPropagation > From dc0733a80a25de01f6930f6da72daae78d6115dc Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Fri, 8 May 2026 15:51:04 +0430 Subject: [PATCH 12/12] mock focused navigation state in country step test --- tests/ui/SelectCountryStepTest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui/SelectCountryStepTest.tsx b/tests/ui/SelectCountryStepTest.tsx index bc151dd74282..d5b797469c7e 100644 --- a/tests/ui/SelectCountryStepTest.tsx +++ b/tests/ui/SelectCountryStepTest.tsx @@ -18,6 +18,7 @@ jest.mock('@react-navigation/native', () => { return { ...actualNavigation, useFocusEffect: jest.fn(), + useIsFocused: jest.fn(() => true), useRoute: jest.fn(() => ({params: {backTo: ''}})), }; });