Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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_MAX_HEIGHT: 366,
POPOVER_DATE_MIN_HEIGHT: 322,
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 @@ -493,12 +495,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
22 changes: 16 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,28 @@ 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 initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : [];
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, initialSelectedValues);

return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value}));
}, [initialSelectedValues, 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
67 changes: 67 additions & 0 deletions src/hooks/useInitialSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {useFocusEffect} from '@react-navigation/native';
import type {DependencyList} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';

type UseInitialSelectionOptions<T> = {
/** 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;

/** Whether the snapshot should continue following incoming selection changes */
shouldSyncSelection?: boolean;

/** Equality check used to avoid replacing the snapshot with equivalent values */
isEqual?: (previousSelection: T, nextSelection: T) => 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<T> = {}) {
const {resetDeps = [], resetOnFocus = false, shouldSyncSelection = false, isEqual = Object.is} = options;
const [initialSelection, setInitialSelection] = useState(selection);
const latestSelectionRef = useRef(selection);

const updateInitialSelection = useCallback(
(nextSelection: T) => {
setInitialSelection((previousSelection) => (isEqual(previousSelection, nextSelection) ? previousSelection : nextSelection));
},
[isEqual],
);

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]),
);

useEffect(() => {
if (!shouldSyncSelection) {
return;
}

updateInitialSelection(selection);
}, [selection, shouldSyncSelection, 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