Skip to content
Merged
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
47 changes: 31 additions & 16 deletions src/components/Search/SearchMultipleSelectionPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useState} from 'react';
import React, {useEffect, useState} from 'react';
import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem';
import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections';
import useDebouncedState from '@hooks/useDebouncedState';
Expand Down Expand Up @@ -33,36 +33,49 @@ function SearchMultipleSelectionPicker<T extends string | string[]>({
const {translate, localeCompare} = useLocalize();
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');

const [selectedItemIDs, setSelectedItemIDs] = useState(() => new Set((initiallySelectedItems ?? []).map((item) => item.value.toString())));
const [initialSelectedIDs] = useState(() => new Set((initiallySelectedItems ?? []).map((item) => item.value.toString())));
Copy link
Copy Markdown
Contributor

@mkhutornyi mkhutornyi May 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot when search something to have no results and clear search, scroll still happens because initiallyFocusedKey is set to undefined(computed here) and then set to as stale initial selected item as initialSelectedIDs never changes after first render.

search.mov

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in b797082initiallyFocusedKey is now computed once on mount via a useState lazy initializer (from items + initialSelectedIDs), so it no longer depends on sectionData and stays stable when search results change.

const [selectedItemIDs, setSelectedItemIDs] = useState(() => initialSelectedIDs);
// Clear after mount to prevent FlashList from auto-scrolling when data changes
// cause the key to transition from "not found" to "found" (e.g., clearing a search).
// Deferred by one frame so FlashList processes the initial scroll first.
const [initiallyFocusedKey, setInitiallyFocusedKey] = useState(() => {
let minItem: SearchMultipleSelectionPickerItem<T> | undefined;
for (const item of items) {
if (initialSelectedIDs.has(item.value.toString())) {
if (!minItem || sortOptionsWithEmptyValue(item.value.toString(), minItem.value.toString(), localeCompare) < 0) {
minItem = item;
}
}
}
return minItem?.name;
});
useEffect(() => {
const id = requestAnimationFrame(() => {
setInitiallyFocusedKey(undefined);
});
return () => cancelAnimationFrame(id);
}, []);

const searchLower = debouncedSearchTerm.toLowerCase();
const selectedSectionData: Array<{text: string; keyForList: string; isSelected: boolean; value: T; leftElement?: React.ReactNode}> = [];
const remainingSectionData: typeof selectedSectionData = [];
const sectionData: Array<{text: string; keyForList: string; isSelected: boolean; value: T; leftElement?: React.ReactNode}> = [];
for (const item of items) {
if (!item.name.toLowerCase().includes(searchLower)) {
continue;
}
const isSelected = selectedItemIDs.has(item.value.toString());
(isSelected ? selectedSectionData : remainingSectionData).push({text: item.name, keyForList: item.name, isSelected, value: item.value, leftElement: item.leftElement});
sectionData.push({text: item.name, keyForList: item.name, isSelected, value: item.value, leftElement: item.leftElement});
}

const sortByValue = (a: {value: string | string[]}, b: {value: string | string[]}) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare);
selectedSectionData.sort(sortByValue);
remainingSectionData.sort(sortByValue);
sectionData.sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare));

const noResultsFound = !selectedSectionData.length && !remainingSectionData.length;
const noResultsFound = !sectionData.length;
const sections = noResultsFound
? []
: [
{
title: undefined,
data: selectedSectionData,
sectionIndex: 0,
},
{
title: pickerTitle,
data: remainingSectionData,
sectionIndex: 1,
data: sectionData,
sectionIndex: 0,
},
];

