diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx index 1cae120f349c6..e90ea0b2e532b 100644 --- a/src/components/Search/SearchMultipleSelectionPicker.tsx +++ b/src/components/Search/SearchMultipleSelectionPicker.tsx @@ -1,7 +1,6 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -// eslint-disable-next-line no-restricted-imports -import SelectionList from '@components/SelectionListWithSections'; -import MultiSelectListItem from '@components/SelectionListWithSections/MultiSelectListItem'; +import React, {useEffect, useState} from 'react'; +import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; +import SelectionList from '@components/SelectionList/SelectionListWithSections'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; @@ -33,94 +32,89 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit setSelectedItems(initiallySelectedItems ?? []); }, [initiallySelectedItems]); - const {sections, noResultsFound} = useMemo(() => { - const selectedItemsSection = selectedItems - .filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) - .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) - .map((item) => ({ - text: item.name, - keyForList: item.name, - isSelected: true, - value: item.value, - })); - const remainingItemsSection = items - .filter( - (item) => - !selectedItems.some((selectedItem) => selectedItem.value.toString() === item.value.toString()) && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()), - ) - .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) - .map((item) => ({ - text: item.name, - keyForList: item.name, - isSelected: false, - value: item.value, - })); - const isEmpty = !selectedItemsSection.length && !remainingItemsSection.length; - return { - sections: isEmpty - ? [] - : [ - { - title: undefined, - data: selectedItemsSection, - shouldShow: selectedItemsSection.length > 0, - }, - { - title: pickerTitle, - data: remainingItemsSection, - shouldShow: remainingItemsSection.length > 0, - }, - ], - noResultsFound: isEmpty, - }; - }, [selectedItems, items, pickerTitle, debouncedSearchTerm, localeCompare]); + const selectedItemsSection = selectedItems + .filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) + .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) + .map((item) => ({ + text: item.name, + keyForList: item.name, + isSelected: true, + value: item.value, + })); - const onSelectItem = useCallback( - (item: Partial) => { - if (!item.text || !item.keyForList || !item.value) { - return; - } - if (item.isSelected) { - setSelectedItems(selectedItems?.filter((selectedItem) => selectedItem.name !== item.keyForList)); - } else { - setSelectedItems([...(selectedItems ?? []), {name: item.text, value: item.value}]); - } - }, - [selectedItems], - ); + const remainingItemsSection = items + .filter( + (item) => + !selectedItems.some((selectedItem) => selectedItem.value.toString() === item.value.toString()) && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()), + ) + .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) + .map((item) => ({ + text: item.name, + keyForList: item.name, + isSelected: false, + value: item.value, + })); - const resetChanges = useCallback(() => { + const noResultsFound = !selectedItemsSection.length && !remainingItemsSection.length; + const sections = noResultsFound + ? [] + : [ + { + title: undefined, + data: selectedItemsSection, + sectionIndex: 0, + }, + { + title: pickerTitle, + data: remainingItemsSection, + sectionIndex: 1, + }, + ]; + + const onSelectItem = (item: Partial) => { + if (!item.text || !item.keyForList || !item.value) { + return; + } + if (item.isSelected) { + setSelectedItems(selectedItems?.filter((selectedItem) => selectedItem.name !== item.keyForList)); + } else { + setSelectedItems([...(selectedItems ?? []), {name: item.text, value: item.value}]); + } + }; + + const resetChanges = () => { setSelectedItems([]); - }, []); + }; - const applyChanges = useCallback(() => { + const applyChanges = () => { onSaveSelection(selectedItems.map((item) => item.value).flat()); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }, [onSaveSelection, selectedItems]); + }; - const footerContent = useMemo( - () => ( - - ), - [resetChanges, applyChanges], - ); + const textInputOptions = { + value: searchTerm, + label: translate('common.search'), + onChangeText: setSearchTerm, + headerMessage: noResultsFound ? translate('common.noResultsFound') : undefined, + }; return ( + } /> ); } diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 0a9b170b10404..5d29ed7efcb78 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -80,7 +80,7 @@ function BaseSelectionListWithSections({ const paddingBottomStyle = !isKeyboardShown && !footerContent && safeAreaPaddingBottomStyle; - const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex} = useFlattenedSections(sections, initiallyFocusedItemKey); + const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex, firstFocusableIndex} = useFlattenedSections(sections, initiallyFocusedItemKey); const setHasKeyBeenPressed = () => { if (hasKeyBeenPressed.current) { @@ -90,11 +90,11 @@ function BaseSelectionListWithSections({ }; const scrollToIndex = (index: number) => { - if (index < 0 || index >= flattenedData.length) { + if (index < 0 || index >= flattenedData.length || !listRef.current) { return; } const item = flattenedData.at(index); - if (!listRef.current || !item || getItemType(item) === CONST.SECTION_LIST_ITEM_TYPE.HEADER) { + if (!item) { return; } try { @@ -140,8 +140,14 @@ function BaseSelectionListWithSections({ if (!isScreenFocused) { return; } - if (canSelectMultiple && shouldShowTextInput) { - textInputOptions?.onChangeText?.(''); + if (canSelectMultiple) { + if (sections.length > 1 && !isItemSelected(item)) { + scrollToIndex(0); + } + + if (shouldShowTextInput) { + textInputOptions?.onChangeText?.(''); + } } if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { setFocusedIndex(indexToFocus); @@ -208,6 +214,7 @@ function BaseSelectionListWithSections({ shouldUpdateFocusedIndex, scrollToIndex, setFocusedIndex, + firstFocusableIndex, }); const textInputComponent = () => { diff --git a/src/components/SelectionList/hooks/useFlattenedSections.ts b/src/components/SelectionList/hooks/useFlattenedSections.ts index 52a13cfef1a3c..868e4774701f9 100644 --- a/src/components/SelectionList/hooks/useFlattenedSections.ts +++ b/src/components/SelectionList/hooks/useFlattenedSections.ts @@ -31,6 +31,9 @@ type UseFlattenedSectionsResult = { /** Index of initially focused item in flattenedData, or -1 if none */ initialFocusedIndex: number; + + /** Index of the first focusable (non-header) item in flattenedData. Returns 0 if no items exist. */ + firstFocusableIndex: number; }; /** @@ -43,6 +46,7 @@ function useFlattenedSections(sections: Array(sections: Array; data.push(itemData); + if (firstNonHeaderIndex === -1) { + firstNonHeaderIndex = currentIndex; + } + if (item.keyForList === initiallyFocusedItemKey && focusedIndex === -1) { focusedIndex = currentIndex; } @@ -89,6 +97,7 @@ function useFlattenedSections(sections: Array = { /** Function to set the focused index */ setFocusedIndex: (index: number) => void; + + /** The first focusable index in the list (useful when index 0 is a header). Defaults to 0. */ + firstFocusableIndex?: number; }; /** @@ -44,6 +47,7 @@ function useSearchFocusSync({ shouldUpdateFocusedIndex, scrollToIndex, setFocusedIndex, + firstFocusableIndex = 0, }: UseSearchFocusSyncParams) { const prevSearchValue = usePrevious(searchValue); const prevSelectedOptionsCount = usePrevious(selectedOptionsCount); @@ -76,12 +80,18 @@ function useSearchFocusSync({ // Remove focus (set focused index to -1) if: // 1. If the search is idle or // 2. If the user is just toggling options without changing the list content - // Otherwise (e.g. when filtering/typing), focus on the first item (0) + // Otherwise (e.g. when filtering/typing), scroll to top and focus on the first focusable item const isSearchIdle = !prevSearchValue && !searchValue; - const newSelectedIndex = isSearchIdle || (selectedOptionsChanged && prevItemsLength === data.length) ? -1 : 0; + const shouldResetFocus = isSearchIdle || (selectedOptionsChanged && prevItemsLength === data.length); + + if (shouldResetFocus) { + setFocusedIndex(-1); + return; + } - scrollToIndex(newSelectedIndex); - setFocusedIndex(newSelectedIndex); + // Scroll to top of list and focus on first focusable item (not header) + scrollToIndex(0); + setFocusedIndex(firstFocusableIndex); }, [ canSelectMultiple, data, @@ -94,6 +104,7 @@ function useSearchFocusSync({ shouldUpdateFocusedIndex, searchValue, isItemSelected, + firstFocusableIndex, ]); }