diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 552f96d55b20a..d146fb0eb763b 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -252,6 +252,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 a2f7e0fffeeb3..9d85f55d6ee3b 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, {resetDeps: [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 a2f94504d49a6..0c4acb2f7ddbd 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, {resetDeps: [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 a38baf7bdb373..ae38df2cdba97 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) => {
@@ -494,12 +496,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 e14207e1461cc..80d10a43728d7 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 d03e8183ca300..b66e2046264a5 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, {resetDeps: [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 cde8688085984..d212cd39d8259 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} : {resetDeps: [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 4aa1ba794ceda..24afba16afe7a 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 0000000000000..c61868e650e66
--- /dev/null
+++ b/src/hooks/useInitialSelection.ts
@@ -0,0 +1,50 @@
+import {useFocusEffect} from '@react-navigation/native';
+import type {DependencyList} from 'react';
+import {useCallback, useEffect, useRef, useState} from 'react';
+
+type UseInitialSelectionOptions = {
+ /** Dependencies that should trigger refreshing the snapshot (e.g., when a modal opens) */
+ resetDeps?: DependencyList;
+
+ /** 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 by changing `resetDeps` or via screen focus.
+ */
+function useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) {
+ const {resetDeps = [], resetOnFocus = false} = options;
+ const [initialSelection, setInitialSelection] = useState(selection);
+ const latestSelectionRef = useRef(selection);
+
+ const updateInitialSelection = useCallback((nextSelection: T) => {
+ setInitialSelection((previousSelection) => (Object.is(previousSelection, nextSelection) ? previousSelection : nextSelection));
+ }, []);
+
+ useEffect(() => {
+ latestSelectionRef.current = selection;
+ }, [selection]);
+
+ useEffect(() => {
+ // Intentionally refresh the snapshot only when the caller marks a new open/focus cycle.
+ // Live selection changes while the picker stays open should not repin or refocus the list.
+ updateInitialSelection(selection);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, resetDeps);
+
+ 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 0000000000000..29cccec119b3b
--- /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 e83eecb21dd90..210fe5586283c 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 27148e19171c3..684b0ae223f60 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 30c39317c1f60..a80f94f17f710 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 = {
@@ -79,12 +85,14 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS
ListItem={RadioListItem}
onSelectRow={onSelectionChange}
textInputOptions={textInputOptions}
+ searchValueForFocusSync={debouncedSearchValue}
confirmButtonOptions={confirmButtonOptions}
- initiallyFocusedItemKey={selectedCountry}
+ initiallyFocusedItemKey={initialSelectedValue}
footerContent={footerContent}
disableMaintainingScrollPosition
shouldSingleExecuteRowSelect
- shouldUpdateFocusedIndex
+ shouldScrollToFocusedIndex={false}
+ shouldScrollToFocusedIndexOnMount={false}
shouldStopPropagation
/>
diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx
index 6aac0599a93b5..43e1532ebb58c 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,13 @@ 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/CountrySelectionListTest.tsx b/tests/ui/CountrySelectionListTest.tsx
new file mode 100644
index 0000000000000..abe5e7561bcca
--- /dev/null
+++ b/tests/ui/CountrySelectionListTest.tsx
@@ -0,0 +1,155 @@
+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 CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList';
+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/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(() => {
+ mockedSelectionList.mockClear();
+ });
+
+ 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).toBeUndefined();
+ expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false);
+ expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false);
+ });
+
+ 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 0000000000000..c51ff2f39da71
--- /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 0000000000000..f1fcad19e2e62
--- /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 0000000000000..5ab2d4370d593
--- /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 0000000000000..210e1b05fd677
--- /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).toBeUndefined();
+ });
+
+ 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 0000000000000..b08dcaa591a6c
--- /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 0000000000000..4df6f34618b2f
--- /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 0000000000000..bfc4e9bad4f0a
--- /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 0000000000000..77c71176f8bff
--- /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 0000000000000..71c93d962755f
--- /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 0000000000000..d0ac6349ab85e
--- /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);
+ });
+});