diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx index cae3e8802839..a58e4510c0fd 100644 --- a/src/components/Search/SearchMultipleSelectionPicker.tsx +++ b/src/components/Search/SearchMultipleSelectionPicker.tsx @@ -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'; @@ -33,36 +33,49 @@ function SearchMultipleSelectionPicker({ 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()))); + 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 | 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, }, ]; @@ -101,6 +114,8 @@ function SearchMultipleSelectionPicker({ ({ }, []); 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; @@ -168,7 +168,7 @@ function BaseSelectionList({ 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) diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index e8cd7d10162d..4f6bb2a1f020 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -71,6 +71,7 @@ function BaseSelectionListWithSections({ shouldDebounceScrolling = false, shouldUpdateFocusedIndex = false, shouldScrollToFocusedIndex = true, + shouldClearInputOnSelect = true, shouldSingleExecuteRowSelect = false, shouldPreventDefaultFocusOnSelectRow = false, isRowMultilineSupported = false, @@ -103,7 +104,7 @@ function BaseSelectionListWithSections({ hasKeyBeenPressed.current = true; }; - const scrollToIndex = (index: number) => { + const scrollToIndex = (index: number, animated = true) => { if (index < 0 || index >= flattenedData.length || !listRef.current) { return; } @@ -112,7 +113,7 @@ function BaseSelectionListWithSections({ 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) @@ -164,7 +165,7 @@ function BaseSelectionListWithSections({ scrollToIndex(0); } - if (shouldShowTextInput) { + if (shouldShowTextInput && shouldClearInputOnSelect) { textInputOptions?.onChangeText?.(''); } } diff --git a/src/components/SelectionList/hooks/useSearchFocusSync.ts b/src/components/SelectionList/hooks/useSearchFocusSync.ts index ed1fb22822fa..9a7fe7aefe4a 100644 --- a/src/components/SelectionList/hooks/useSearchFocusSync.ts +++ b/src/components/SelectionList/hooks/useSearchFocusSync.ts @@ -22,7 +22,7 @@ type UseSearchFocusSyncParams = { 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; @@ -71,7 +71,7 @@ function useSearchFocusSync({ const foundSelectedItemIndex = data.findIndex(isItemSelected); if (foundSelectedItemIndex !== -1 && !canSelectMultiple) { - scrollToIndex(foundSelectedItemIndex); + scrollToIndex(foundSelectedItemIndex, false); setFocusedIndex(foundSelectedItemIndex); return; } @@ -90,7 +90,7 @@ function useSearchFocusSync({ } // Scroll to top of list and focus on first focusable item (not header) - scrollToIndex(0); + scrollToIndex(0, false); setFocusedIndex(firstFocusableIndex); }, [ canSelectMultiple, diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 7d8cfe24def5..7d24527f3ace 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -110,6 +110,9 @@ type BaseSelectionListProps = { /** Configuration for the confirm button */ confirmButtonOptions?: ConfirmButtonOptions; + /** Whether to clear the text input when a row is selected */ + shouldClearInputOnSelect?: boolean; + /** Whether hover style should be disabled */ shouldDisableHoverStyle?: boolean; @@ -172,9 +175,6 @@ type SelectionListProps = Partial & /** 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;