Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions src/components/CountryPicker/CountrySelectorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
() =>
Expand All @@ -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();
Expand Down Expand Up @@ -89,9 +94,10 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC
<SelectionList
data={searchResults}
textInputOptions={textInputOptions}
searchValueForFocusSync={debouncedSearchValue}
onSelectRow={onCountrySelected}
ListItem={RadioListItem}
initiallyFocusedItemKey={currentCountry}
initiallyFocusedItemKey={initiallyFocusedCountry}
shouldSingleExecuteRowSelect
shouldStopPropagation
/>
Expand Down
12 changes: 10 additions & 2 deletions src/components/PushRowWithModal/PushRowModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
() =>
Expand All @@ -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();
Expand All @@ -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(
() => ({
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/components/SelectionList/BaseSelectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function BaseSelectionList<TItem extends ListItem>({
ref,
ListItem,
textInputOptions,
searchValueForFocusSync,
initiallyFocusedItemKey,
onSelectRow,
onSelectAll,
Expand Down Expand Up @@ -205,6 +206,7 @@ function BaseSelectionList<TItem extends ListItem>({
// 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) => {
Expand Down Expand Up @@ -494,12 +496,12 @@ function BaseSelectionList<TItem extends ListItem>({
initiallyFocusedItemKey,
isItemSelected,
focusedIndex,
searchValue: textInputOptions?.value,
searchValue: syncedSearchValue,
setFocusedIndex,
});

useSearchFocusSync({
searchValue: textInputOptions?.value,
searchValue: syncedSearchValue,
data,
selectedOptionsCount: dataDetails.selectedOptions.length,
isItemSelected,
Expand Down
3 changes: 3 additions & 0 deletions src/components/SelectionList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ type BaseSelectionListProps<TItem extends ListItem> = {
/** 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;

Expand Down
11 changes: 9 additions & 2 deletions src/components/StatePicker/StateSelectorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
() =>
Expand All @@ -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(
() => ({
Expand Down Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions src/components/ValuePicker/ValueSelectionList.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 (
<SelectionList
data={options}
onSelectRow={(item) => onItemSelected?.(item)}
initiallyFocusedItemKey={selectedItem?.value}
initiallyFocusedItemKey={initiallyFocusedItemKey}
shouldStopPropagation
shouldShowTooltips={shouldShowTooltips}
shouldUpdateFocusedIndex
shouldScrollToFocusedIndex={false}
shouldScrollToFocusedIndexOnMount={false}
ListItem={RadioListItem}
addBottomSafeAreaPadding={addBottomSafeAreaPadding}
disableKeyboardShortcuts={disableKeyboardShortcuts}
Expand Down
1 change: 1 addition & 0 deletions src/components/ValuePicker/ValueSelectorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function ValueSelectorModal({
<ValueSelectionList
items={items}
selectedItem={selectedItem}
isVisible={isVisible}
onItemSelected={onItemSelected}
shouldShowTooltips={shouldShowTooltips}
disableKeyboardShortcuts={disableKeyboardShortcuts}
Expand Down
5 changes: 4 additions & 1 deletion src/components/ValuePicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ type ValueSelectorModalProps = {
type ValueSelectionListProps = Pick<
ValueSelectorModalProps,
'items' | 'selectedItem' | 'onItemSelected' | 'shouldShowTooltips' | 'addBottomSafeAreaPadding' | 'disableKeyboardShortcuts' | 'alternateNumberOfSupportedLines'
>;
> & {
/** Whether the parent modal is visible */
isVisible?: boolean;
};

type ValuePickerProps = ForwardedFSClassProps & {
/** Item to display */
Expand Down
50 changes: 50 additions & 0 deletions src/hooks/useInitialSelection.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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;
45 changes: 45 additions & 0 deletions src/libs/SelectionListOrderUtils.ts
Original file line number Diff line number Diff line change
@@ -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<T extends {value: string}>(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};
Loading
Loading