Expand Down Expand Up @@ -101,6 +114,8 @@ function SearchMultipleSelectionPicker<T extends string | string[]>({
<SelectionListWithSections
sections={sections}
ListItem={MultiSelectListItem}
initiallyFocusedItemKey={initiallyFocusedKey}
shouldClearInputOnSelect={false}
shouldShowTextInput={shouldShowTextInput}
textInputOptions={textInputOptions}
onSelectRow={onSelectItem}
Expand Down
4 changes: 2 additions & 2 deletions src/components/SelectionList/BaseSelectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ function BaseSelectionList<TItem extends ListItem>({
}, []);

const scrollToIndex = useCallback(
(index: number) => {
(index: number, animated = true) => {
// Bounds check: ensure index is valid for current data
if (index < 0 || index >= data.length) {
return;
Expand All @@ -168,7 +168,7 @@ function BaseSelectionList<TItem extends ListItem>({
return;
}
try {
listRef.current.scrollToIndex({index});
listRef.current.scrollToIndex({index, animated});
} catch (error) {
// FlashList may throw if layout for this index doesn't exist yet
// This can happen when data changes rapidly (e.g., during search filtering)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
shouldDebounceScrolling = false,
shouldUpdateFocusedIndex = false,
shouldScrollToFocusedIndex = true,
shouldClearInputOnSelect = true,
shouldSingleExecuteRowSelect = false,
shouldPreventDefaultFocusOnSelectRow = false,
isRowMultilineSupported = false,
Expand Down Expand Up @@ -103,7 +104,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
hasKeyBeenPressed.current = true;
};

const scrollToIndex = (index: number) => {
const scrollToIndex = (index: number, animated = true) => {
if (index < 0 || index >= flattenedData.length || !listRef.current) {
return;
}
Expand All @@ -112,7 +113,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
return;
}
try {
listRef.current.scrollToIndex({index});
listRef.current.scrollToIndex({index, animated});
} catch (error) {
// FlashList may throw if layout for this index doesn't exist yet
// This can happen when data changes rapidly (e.g., during search filtering)
Expand Down Expand Up @@ -164,7 +165,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
scrollToIndex(0);
}

if (shouldShowTextInput) {
if (shouldShowTextInput && shouldClearInputOnSelect) {
textInputOptions?.onChangeText?.('');
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/components/SelectionList/hooks/useSearchFocusSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type UseSearchFocusSyncParams<TItem extends ListItem, TData = TItem> = {
shouldUpdateFocusedIndex: boolean;

/** Function to scroll to an index */
scrollToIndex: (index: number) => void;
scrollToIndex: (index: number, animated?: boolean) => void;

/** Function to set the focused index */
setFocusedIndex: (index: number) => void;
Expand Down Expand Up @@ -71,7 +71,7 @@ function useSearchFocusSync<TItem extends ListItem, TData = TItem>({
const foundSelectedItemIndex = data.findIndex(isItemSelected);

if (foundSelectedItemIndex !== -1 && !canSelectMultiple) {
scrollToIndex(foundSelectedItemIndex);
scrollToIndex(foundSelectedItemIndex, false);
setFocusedIndex(foundSelectedItemIndex);
return;
}
Expand All @@ -90,7 +90,7 @@ function useSearchFocusSync<TItem extends ListItem, TData = TItem>({
}

// Scroll to top of list and focus on first focusable item (not header)
scrollToIndex(0);
scrollToIndex(0, false);
setFocusedIndex(firstFocusableIndex);
}, [
canSelectMultiple,
Expand Down
6 changes: 3 additions & 3 deletions src/components/SelectionList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ type BaseSelectionListProps<TItem extends ListItem> = {
/** Configuration for the confirm button */
confirmButtonOptions?: ConfirmButtonOptions<TItem>;

/** Whether to clear the text input when a row is selected */
shouldClearInputOnSelect?: boolean;

/** Whether hover style should be disabled */
shouldDisableHoverStyle?: boolean;

Expand Down Expand Up @@ -172,9 +175,6 @@ type SelectionListProps<TItem extends ListItem> = Partial<ChildrenProps> &
/** Whether to place customListHeader in the list so it scrolls with data */
shouldHeaderBeInsideList?: boolean;

/** Whether to clear the text input when a row is selected */
shouldClearInputOnSelect?: boolean;

/** Whether to highlight the selected item */
shouldHighlightSelectedItem?: boolean;

Expand Down
Loading