From afe6912e26fcfd398164d26c73a4ffbfdd8a1021 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Tue, 20 Jan 2026 11:44:54 +0100 Subject: [PATCH 01/16] Create new list component --- .../SelectionList/BaseSelectionList.tsx | 3 +- .../SelectionList/ListItem/types.ts | 7 +- .../NewBaseSelectionListWithSections.tsx | 428 ++++++++++++++++++ .../index.native.tsx | 16 + .../SelectionListWithSections/index.tsx | 67 +++ .../SelectionListWithSections/types.ts | 153 +++++++ .../SelectionList/components/TextInput.tsx | 6 +- src/components/SelectionList/index.native.tsx | 3 +- src/components/SelectionList/index.tsx | 3 +- src/components/SelectionList/types.ts | 54 ++- src/components/TaxPicker.tsx | 19 +- src/libs/TaxOptionsListUtils.ts | 18 +- 12 files changed, 726 insertions(+), 51 deletions(-) create mode 100644 src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx create mode 100644 src/components/SelectionList/SelectionListWithSections/index.native.tsx create mode 100644 src/components/SelectionList/SelectionListWithSections/index.tsx create mode 100644 src/components/SelectionList/SelectionListWithSections/types.ts diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 840f3227fc8e..202235f25ec9 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -19,6 +19,7 @@ import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import Footer from './components/Footer'; import ListHeader from './components/ListHeader'; import TextInput from './components/TextInput'; @@ -57,7 +58,7 @@ function BaseSelectionList({ listFooterContent, rightHandSideComponent, alternateNumberOfSupportedLines, - selectedItems = CONST.EMPTY_ARRAY as unknown as string[], + selectedItems = getEmptyArray(), style, isSelected, isDisabled = false, diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index f2d0cb6f8362..1a893edec70c 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -17,6 +17,7 @@ import type SingleSelectListItem from './SingleSelectListItem'; import type SpendCategorySelectorListItem from './SpendCategorySelectorListItem'; import type SplitListItem from './SplitListItem'; import type TravelDomainListItem from './TravelDomainListItem'; +import type TableListItem from './TableListItem'; type ListItem = { /** Text to display */ @@ -128,6 +129,9 @@ type ListItem = { /** Used to initiate payment from search page */ hash?: number; + + /** Type of the item - 'header' for section headers, 'row' for data items */ + type?: 'header' | 'row'; }; type CommonListItemProps = { @@ -270,7 +274,8 @@ type ValidListItem = | typeof SingleSelectListItem | typeof SpendCategorySelectorListItem | typeof TravelDomainListItem - | typeof SplitListItem; + | typeof SplitListItem + | typeof TableListItem; type BaseListItemProps = CommonListItemProps & { item: TItem; diff --git a/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx new file mode 100644 index 000000000000..666b25c37c11 --- /dev/null +++ b/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx @@ -0,0 +1,428 @@ +import {useIsFocused} from '@react-navigation/native'; +import {FlashList} from '@shopify/flash-list'; +import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react'; +import type {TextInputKeyPressEvent} from 'react-native'; +import {View} from 'react-native'; +import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; +import Footer from '@components/SelectionList/components/Footer'; +import TextInput from '@components/SelectionList/components/TextInput'; +import ListItemRenderer from '@components/SelectionList/ListItem/ListItemRenderer'; +import type {ButtonOrCheckBoxRoles} from '@components/SelectionList/types'; +import Text from '@components/Text'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import useActiveElementRole from '@hooks/useActiveElementRole'; +import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import useDebounce from '@hooks/useDebounce'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useKeyboardState from '@hooks/useKeyboardState'; +import usePrevious from '@hooks/usePrevious'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import useScrollEnabled from '@hooks/useScrollEnabled'; +import useSingleExecution from '@hooks/useSingleExecution'; +import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type {FlattenedItem, ListItem, SectionHeader, SelectionListWithSectionsProps} from './types'; + +function getItemType(item: TItem): 'header' | 'row' { + return item.type ?? 'row'; +} + +function isItemSelected(item: ListItem): boolean { + return (item.type === 'row' && item.isSelected) ?? false; +} + +function NewBaseSelectionListWithSections({ + sections, + ListItem, + onSelectRow, + ref, + canSelectMultiple = false, + initiallyFocusedOptionKey, + customHeaderContent, + footerContent, + showLoadingPlaceholder = false, + rightHandSideComponent, + shouldShowTooltips = true, + onDismissError, + shouldPreventDefaultFocusOnSelectRow = false, + shouldSingleExecuteRowSelect = false, + textInputOptions, + isLoadingNewOptions, + shouldShowTextInput, + listEmptyContent, + shouldShowListEmptyContent = true, + shouldScrollToFocusedIndex = true, + shouldDebounceScrolling = false, + style, + onScrollBeginDrag, + addBottomSafeAreaPadding, + disableKeyboardShortcuts = false, + shouldStopPropagation = false, + onEndReached, + onEndReachedThreshold, + disableMaintainingScrollPosition = false, + shouldUpdateFocusedIndex = false, + shouldIgnoreFocus = false, +}: SelectionListWithSectionsProps) { + const styles = useThemeStyles(); + const isFocused = useIsFocused(); + const scrollEnabled = useScrollEnabled(); + const {singleExecution} = useSingleExecution(); + const listRef = useRef> | null>(null); + const innerTextInputRef = useRef(null); + const isTextInputFocusedRef = useRef(false); + const hasKeyBeenPressed = useRef(false); + const activeElementRole = useActiveElementRole(); + const {isKeyboardShown} = useKeyboardState(); + const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); + + const paddingBottomStyle = useMemo(() => !isKeyboardShown && safeAreaPaddingBottomStyle, [isKeyboardShown, safeAreaPaddingBottomStyle]); + const hasFooter = !!footerContent; + + const {flattenedData, headerIndices, itemsOnly, selectedItems} = useMemo(() => { + const data: Array> = []; + const selectedOptions: TItem[] = []; + const disabledArrowKeyIndexes: number[] = []; + const headers: number[] = []; + const items: TItem[] = []; + let itemIndex = 0; + + for (const section of sections) { + if (section.title) { + headers.push(data.length); + data.push({ + type: 'header', + title: section.title, + keyForList: `header-${section.title}`, + isDisabled: true, + }); + } + + for (const item of section.data ?? []) { + const itemWithIndex = { + ...item, + type: 'row', + flatIndex: itemIndex, + } as TItem; + data.push(itemWithIndex); + items.push(itemWithIndex); + + if (itemWithIndex.isSelected) { + selectedOptions.push(itemWithIndex); + } + + const isItemDisabled = section.isDisabled === true || (!!item?.isDisabled && !isItemSelected(item)); + if (isItemDisabled) { + disabledArrowKeyIndexes.push(itemIndex); + } + + itemIndex++; + } + } + + return {flattenedData: data, headerIndices: headers, itemsOnly: items, selectedItems: selectedOptions}; + }, [sections]); + + const initialFocusedIndex = useMemo(() => itemsOnly.findIndex((item) => item.keyForList === initiallyFocusedOptionKey), [itemsOnly, initiallyFocusedOptionKey]); + + const setHasKeyBeenPressed = useCallback(() => { + if (hasKeyBeenPressed.current) { + return; + } + hasKeyBeenPressed.current = true; + }, []); + + const scrollToIndex = useCallback( + (index: number) => { + // Bounds check: ensure index is valid for current data + if (index < 0 || index >= itemsOnly.length) { + return; + } + const item = itemsOnly.at(index); + if (!listRef.current || !item) { + return; + } + try { + listRef.current.scrollToIndex({index}); + } 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) + // The layout will be computed on next render, so we can safely ignore this + } + }, + [itemsOnly], + ); + + const debouncedScrollToIndex = useDebounce(scrollToIndex, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME, {leading: true, trailing: true}); + + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + initialFocusedIndex, + maxIndex: itemsOnly.length - 1, + disabledIndexes: headerIndices, + isActive: isFocused, + onFocusedIndexChange: (index: number) => { + if (!shouldScrollToFocusedIndex) { + return; + } + + (shouldDebounceScrolling ? debouncedScrollToIndex : scrollToIndex)(index); + }, + // eslint-disable-next-line react-hooks/refs + ...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}), + isFocused, + }); + + const focusedItem = useMemo(() => { + if (focusedIndex < 0 || focusedIndex >= flattenedData.length) { + return; + } + const item = flattenedData.at(focusedIndex); + if (!item || (item.isDisabled && !isItemSelected(item))) { + return; + } + return item as TItem; + }, [flattenedData, focusedIndex]); + + const selectRow = useCallback( + (item: TItem, indexToFocus?: number) => { + if (!isFocused) { + return; + } + if (canSelectMultiple) { + if (shouldShowTextInput) { + textInputOptions?.onChangeText?.(''); + } + } + if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { + setFocusedIndex(indexToFocus); + } + onSelectRow(item); + + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { + innerTextInputRef.current.focus(); + } + }, + [isFocused, canSelectMultiple, shouldUpdateFocusedIndex, onSelectRow, shouldShowTextInput, shouldPreventDefaultFocusOnSelectRow, textInputOptions, setFocusedIndex], + ); + + const selectFocusedItem = () => { + if (!focusedItem) { + return; + } + selectRow(focusedItem); + }; + + const focusTextInput = useCallback(() => { + innerTextInputRef.current?.focus(); + }, []); + + useImperativeHandle( + ref, + () => ({ + focusTextInput, + }), + [focusTextInput], + ); + + // Disable `Enter` shortcut if the active element is a button or checkbox + const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX].includes(activeElementRole as ButtonOrCheckBoxRoles); + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER || CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, selectFocusedItem, { + captureOnInputs: true, + shouldBubble: !focusedItem, + shouldStopPropagation, + isActive: !disableKeyboardShortcuts && isFocused && focusedIndex >= 0 && !disableEnterShortcut, + }); + + const textInputKeyPress = useCallback((event: TextInputKeyPressEvent) => { + const key = event.nativeEvent.key; + if (key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { + focusedItemRef?.focus(); + } + }, []); + + const selectedItemIndex = useMemo(() => (initiallyFocusedOptionKey ? itemsOnly.findIndex(isItemSelected) : -1), [itemsOnly, initiallyFocusedOptionKey]); + + useEffect(() => { + if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex || textInputOptions?.value) { + return; + } + setFocusedIndex(selectedItemIndex); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItemIndex]); + + const prevSearchValue = usePrevious(textInputOptions?.value); + const prevSelectedOptionsLength = usePrevious(selectedItems.length); + const prevAllOptionsLength = usePrevious(itemsOnly.length); + + useEffect(() => { + const currentSearchValue = textInputOptions?.value; + const searchChanged = prevSearchValue !== currentSearchValue; + const selectedOptionsChanged = selectedItems.length !== prevSelectedOptionsLength; + const selectionChangedByClicking = !searchChanged && selectedOptionsChanged && shouldUpdateFocusedIndex; + // Do not change focus if: + // 1. Input value is the same or + // 2. Data length is 0 or + // 3. Selection changed via user interaction (not filtering), so focus is handled externally + if ((!searchChanged && !selectedOptionsChanged) || itemsOnly.length === 0 || selectionChangedByClicking) { + return; + } + + const hasSearchBeenCleared = prevSearchValue && !currentSearchValue; + if (hasSearchBeenCleared) { + const foundSelectedItemIndex = itemsOnly.findIndex(isItemSelected); + + if (foundSelectedItemIndex !== -1 && !canSelectMultiple) { + scrollToIndex(foundSelectedItemIndex); + setFocusedIndex(foundSelectedItemIndex); + return; + } + } + + // 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) + const isSearchIdle = !prevSearchValue && !currentSearchValue; + const newSelectedIndex = isSearchIdle || (selectedOptionsChanged && prevAllOptionsLength === itemsOnly.length) ? -1 : 0; + + scrollToIndex(newSelectedIndex); + setFocusedIndex(newSelectedIndex); + }, [ + canSelectMultiple, + itemsOnly, + selectedItems.length, + prevAllOptionsLength, + prevSelectedOptionsLength, + prevSearchValue, + scrollToIndex, + setFocusedIndex, + shouldUpdateFocusedIndex, + textInputOptions?.value, + ]); + + const textInputComponent = () => { + return ( + (isTextInputFocusedRef.current = v)} + showLoadingPlaceholder={showLoadingPlaceholder} + isLoadingNewOptions={isLoadingNewOptions} + /> + ); + }; + + const renderListEmptyContent = () => { + if (showLoadingPlaceholder) { + return ; + } + if (shouldShowListEmptyContent) { + return listEmptyContent; + } + }; + + const renderItem = useCallback( + ({item, index}: ListRenderItemInfo>) => { + if (getItemType(item) === 'header') { + return ( + + {(item as SectionHeader).title} + + ); + } + + const listItem = item as TItem & {flatIndex?: number}; + const flatIndex = listItem.flatIndex ?? index; + const isItemFocused = flatIndex === focusedIndex; + const isDisabled = !!listItem.isDisabled; + + return ( + + ); + }, + [ + focusedIndex, + ListItem, + selectRow, + shouldShowTooltips, + canSelectMultiple, + shouldSingleExecuteRowSelect, + onDismissError, + shouldPreventDefaultFocusOnSelectRow, + rightHandSideComponent, + setFocusedIndex, + singleExecution, + shouldIgnoreFocus, + styles.optionsListSectionHeader, + styles.justifyContentCenter, + styles.ph5, + styles.textLabelSupporting, + ], + ); + + return ( + + {textInputComponent()} + {itemsOnly.length === 0 && (showLoadingPlaceholder || shouldShowListEmptyContent) ? ( + renderListEmptyContent() + ) : ( + <> + {customHeaderContent} + item.keyForList} + onEndReached={onEndReached} + onEndReachedThreshold={onEndReachedThreshold} + onScrollBeginDrag={onScrollBeginDrag} + scrollEnabled={scrollEnabled} + indicatorStyle="white" + showsVerticalScrollIndicator + keyboardShouldPersistTaps="always" + style={style?.listStyle} + maintainVisibleContentPosition={{disabled: disableMaintainingScrollPosition}} + /> + + )} + + footerContent={footerContent} + addBottomSafeAreaPadding={addBottomSafeAreaPadding} + /> + + ); +} + +export default NewBaseSelectionListWithSections; diff --git a/src/components/SelectionList/SelectionListWithSections/index.native.tsx b/src/components/SelectionList/SelectionListWithSections/index.native.tsx new file mode 100644 index 000000000000..dca23af56156 --- /dev/null +++ b/src/components/SelectionList/SelectionListWithSections/index.native.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import {Keyboard} from 'react-native'; +import BaseSelectionList from './NewBaseSelectionListWithSections'; +import type {ListItem, SelectionListWithSectionsProps} from './types'; + +function SelectionList(props: SelectionListWithSectionsProps) { + return ( + + ); +} + +export default SelectionList; diff --git a/src/components/SelectionList/SelectionListWithSections/index.tsx b/src/components/SelectionList/SelectionListWithSections/index.tsx new file mode 100644 index 000000000000..13f69e70d884 --- /dev/null +++ b/src/components/SelectionList/SelectionListWithSections/index.tsx @@ -0,0 +1,67 @@ +import React, {useEffect, useState} from 'react'; +import {isMobileChrome} from '@libs/Browser'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import CONST from '@src/CONST'; +import BaseSelectionList from './NewBaseSelectionListWithSections'; +import type {ListItem, SelectionListWithSectionsProps} from './types'; + +function SelectionList({ref, ...props}: SelectionListWithSectionsProps) { + const [isScreenTouched, setIsScreenTouched] = useState(false); + const [shouldDebounceScrolling, setShouldDebounceScrolling] = useState(false); + + const touchStart = () => setIsScreenTouched(true); + const touchEnd = () => setIsScreenTouched(false); + + useEffect(() => { + if (!canUseTouchScreen()) { + return; + } + // We're setting `isScreenTouched` in this listener only for web platforms with touchscreen (mWeb) where + // we want to dismiss the keyboard only when the list is scrolled by the user and not when it's scrolled programmatically. + document.addEventListener('touchstart', touchStart); + document.addEventListener('touchend', touchEnd); + + return () => { + document.removeEventListener('touchstart', touchStart); + document.removeEventListener('touchend', touchEnd); + }; + }, []); + + const handleKeyboardScrollDebounce = (event: KeyboardEvent) => { + if (!event) { + return; + } + // Moving through items using the keyboard triggers scrolling by the browser, so we debounce programmatic scrolling to prevent jittering. + if ( + event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN.shortcutKey || + event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey || + event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey + ) { + setShouldDebounceScrolling(event.type === 'keydown'); + } + }; + + useEffect(() => { + document.addEventListener('keydown', handleKeyboardScrollDebounce, {passive: true}); + document.addEventListener('keyup', handleKeyboardScrollDebounce, {passive: true}); + + return () => { + document.removeEventListener('keydown', handleKeyboardScrollDebounce); + document.removeEventListener('keyup', handleKeyboardScrollDebounce); + }; + }, []); + + return ( + + ); +} + +export default SelectionList; diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts new file mode 100644 index 000000000000..31fe42e1a08e --- /dev/null +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -0,0 +1,153 @@ +import type {ReactElement, ReactNode} from 'react'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {ListItem, ValidListItem} from '@components/SelectionList/ListItem/types'; +import type {SelectionListStyle, TextInputOptions} from '@components/SelectionList/types'; + +type Section = { + /** Title of the section */ + title?: string; + + /** Array of items in the section */ + data?: TItem[]; + + /** Whether this section is disabled */ + isDisabled?: boolean; +}; + +type SelectionListWithSectionsProps = { + /** Reference to the SelectionList component */ + ref?: React.Ref; + + /** Array of sections to display in the list */ + sections: Array>; + + /** Component to render for each list item */ + ListItem: ValidListItem; + + /** Called when a row is pressed */ + onSelectRow: (item: TItem) => void; + + /** Whether this is a multi-select list */ + canSelectMultiple?: boolean; + + /** Key of the item to focus initially */ + initiallyFocusedOptionKey?: string | null; + + /** Custom content to display in the header */ + customHeaderContent?: ReactNode; + + /** Custom content to display in the footer */ + footerContent?: ReactNode; + + /** Whether to show the loading placeholder */ + showLoadingPlaceholder?: boolean; + + /** Component to display on the right side of each item */ + rightHandSideComponent?: ((item: TItem, isFocused?: boolean) => ReactElement | null | undefined) | ReactElement | null; + + /** Whether tooltips should be shown */ + shouldShowTooltips?: boolean; + + /** Called when a checkbox is pressed */ + onCheckboxPress?: (item: TItem) => void; + + /** Callback to fire when an error is dismissed */ + onDismissError?: (item: TItem) => void; + + /** Whether to prevent default focus on row selection */ + shouldPreventDefaultFocusOnSelectRow?: boolean; + + /** Whether to single execution onRowSelect to avoid double clicks */ + shouldSingleExecuteRowSelect?: boolean; + + /** Configuration options for the text input */ + textInputOptions?: TextInputOptions; + + /** Whether to show the text input */ + shouldShowTextInput?: boolean; + + /** Whether to show the loading indicator for new options */ + isLoadingNewOptions?: boolean; + + /** Custom content to display when the list is empty */ + listEmptyContent?: React.JSX.Element; + + /** Whether to show the empty list content */ + shouldShowListEmptyContent?: boolean; + + /** Whether to add bottom safe area padding */ + addBottomSafeAreaPadding?: boolean; + + /** Styles for the list */ + style?: SelectionListStyle; + + /** Whether to debounce scrolling on focused index change */ + shouldDebounceScrolling?: boolean; + + /** Whether to scroll to the focused index */ + shouldScrollToFocusedIndex?: boolean; + + /** Whether keyboard shortcuts should be disabled */ + disableKeyboardShortcuts?: boolean; + + /** Whether to stop propagation on keyboard shortcuts */ + shouldStopPropagation?: boolean; + + /** Called once when the scroll position gets within onEndReachedThreshold of the rendered content. */ + onEndReached?: () => void; + + /** + * How far from the end (in units of visible length of the list) the bottom edge of the + * list must be from the end of the content to trigger the `onEndReached` callback. + * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is + * within half the visible length of the list. + */ + onEndReachedThreshold?: number; + + /** Whether to disable maintaining scroll position */ + disableMaintainingScrollPosition?: boolean; + + /** Whether to update the focused index */ + shouldUpdateFocusedIndex?: boolean; + + /** Whether to ignore the focus event */ + shouldIgnoreFocus?: boolean; + + /** Called when the list is scrolled and the user begins dragging */ + onScrollBeginDrag?: () => void; +}; + +type SelectionListWithSectionsStyle = { + /** Styles for the list */ + sectionListStyle?: StyleProp; + + /** Styles for the section titles */ + sectionTitleStyles?: StyleProp; + + /** Styles for the list container */ + containerStyle?: StyleProp; + + /** Styles for the header message */ + headerMessageStyle?: StyleProp; + + /** Styles for the list item title */ + listItemTitleStyles?: StyleProp; + + /** Styles for the list item wrapper */ + listItemWrapperStyle?: StyleProp; +}; + +type SelectionWithSectionsListHandle = { + focusTextInput: () => void; +}; + +type SectionHeader = { + type: 'header'; + title: string; + keyForList: string; + isDisabled: boolean; +}; + +type FlattenedItem = TItem | SectionHeader; + +export type {Section, ListItem, SelectionListWithSectionsProps, SelectionWithSectionsListHandle, SelectionListWithSectionsStyle, SectionHeader, FlattenedItem}; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 59306a18fb16..ae4e2548541a 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -65,7 +65,7 @@ function TextInput({ }: TextInputProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {label, value, onChangeText, errorText, headerMessage, hint, disableAutoFocus, placeholder, maxLength, inputMode, ref: optionsRef} = options ?? {}; + const {label, value, onChangeText, errorText, headerMessage, hint, disableAutoFocus, placeholder, maxLength, inputMode, ref: optionsRef, style} = options ?? {}; const resultsFound = headerMessage !== translate('common.noResultsFound'); const noData = dataLength === 0 && !showLoadingPlaceholder; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || resultsFound || noData); @@ -112,7 +112,7 @@ function TextInput({ return ( <> - + {shouldShowHeaderMessage && ( - + {headerMessage} )} diff --git a/src/components/SelectionList/index.native.tsx b/src/components/SelectionList/index.native.tsx index 324b52e19157..321ad55815fb 100644 --- a/src/components/SelectionList/index.native.tsx +++ b/src/components/SelectionList/index.native.tsx @@ -1,8 +1,7 @@ import React from 'react'; import {Keyboard} from 'react-native'; import BaseSelectionList from './BaseSelectionList'; -import type {ListItem} from './ListItem/types'; -import type {SelectionListProps} from './types'; +import type {ListItem, SelectionListProps} from './types'; function SelectionList({ref, ...props}: SelectionListProps) { return ( diff --git a/src/components/SelectionList/index.tsx b/src/components/SelectionList/index.tsx index 5c074593ab6d..a1a2f1ccadc5 100644 --- a/src/components/SelectionList/index.tsx +++ b/src/components/SelectionList/index.tsx @@ -3,8 +3,7 @@ import {isMobileChrome} from '@libs/Browser'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; import BaseSelectionList from './BaseSelectionList'; -import type {ListItem} from './ListItem/types'; -import type {SelectionListProps} from './types'; +import type {ListItem, SelectionListProps} from './types'; function SelectionList({ref, ...props}: SelectionListProps) { const [isScreenTouched, setIsScreenTouched] = useState(false); diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 5300387f0fda..d8da32f408b1 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -74,25 +74,8 @@ type SelectionListProps = Partial & { /** Array of selected item keys */ selectedItems?: readonly string[]; - style?: { - /** Styles for the list */ - listStyle?: StyleProp; - - /** Styles for the list container */ - containerStyle?: StyleProp; - - /** Styles for the title of the list item */ - listItemTitleStyles?: StyleProp; - - /** Styles for the list item wrapper */ - listItemWrapperStyle?: StyleProp; - - /** Styles for the list header wrapper */ - listHeaderWrapperStyle?: StyleProp; - - /** Styles for the title container of the list item */ - listItemTitleContainerStyles?: StyleProp; - }; + /** Styles for the list */ + style?: SelectionListStyle; /** Function that determines if an item is selected */ isSelected?: (item: TItem) => boolean; @@ -177,9 +160,31 @@ type SelectionListProps = Partial & { /** Whether hover style should be disabled */ shouldDisableHoverStyle?: boolean; + + /** Whether to set the hover style */ setShouldDisableHoverStyle?: React.Dispatch>; }; +type SelectionListStyle = { + /** Styles for the list */ + listStyle?: StyleProp; + + /** Styles for the list container */ + containerStyle?: StyleProp; + + /** Styles for the title of the list item */ + listItemTitleStyles?: StyleProp; + + /** Styles for the list item wrapper */ + listItemWrapperStyle?: StyleProp; + + /** Styles for the list header wrapper */ + listHeaderWrapperStyle?: StyleProp; + + /** Styles for the title container of the list item */ + listItemTitleContainerStyles?: StyleProp; +}; + type TextInputOptions = { /** Called when the text input value changes */ onChangeText?: (text: string) => void; @@ -214,6 +219,15 @@ type TextInputOptions = { /** Whether the text input autofocus should be disabled */ disableAutoFocus?: boolean; + /** Styles for the text input */ + style?: { + /** Styles for the text input container */ + containerStyle?: StyleProp; + + /** Styles for the header message container */ + headerMessageStyle?: StyleProp; + }; + /** Reference to the text input component */ ref?: RefObject; }; @@ -274,4 +288,4 @@ type DataDetailsType = { disabledArrowKeyIndexes: number[]; }; -export type {DataDetailsType, SelectionListHandle, SelectionListProps, TextInputOptions, ConfirmButtonOptions, ListItem, ButtonOrCheckBoxRoles}; +export type {DataDetailsType, SelectionListHandle, SelectionListProps, TextInputOptions, ConfirmButtonOptions, ListItem, ButtonOrCheckBoxRoles, SelectionListStyle}; diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx index d254d43fed1e..7e50f1926905 100644 --- a/src/components/TaxPicker.tsx +++ b/src/components/TaxPicker.tsx @@ -12,9 +12,8 @@ import CONST from '@src/CONST'; import type {IOUAction} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -// eslint-disable-next-line no-restricted-imports -import SelectionList from './SelectionListWithSections'; -import RadioListItem from './SelectionListWithSections/RadioListItem'; +import SelectionList from './SelectionList/SelectionListWithSections'; +import RadioListItem from './SelectionList/ListItem/RadioListItem'; type TaxPickerProps = { /** The selected tax rate of an expense */ @@ -95,7 +94,6 @@ function TaxPicker({selectedTaxRate = '', policyID, transactionID, onSubmit, act [searchValue, selectedOptions, policy, currentTransaction, localeCompare], ); - const headerMessage = getHeaderMessageForNonUserList((sections.at(0)?.data?.length ?? 0) > 0, searchValue); const selectedOptionKey = useMemo(() => sections?.at(0)?.data?.find((taxRate) => taxRate.searchText === selectedTaxRate)?.keyForList, [sections, selectedTaxRate]); @@ -110,13 +108,18 @@ function TaxPicker({selectedTaxRate = '', policyID, transactionID, onSubmit, act [onSubmit, onDismiss, selectedOptionKey], ); + const textInputOptions = { + label: translate('common.search'), + value: searchValue, + onChangeText: setSearchValue, + headerMessage: getHeaderMessageForNonUserList((sections.at(0)?.data?.length ?? 0) > 0, searchValue), + }; + return ( >): TaxRatesOption[] { - return taxRates.map(({code, modifiedName, isDisabled, isSelected, pendingAction}) => ({ + return taxRates.map(({code, modifiedName, isDisabled, isSelected, pendingAction}, index) => ({ code, text: modifiedName, - keyForList: modifiedName, + keyForList: `${modifiedName}-${index}`, searchText: modifiedName, tooltipText: modifiedName, isDisabled: !!isDisabled || pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, @@ -93,8 +92,6 @@ function getTaxRatesSection({ if (numberOfTaxRates === 0 && selectedOptions.length > 0) { policyRatesSections.push({ // "Selected" section - title: '', - shouldShow: false, data: getTaxRatesOptions(selectedTaxRateWithDisabledState), }); @@ -109,8 +106,6 @@ function getTaxRatesSection({ policyRatesSections.push({ // "Search" section - title: '', - shouldShow: true, data: getTaxRatesOptions(taxesForSearch), }); @@ -121,7 +116,6 @@ function getTaxRatesSection({ policyRatesSections.push({ // "All" section when items amount less than the threshold title: '', - shouldShow: false, data: getTaxRatesOptions([...selectedTaxRateWithDisabledState, ...enabledTaxRatesWithoutSelectedOptions]), }); @@ -131,16 +125,12 @@ function getTaxRatesSection({ if (selectedOptions.length > 0) { policyRatesSections.push({ // "Selected" section - title: '', - shouldShow: true, data: getTaxRatesOptions(selectedTaxRateWithDisabledState), }); } policyRatesSections.push({ // "All" section when number of items are more than the threshold - title: '', - shouldShow: true, data: getTaxRatesOptions(enabledTaxRatesWithoutSelectedOptions), }); From f48b52e479622daf9ed9c71e3d92447e403db083 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Tue, 20 Jan 2026 13:23:48 +0100 Subject: [PATCH 02/16] Fix getItemType --- .../SelectionList/ListItem/types.ts | 3 -- .../NewBaseSelectionListWithSections.tsx | 29 ++++++++++++------- .../SelectionListWithSections/types.ts | 6 ++-- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index 1a893edec70c..e9841be5ab46 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -129,9 +129,6 @@ type ListItem = { /** Used to initiate payment from search page */ hash?: number; - - /** Type of the item - 'header' for section headers, 'row' for data items */ - type?: 'header' | 'row'; }; type CommonListItemProps = { diff --git a/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx index 666b25c37c11..3b3cff5c0b71 100644 --- a/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx @@ -23,14 +23,14 @@ import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import type {FlattenedItem, ListItem, SectionHeader, SelectionListWithSectionsProps} from './types'; +import type {FlattenedItem, ListItem, SectionHeader, SectionListItem, SelectionListWithSectionsProps} from './types'; -function getItemType(item: TItem): 'header' | 'row' { - return item.type ?? 'row'; +function getItemType(item: FlattenedItem): 'header' | 'row' { + return item?.type ?? 'row'; } -function isItemSelected(item: ListItem): boolean { - return (item.type === 'row' && item.isSelected) ?? false; +function isItemSelected(item: TItem): boolean { + return item?.isSelected ?? false; } function NewBaseSelectionListWithSections({ @@ -86,7 +86,7 @@ function NewBaseSelectionListWithSections({ const selectedOptions: TItem[] = []; const disabledArrowKeyIndexes: number[] = []; const headers: number[] = []; - const items: TItem[] = []; + const items: Array> = []; let itemIndex = 0; for (const section of sections) { @@ -105,7 +105,7 @@ function NewBaseSelectionListWithSections({ ...item, type: 'row', flatIndex: itemIndex, - } as TItem; + } as SectionListItem; data.push(itemWithIndex); items.push(itemWithIndex); @@ -333,6 +333,9 @@ function NewBaseSelectionListWithSections({ const renderItem = useCallback( ({item, index}: ListRenderItemInfo>) => { + if (!item) { + return null; + } if (getItemType(item) === 'header') { return ( @@ -341,10 +344,9 @@ function NewBaseSelectionListWithSections({ ); } - const listItem = item as TItem & {flatIndex?: number}; - const flatIndex = listItem.flatIndex ?? index; + const flatIndex = (item as SectionListItem).flatIndex ?? index; const isItemFocused = flatIndex === focusedIndex; - const isDisabled = !!listItem.isDisabled; + const isDisabled = !!item.isDisabled; return ( ({ selectRow={selectRow} keyForList={item.keyForList} showTooltip={shouldShowTooltips} - item={listItem} + item={item as TItem} index={index} normalizedIndex={flatIndex} isFocused={isItemFocused} @@ -367,6 +369,8 @@ function NewBaseSelectionListWithSections({ shouldSyncFocus={!isTextInputFocusedRef.current && hasKeyBeenPressed.current} shouldHighlightSelectedItem shouldIgnoreFocus={shouldIgnoreFocus} + wrapperStyle={style?.listItemWrapperStyle} + titleStyles={style?.listItemTitleStyles} /> ); }, @@ -383,6 +387,8 @@ function NewBaseSelectionListWithSections({ setFocusedIndex, singleExecution, shouldIgnoreFocus, + style?.listItemWrapperStyle, + style?.listItemTitleStyles, styles.optionsListSectionHeader, styles.justifyContentCenter, styles.ph5, @@ -402,6 +408,7 @@ function NewBaseSelectionListWithSections({ data={flattenedData} renderItem={renderItem} ref={listRef} + extraData={itemsOnly.length} getItemType={getItemType} initialScrollIndex={initialFocusedIndex} keyExtractor={(item) => item.keyForList} diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index 31fe42e1a08e..5e23739c67b0 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -148,6 +148,8 @@ type SectionHeader = { isDisabled: boolean; }; -type FlattenedItem = TItem | SectionHeader; +type SectionListItem = TItem & {flatIndex: number, type: 'row'}; -export type {Section, ListItem, SelectionListWithSectionsProps, SelectionWithSectionsListHandle, SelectionListWithSectionsStyle, SectionHeader, FlattenedItem}; +type FlattenedItem = SectionListItem | SectionHeader; + +export type {Section, ListItem, SectionListItem, SelectionListWithSectionsProps, SelectionWithSectionsListHandle, SelectionListWithSectionsStyle, SectionHeader, FlattenedItem}; From 726b04c7bb57a4bb8b57b828b491fb6910804030 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Tue, 20 Jan 2026 15:15:45 +0100 Subject: [PATCH 03/16] Fix eslint errors --- src/components/SelectionList/ListItem/ListItemRenderer.tsx | 5 +++-- .../SelectionList/SelectionListWithSections/types.ts | 2 +- src/components/TaxPicker.tsx | 3 +-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/SelectionList/ListItem/ListItemRenderer.tsx b/src/components/SelectionList/ListItem/ListItemRenderer.tsx index 4ce12bb19749..d990d1fde36e 100644 --- a/src/components/SelectionList/ListItem/ListItemRenderer.tsx +++ b/src/components/SelectionList/ListItem/ListItemRenderer.tsx @@ -10,7 +10,7 @@ import type {BaseListItemProps, ExtendedTargetedEvent, ListItem} from './types'; type ListItemRendererProps = Omit, 'onSelectRow' | 'keyForList'> & Pick, 'ListItem' | 'shouldIgnoreFocus' | 'shouldSingleExecuteRowSelect'> & { index: number; - normalizedIndex: number; + normalizedIndex?: number; selectRow: (item: TItem, indexToFocus?: number) => void; setFocusedIndex: ReturnType[1]; singleExecution: ReturnType['singleExecution']; @@ -23,6 +23,7 @@ function ListItemRenderer({ ListItem, item, index, + normalizedIndex, isFocused, isDisabled, showTooltip, @@ -92,7 +93,7 @@ function ListItemRenderer({ if (isMobileChrome() && event.nativeEvent && !event.nativeEvent.sourceCapabilities) { return; } - setFocusedIndex(index); + setFocusedIndex(normalizedIndex ?? index); }} shouldSyncFocus={shouldSyncFocus} wrapperStyle={wrapperStyle} diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index 5e23739c67b0..20d202cdace8 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -148,7 +148,7 @@ type SectionHeader = { isDisabled: boolean; }; -type SectionListItem = TItem & {flatIndex: number, type: 'row'}; +type SectionListItem = TItem & {flatIndex: number; type: 'row'}; type FlattenedItem = SectionListItem | SectionHeader; diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx index 7e50f1926905..24d34310b040 100644 --- a/src/components/TaxPicker.tsx +++ b/src/components/TaxPicker.tsx @@ -12,8 +12,8 @@ import CONST from '@src/CONST'; import type {IOUAction} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import SelectionList from './SelectionList/SelectionListWithSections'; import RadioListItem from './SelectionList/ListItem/RadioListItem'; +import SelectionList from './SelectionList/SelectionListWithSections'; type TaxPickerProps = { /** The selected tax rate of an expense */ @@ -94,7 +94,6 @@ function TaxPicker({selectedTaxRate = '', policyID, transactionID, onSubmit, act [searchValue, selectedOptions, policy, currentTransaction, localeCompare], ); - const selectedOptionKey = useMemo(() => sections?.at(0)?.data?.find((taxRate) => taxRate.searchText === selectedTaxRate)?.keyForList, [sections, selectedTaxRate]); const handleSelectRow = useCallback( From 169955566bb18928a4b8a691cae65dcaab4a4c32 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Wed, 21 Jan 2026 09:59:53 +0100 Subject: [PATCH 04/16] Extract common types and function for both lists --- .../SelectionList/BaseSelectionList.tsx | 74 ++---- .../NewBaseSelectionListWithSections.tsx | 81 ++----- .../SelectionListWithSections/types.ts | 124 +--------- .../SelectionList/hooks/useSearchFocusSync.ts | 101 ++++++++ .../hooks/useSelectedItemFocusSync.ts | 53 ++++ src/components/SelectionList/types.ts | 228 ++++++++++-------- 6 files changed, 323 insertions(+), 338 deletions(-) create mode 100644 src/components/SelectionList/hooks/useSearchFocusSync.ts create mode 100644 src/components/SelectionList/hooks/useSelectedItemFocusSync.ts diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index d351ce81db67..10356bb69b40 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -12,7 +12,6 @@ import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useDebounce from '@hooks/useDebounce'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useKeyboardState from '@hooks/useKeyboardState'; -import usePrevious from '@hooks/usePrevious'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useScrollEnabled from '@hooks/useScrollEnabled'; import useSingleExecution from '@hooks/useSingleExecution'; @@ -25,6 +24,8 @@ import ListHeader from './components/ListHeader'; import TextInput from './components/TextInput'; import ListItemRenderer from './ListItem/ListItemRenderer'; import type {ButtonOrCheckBoxRoles, DataDetailsType, ListItem, SelectionListProps} from './types'; +import useSearchFocusSync from './hooks/useSearchFocusSync'; +import useSelectedItemFocusSync from './hooks/useSelectedItemFocusSync'; const ANIMATED_HIGHLIGHT_DURATION = CONST.ANIMATED_HIGHLIGHT_ENTRY_DELAY + @@ -447,66 +448,25 @@ function BaseSelectionList({ [data.length, scrollToIndex, setFocusedIndex], ); - const selectedItemIndex = useMemo(() => (initiallyFocusedItemKey ? data.findIndex(isItemSelected) : -1), [data, initiallyFocusedItemKey, isItemSelected]); - - useEffect(() => { - if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex || textInputOptions?.value) { - return; - } - setFocusedIndex(selectedItemIndex); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedItemIndex]); - - const prevSearchValue = usePrevious(textInputOptions?.value); - const prevSelectedOptionsLength = usePrevious(dataDetails.selectedOptions.length); - const prevAllOptionsLength = usePrevious(data.length); - - useEffect(() => { - const currentSearchValue = textInputOptions?.value; - const searchChanged = prevSearchValue !== currentSearchValue; - const selectedOptionsChanged = dataDetails.selectedOptions.length !== prevSelectedOptionsLength; - const selectionChangedByClicking = !searchChanged && selectedOptionsChanged && shouldUpdateFocusedIndex; - // Do not change focus if: - // 1. Input value is the same or - // 2. Data length is 0 or - // 3. Selection changed via user interaction (not filtering), so focus is handled externally - if ((!searchChanged && !selectedOptionsChanged) || data.length === 0 || selectionChangedByClicking) { - return; - } - - const hasSearchBeenCleared = prevSearchValue && !currentSearchValue; - if (hasSearchBeenCleared) { - const foundSelectedItemIndex = data.findIndex(isItemSelected); - - if (foundSelectedItemIndex !== -1 && !canSelectMultiple) { - scrollToIndex(foundSelectedItemIndex); - setFocusedIndex(foundSelectedItemIndex); - return; - } - } - - // 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) - const isSearchIdle = !prevSearchValue && !currentSearchValue; - const newSelectedIndex = isSearchIdle || (selectedOptionsChanged && prevAllOptionsLength === data.length) ? -1 : 0; + useSelectedItemFocusSync({ + items: data, + initiallyFocusedItemKey, + isItemSelected, + focusedIndex, + searchValue: textInputOptions?.value, + setFocusedIndex, + }); - scrollToIndex(newSelectedIndex); - setFocusedIndex(newSelectedIndex); - }, [ - canSelectMultiple, - data, - dataDetails.selectedOptions.length, + useSearchFocusSync({ + searchValue: textInputOptions?.value, + items: data, + selectedOptionsCount: dataDetails.selectedOptions.length, isItemSelected, - prevAllOptionsLength, - prevSelectedOptionsLength, - prevSearchValue, + canSelectMultiple, + shouldUpdateFocusedIndex, scrollToIndex, setFocusedIndex, - shouldUpdateFocusedIndex, - textInputOptions?.value, - ]); + }); useEffect(() => { if (!itemFocusTimeoutRef.current) { diff --git a/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx index 0e1668733f8a..c3e9da8f4169 100644 --- a/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx @@ -1,7 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import {FlashList} from '@shopify/flash-list'; import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react'; +import React, {useCallback, useImperativeHandle, useMemo, useRef} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; import {View} from 'react-native'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; @@ -16,7 +16,6 @@ import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useDebounce from '@hooks/useDebounce'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useKeyboardState from '@hooks/useKeyboardState'; -import usePrevious from '@hooks/usePrevious'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useScrollEnabled from '@hooks/useScrollEnabled'; import useSingleExecution from '@hooks/useSingleExecution'; @@ -24,6 +23,8 @@ import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {FlattenedItem, ListItem, SectionHeader, SectionListItem, SelectionListWithSectionsProps} from './types'; +import useSelectedItemFocusSync from '../hooks/useSelectedItemFocusSync'; +import useSearchFocusSync from '../hooks/useSearchFocusSync'; function getItemType(item: FlattenedItem): 'header' | 'row' { return item?.type ?? 'row'; @@ -52,7 +53,7 @@ function NewBaseSelectionListWithSections({ isLoadingNewOptions, shouldShowTextInput, listEmptyContent, - shouldShowListEmptyContent = true, + showListEmptyContent = true, shouldScrollToFocusedIndex = true, shouldDebounceScrolling = false, style, @@ -243,65 +244,25 @@ function NewBaseSelectionListWithSections({ } }, []); - const selectedItemIndex = useMemo(() => (initiallyFocusedOptionKey ? itemsOnly.findIndex(isItemSelected) : -1), [itemsOnly, initiallyFocusedOptionKey]); - - useEffect(() => { - if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex || textInputOptions?.value) { - return; - } - setFocusedIndex(selectedItemIndex); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedItemIndex]); - - const prevSearchValue = usePrevious(textInputOptions?.value); - const prevSelectedOptionsLength = usePrevious(selectedItems.length); - const prevAllOptionsLength = usePrevious(itemsOnly.length); - - useEffect(() => { - const currentSearchValue = textInputOptions?.value; - const searchChanged = prevSearchValue !== currentSearchValue; - const selectedOptionsChanged = selectedItems.length !== prevSelectedOptionsLength; - const selectionChangedByClicking = !searchChanged && selectedOptionsChanged && shouldUpdateFocusedIndex; - // Do not change focus if: - // 1. Input value is the same or - // 2. Data length is 0 or - // 3. Selection changed via user interaction (not filtering), so focus is handled externally - if ((!searchChanged && !selectedOptionsChanged) || itemsOnly.length === 0 || selectionChangedByClicking) { - return; - } - - const hasSearchBeenCleared = prevSearchValue && !currentSearchValue; - if (hasSearchBeenCleared) { - const foundSelectedItemIndex = itemsOnly.findIndex(isItemSelected); - - if (foundSelectedItemIndex !== -1 && !canSelectMultiple) { - scrollToIndex(foundSelectedItemIndex); - setFocusedIndex(foundSelectedItemIndex); - return; - } - } - - // 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) - const isSearchIdle = !prevSearchValue && !currentSearchValue; - const newSelectedIndex = isSearchIdle || (selectedOptionsChanged && prevAllOptionsLength === itemsOnly.length) ? -1 : 0; + useSelectedItemFocusSync({ + items: itemsOnly, + initiallyFocusedItemKey: initiallyFocusedOptionKey, + isItemSelected, + focusedIndex, + searchValue: textInputOptions?.value, + setFocusedIndex, + }); - scrollToIndex(newSelectedIndex); - setFocusedIndex(newSelectedIndex); - }, [ + useSearchFocusSync({ + searchValue: textInputOptions?.value, + items: itemsOnly, + selectedOptionsCount: selectedItems.length, + isItemSelected, canSelectMultiple, - itemsOnly, - selectedItems.length, - prevAllOptionsLength, - prevSelectedOptionsLength, - prevSearchValue, + shouldUpdateFocusedIndex, scrollToIndex, setFocusedIndex, - shouldUpdateFocusedIndex, - textInputOptions?.value, - ]); + }); const textInputComponent = () => { return ( @@ -326,7 +287,7 @@ function NewBaseSelectionListWithSections({ if (showLoadingPlaceholder) { return ; } - if (shouldShowListEmptyContent) { + if (showListEmptyContent) { return listEmptyContent; } }; @@ -398,7 +359,7 @@ function NewBaseSelectionListWithSections({ return ( {textInputComponent()} - {itemsOnly.length === 0 && (showLoadingPlaceholder || shouldShowListEmptyContent) ? ( + {itemsOnly.length === 0 && (showLoadingPlaceholder || showListEmptyContent) ? ( renderListEmptyContent() ) : ( <> diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index 20d202cdace8..740b078de810 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -1,7 +1,6 @@ -import type {ReactElement, ReactNode} from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import type {ListItem, ValidListItem} from '@components/SelectionList/ListItem/types'; -import type {SelectionListStyle, TextInputOptions} from '@components/SelectionList/types'; +import type {ReactNode} from 'react'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import type {BaseSelectionListProps} from '@components/SelectionList/types'; type Section = { /** Title of the section */ @@ -14,127 +13,22 @@ type Section = { isDisabled?: boolean; }; -type SelectionListWithSectionsProps = { +/** + * Props for SelectionListWithSections component. + * Extends BaseSelectionListProps with section-specific features. + */ +type SelectionListWithSectionsProps = BaseSelectionListProps & { /** Reference to the SelectionList component */ ref?: React.Ref; /** Array of sections to display in the list */ sections: Array>; - /** Component to render for each list item */ - ListItem: ValidListItem; - - /** Called when a row is pressed */ - onSelectRow: (item: TItem) => void; - - /** Whether this is a multi-select list */ - canSelectMultiple?: boolean; - /** Key of the item to focus initially */ initiallyFocusedOptionKey?: string | null; /** Custom content to display in the header */ customHeaderContent?: ReactNode; - - /** Custom content to display in the footer */ - footerContent?: ReactNode; - - /** Whether to show the loading placeholder */ - showLoadingPlaceholder?: boolean; - - /** Component to display on the right side of each item */ - rightHandSideComponent?: ((item: TItem, isFocused?: boolean) => ReactElement | null | undefined) | ReactElement | null; - - /** Whether tooltips should be shown */ - shouldShowTooltips?: boolean; - - /** Called when a checkbox is pressed */ - onCheckboxPress?: (item: TItem) => void; - - /** Callback to fire when an error is dismissed */ - onDismissError?: (item: TItem) => void; - - /** Whether to prevent default focus on row selection */ - shouldPreventDefaultFocusOnSelectRow?: boolean; - - /** Whether to single execution onRowSelect to avoid double clicks */ - shouldSingleExecuteRowSelect?: boolean; - - /** Configuration options for the text input */ - textInputOptions?: TextInputOptions; - - /** Whether to show the text input */ - shouldShowTextInput?: boolean; - - /** Whether to show the loading indicator for new options */ - isLoadingNewOptions?: boolean; - - /** Custom content to display when the list is empty */ - listEmptyContent?: React.JSX.Element; - - /** Whether to show the empty list content */ - shouldShowListEmptyContent?: boolean; - - /** Whether to add bottom safe area padding */ - addBottomSafeAreaPadding?: boolean; - - /** Styles for the list */ - style?: SelectionListStyle; - - /** Whether to debounce scrolling on focused index change */ - shouldDebounceScrolling?: boolean; - - /** Whether to scroll to the focused index */ - shouldScrollToFocusedIndex?: boolean; - - /** Whether keyboard shortcuts should be disabled */ - disableKeyboardShortcuts?: boolean; - - /** Whether to stop propagation on keyboard shortcuts */ - shouldStopPropagation?: boolean; - - /** Called once when the scroll position gets within onEndReachedThreshold of the rendered content. */ - onEndReached?: () => void; - - /** - * How far from the end (in units of visible length of the list) the bottom edge of the - * list must be from the end of the content to trigger the `onEndReached` callback. - * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is - * within half the visible length of the list. - */ - onEndReachedThreshold?: number; - - /** Whether to disable maintaining scroll position */ - disableMaintainingScrollPosition?: boolean; - - /** Whether to update the focused index */ - shouldUpdateFocusedIndex?: boolean; - - /** Whether to ignore the focus event */ - shouldIgnoreFocus?: boolean; - - /** Called when the list is scrolled and the user begins dragging */ - onScrollBeginDrag?: () => void; -}; - -type SelectionListWithSectionsStyle = { - /** Styles for the list */ - sectionListStyle?: StyleProp; - - /** Styles for the section titles */ - sectionTitleStyles?: StyleProp; - - /** Styles for the list container */ - containerStyle?: StyleProp; - - /** Styles for the header message */ - headerMessageStyle?: StyleProp; - - /** Styles for the list item title */ - listItemTitleStyles?: StyleProp; - - /** Styles for the list item wrapper */ - listItemWrapperStyle?: StyleProp; }; type SelectionWithSectionsListHandle = { @@ -152,4 +46,4 @@ type SectionListItem = TItem & {flatIndex: number; type: type FlattenedItem = SectionListItem | SectionHeader; -export type {Section, ListItem, SectionListItem, SelectionListWithSectionsProps, SelectionWithSectionsListHandle, SelectionListWithSectionsStyle, SectionHeader, FlattenedItem}; +export type {Section, ListItem, SectionListItem, SelectionListWithSectionsProps, SelectionWithSectionsListHandle, SectionHeader, FlattenedItem}; diff --git a/src/components/SelectionList/hooks/useSearchFocusSync.ts b/src/components/SelectionList/hooks/useSearchFocusSync.ts new file mode 100644 index 000000000000..4960fd1c1343 --- /dev/null +++ b/src/components/SelectionList/hooks/useSearchFocusSync.ts @@ -0,0 +1,101 @@ +import {useEffect} from 'react'; +import usePrevious from '@hooks/usePrevious'; +import type {ListItem} from '../ListItem/types'; + +type UseSearchFocusSyncParams = { + /** The current search value from text input */ + searchValue: string | undefined; + + /** Array of items (filtered data) */ + items: TItem[]; + + /** Count of currently selected options */ + selectedOptionsCount: number; + + /** Function to check if an item is selected */ + isItemSelected: (item: TItem) => boolean; + + /** Whether multiple items can be selected */ + canSelectMultiple: boolean; + + /** Whether focus index should be updated when selection changes */ + shouldUpdateFocusedIndex: boolean; + + /** Function to scroll to an index */ + scrollToIndex: (index: number) => void; + + /** Function to set the focused index */ + setFocusedIndex: (index: number) => void; +}; + +/** + * Custom hook that manages focus synchronization when search value or selection changes. + * This handles: + * - Resetting focus when search is cleared + * - Scrolling to selected item when search is cleared + * - Setting focus to first item when filtering + */ +function useSearchFocusSync({ + searchValue, + items, + selectedOptionsCount, + isItemSelected, + canSelectMultiple, + shouldUpdateFocusedIndex, + scrollToIndex, + setFocusedIndex, +}: UseSearchFocusSyncParams) { + const prevSearchValue = usePrevious(searchValue); + const prevSelectedOptionsCount = usePrevious(selectedOptionsCount); + const prevItemsLength = usePrevious(items.length); + + useEffect(() => { + const searchChanged = prevSearchValue !== searchValue; + const selectedOptionsChanged = selectedOptionsCount !== prevSelectedOptionsCount; + const selectionChangedByClicking = !searchChanged && selectedOptionsChanged && shouldUpdateFocusedIndex; + + // Do not change focus if: + // 1. Input value is the same or + // 2. Data length is 0 or + // 3. Selection changed via user interaction (not filtering), so focus is handled externally + if ((!searchChanged && !selectedOptionsChanged) || items.length === 0 || selectionChangedByClicking) { + return; + } + + const hasSearchBeenCleared = prevSearchValue && !searchValue; + if (hasSearchBeenCleared) { + const foundSelectedItemIndex = items.findIndex(isItemSelected); + + if (foundSelectedItemIndex !== -1 && !canSelectMultiple) { + scrollToIndex(foundSelectedItemIndex); + setFocusedIndex(foundSelectedItemIndex); + return; + } + } + + // 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) + const isSearchIdle = !prevSearchValue && !searchValue; + const newSelectedIndex = isSearchIdle || (selectedOptionsChanged && prevItemsLength === items.length) ? -1 : 0; + + scrollToIndex(newSelectedIndex); + setFocusedIndex(newSelectedIndex); + }, [ + canSelectMultiple, + items, + selectedOptionsCount, + prevItemsLength, + prevSelectedOptionsCount, + prevSearchValue, + scrollToIndex, + setFocusedIndex, + shouldUpdateFocusedIndex, + searchValue, + isItemSelected, + ]); +} + +export default useSearchFocusSync; +export type {UseSearchFocusSyncParams}; diff --git a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts new file mode 100644 index 000000000000..132f6e951414 --- /dev/null +++ b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts @@ -0,0 +1,53 @@ +import {useEffect, useMemo} from 'react'; +import type {ListItem} from '../ListItem/types'; + +type UseSelectedItemFocusSyncParams = { + /** Array of items to search in */ + items: TItem[]; + + /** Key of the item to focus initially */ + initiallyFocusedItemKey: string | null | undefined; + + /** Function to check if an item is selected */ + isItemSelected: (item: TItem) => boolean; + + /** Current focused index */ + focusedIndex: number; + + /** Current search value - if present, don't sync focus */ + searchValue: string | undefined; + + /** Function to set the focused index */ + setFocusedIndex: (index: number) => void; +}; + +/** + * Custom hook that syncs the focused index with the selected item. + * When the selected item changes (and no search is active), updates the focused index. + */ +function useSelectedItemFocusSync({ + items, + initiallyFocusedItemKey, + isItemSelected, + focusedIndex, + searchValue, + setFocusedIndex, +}: UseSelectedItemFocusSyncParams) { + const selectedItemIndex = useMemo( + () => (initiallyFocusedItemKey ? items.findIndex(isItemSelected) : -1), + [items, initiallyFocusedItemKey, isItemSelected], + ); + + useEffect(() => { + if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex || searchValue) { + return; + } + setFocusedIndex(selectedItemIndex); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItemIndex]); + + return selectedItemIndex; +} + +export default useSelectedItemFocusSync; +export type {UseSelectedItemFocusSyncParams}; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index d8da32f408b1..600b7697e8aa 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -4,166 +4,179 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {ListItem, ValidListItem} from './ListItem/types'; -type SelectionListProps = Partial & { - /** Array of items to display in the list */ - data: TItem[]; - - /** Reference to the SelectionList component */ - ref?: React.Ref>; - +/** + * Base props shared between SelectionList and SelectionListWithSections. + * Contains common configuration for list behavior, styling, and callbacks. + */ +type BaseSelectionListProps = { /** Component to render for each list item */ ListItem: ValidListItem; - /** Configuration options for the text input */ - textInputOptions?: TextInputOptions; - - /** Key of the item to focus initially */ - initiallyFocusedItemKey?: string; - /** Called when a row is pressed */ onSelectRow: (item: TItem) => void; - /** Called when "Select All" button is pressed */ - onSelectAll?: () => void; + /** Whether this is a multi-select list */ + canSelectMultiple?: boolean; - /** Callback to fire when the item is long pressed */ - onLongPressRow?: (item: TItem) => void; + /** Custom content to display in the footer */ + footerContent?: React.ReactNode; + + /** Whether to show the loading placeholder */ + showLoadingPlaceholder?: boolean; + + /** Component to display on the right side of each item */ + rightHandSideComponent?: ((item: TItem, isFocused?: boolean) => ReactElement | null | undefined) | ReactElement | null; + + /** Whether tooltips should be shown */ + shouldShowTooltips?: boolean; /** Called when a checkbox is pressed */ onCheckboxPress?: (item: TItem) => void; - /** Called when the list is scrolled and the user begins dragging */ - onScrollBeginDrag?: () => void; - /** Callback to fire when an error is dismissed */ onDismissError?: (item: TItem) => void; - /** Called once when the scroll position gets within onEndReachedThreshold of the rendered content */ - onEndReached?: () => void; - - /** How far from the end the bottom edge of the list must be to trigger onEndReached */ - onEndReachedThreshold?: number; - - /** Configuration for the confirm button */ - confirmButtonOptions?: ConfirmButtonOptions; + /** Whether to prevent default focus on row selection */ + shouldPreventDefaultFocusOnSelectRow?: boolean; - /** Custom header content to render instead of the default select all header */ - customListHeader?: React.ReactNode; + /** Whether to single execution onRowSelect to avoid double clicks on mobile app */ + shouldSingleExecuteRowSelect?: boolean; - /** Custom content to display in the header of list component. */ - customListHeaderContent?: React.JSX.Element | null; + /** Configuration options for the text input */ + textInputOptions?: TextInputOptions; - /** Custom component to render while data is loading */ - customLoadingPlaceholder?: React.JSX.Element; + /** Whether to show the text input */ + shouldShowTextInput?: boolean; - /** Custom content to display in the footer */ - footerContent?: React.ReactNode; + /** Whether new options are loading */ + isLoadingNewOptions?: boolean; /** Custom content to display when the list is empty */ listEmptyContent?: React.JSX.Element | null | undefined; - /** Custom content to display in the footer of list component */ - listFooterContent?: React.JSX.Element | null | undefined; - - /** Component to display on the right side of each item */ - rightHandSideComponent?: ((item: TItem, isFocused?: boolean) => ReactElement | null | undefined) | ReactElement | null; - - /** Number of lines to show for alternate text */ - alternateNumberOfSupportedLines?: number; + /** Whether to show the empty list content */ + showListEmptyContent?: boolean; - /** Array of selected item keys */ - selectedItems?: readonly string[]; + /** Whether to add bottom safe area padding */ + addBottomSafeAreaPadding?: boolean; /** Styles for the list */ style?: SelectionListStyle; - /** Function that determines if an item is selected */ - isSelected?: (item: TItem) => boolean; + /** Whether to debounce scrolling on focused item change */ + shouldDebounceScrolling?: boolean; - /** Whether the whole list is disabled */ - isDisabled?: boolean; + /** Whether to scroll to the focused item */ + shouldScrollToFocusedIndex?: boolean; - /** Whether the layout is narrow */ - isSmallScreenWidth?: boolean; + /** Whether keyboard shortcuts should be disabled */ + disableKeyboardShortcuts?: boolean; - /** Whether new options are loading */ - isLoadingNewOptions?: boolean; + /** Whether to stop automatic propagation on pressing enter key */ + shouldStopPropagation?: boolean; - /** Whether to wrap long text */ - isRowMultilineSupported?: boolean; + /** Called once when the scroll position gets within onEndReachedThreshold of the rendered content */ + onEndReached?: () => void; - /** Whether to add bottom safe area padding */ - addBottomSafeAreaPadding?: boolean; + /** How far from the end the bottom edge of the list must be to trigger onEndReached */ + onEndReachedThreshold?: number; - /** Whether to show the empty list content */ - showListEmptyContent?: boolean; + /** Whether scroll position should change when focused item changes */ + disableMaintainingScrollPosition?: boolean; - /** Whether to show the loading placeholder */ - showLoadingPlaceholder?: boolean; + /** Whether to update the focused item on a row select */ + shouldUpdateFocusedIndex?: boolean; - /** Whether to show the vertical scroll indicator */ - showScrollIndicator?: boolean; + /** Whether to ignore focus events */ + shouldIgnoreFocus?: boolean; - /** Whether this is a multi-select list */ - canSelectMultiple?: boolean; + /** Called when the list is scrolled and the user begins dragging */ + onScrollBeginDrag?: () => void; +}; - /** Whether keyboard shortcuts should be disabled */ - disableKeyboardShortcuts?: boolean; +/** + * Props specific to the flat SelectionList component (without sections). + * Extends BaseSelectionListProps with additional features like select all, + * long press, confirm button, and more advanced customization options. + */ +type SelectionListProps = Partial & + BaseSelectionListProps & { + /** Array of items to display in the list */ + data: TItem[]; - /** Whether scroll position should change when focused item changes */ - disableMaintainingScrollPosition?: boolean; + /** Reference to the SelectionList component */ + ref?: React.Ref>; - /** Whether to use the user skeleton view */ - shouldUseUserSkeletonView?: boolean; + /** Key of the item to focus initially */ + initiallyFocusedItemKey?: string; - /** Whether tooltips should be shown */ - shouldShowTooltips?: boolean; + /** Called when "Select All" button is pressed */ + onSelectAll?: () => void; - /** Whether to ignore focus events */ - shouldIgnoreFocus?: boolean; + /** Callback to fire when the item is long pressed */ + onLongPressRow?: (item: TItem) => void; - /** Whether to show the right caret icon */ - shouldShowRightCaret?: boolean; + /** Configuration for the confirm button */ + confirmButtonOptions?: ConfirmButtonOptions; - /** Whether to stop automatic propagation on pressing enter key */ - shouldStopPropagation?: boolean; + /** Custom header content to render instead of the default select all header */ + customListHeader?: React.ReactNode; - /** Whether to place customListHeader in the list so it scrolls with data */ - shouldHeaderBeInsideList?: boolean; + /** Custom content to display in the header of list component. */ + customListHeaderContent?: React.JSX.Element | null; - /** Whether to scroll to the focused item */ - shouldScrollToFocusedIndex?: boolean; + /** Custom component to render while data is loading */ + customLoadingPlaceholder?: React.JSX.Element; - /** Whether to debounce scrolling on focused item change */ - shouldDebounceScrolling?: boolean; + /** Custom content to display in the footer of list component */ + listFooterContent?: React.JSX.Element | null | undefined; - /** Whether to update the focused item on a row select */ - shouldUpdateFocusedIndex?: boolean; + /** Number of lines to show for alternate text */ + alternateNumberOfSupportedLines?: number; - /** Whether to single execution onRowSelect to avoid double clicks on mobile app */ - shouldSingleExecuteRowSelect?: boolean; + /** Array of selected item keys */ + selectedItems?: readonly string[]; - /** Whether to prevent default focus on row selection */ - shouldPreventDefaultFocusOnSelectRow?: boolean; + /** Function that determines if an item is selected */ + isSelected?: (item: TItem) => boolean; - /** Whether to show the text input */ - shouldShowTextInput?: boolean; + /** Whether the whole list is disabled */ + isDisabled?: boolean; - /** Whether to clear the text input when a row is selected */ - shouldClearInputOnSelect?: boolean; + /** Whether the layout is narrow */ + isSmallScreenWidth?: boolean; - /** Whether to highlight the selected item */ - shouldHighlightSelectedItem?: boolean; + /** Whether to wrap long text */ + isRowMultilineSupported?: boolean; - /** Whether to show the default right hand side checkmark */ - shouldUseDefaultRightHandSideCheckmark?: boolean; + /** Whether to show the vertical scroll indicator */ + showScrollIndicator?: boolean; - /** Whether hover style should be disabled */ - shouldDisableHoverStyle?: boolean; + /** Whether to use the user skeleton view */ + shouldUseUserSkeletonView?: boolean; + + /** Whether to show the right caret icon */ + shouldShowRightCaret?: boolean; + + /** 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; + + /** Whether to show the default right hand side checkmark */ + shouldUseDefaultRightHandSideCheckmark?: boolean; + + /** Whether hover style should be disabled */ + shouldDisableHoverStyle?: boolean; + + /** Whether to set the hover style */ + setShouldDisableHoverStyle?: React.Dispatch>; + }; - /** Whether to set the hover style */ - setShouldDisableHoverStyle?: React.Dispatch>; -}; type SelectionListStyle = { /** Styles for the list */ @@ -183,6 +196,9 @@ type SelectionListStyle = { /** Styles for the title container of the list item */ listItemTitleContainerStyles?: StyleProp; + + /** Styles for the section titles */ + sectionTitleStyles?: StyleProp; }; type TextInputOptions = { @@ -288,4 +304,4 @@ type DataDetailsType = { disabledArrowKeyIndexes: number[]; }; -export type {DataDetailsType, SelectionListHandle, SelectionListProps, TextInputOptions, ConfirmButtonOptions, ListItem, ButtonOrCheckBoxRoles, SelectionListStyle}; +export type {BaseSelectionListProps, DataDetailsType, SelectionListHandle, SelectionListProps, TextInputOptions, ConfirmButtonOptions, ListItem, ButtonOrCheckBoxRoles, SelectionListStyle}; From 06e346ba068e8479cc0e69fcb69cd5feb9208f1f Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Wed, 21 Jan 2026 10:24:24 +0100 Subject: [PATCH 05/16] Fix unit test and run prettier --- src/components/SelectionList/BaseSelectionList.tsx | 4 ++-- .../NewBaseSelectionListWithSections.tsx | 4 ++-- .../SelectionList/hooks/useSearchFocusSync.ts | 2 +- .../SelectionList/hooks/useSelectedItemFocusSync.ts | 7 ++----- src/components/SelectionList/types.ts | 5 ++--- src/libs/TaxOptionsListUtils.ts | 12 +++++++++++- tests/unit/TaxOptionsListUtilsTest.ts | 8 ++++---- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 10356bb69b40..6dd1de9cc17a 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -22,10 +22,10 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import Footer from './components/Footer'; import ListHeader from './components/ListHeader'; import TextInput from './components/TextInput'; -import ListItemRenderer from './ListItem/ListItemRenderer'; -import type {ButtonOrCheckBoxRoles, DataDetailsType, ListItem, SelectionListProps} from './types'; import useSearchFocusSync from './hooks/useSearchFocusSync'; import useSelectedItemFocusSync from './hooks/useSelectedItemFocusSync'; +import ListItemRenderer from './ListItem/ListItemRenderer'; +import type {ButtonOrCheckBoxRoles, DataDetailsType, ListItem, SelectionListProps} from './types'; const ANIMATED_HIGHLIGHT_DURATION = CONST.ANIMATED_HIGHLIGHT_ENTRY_DELAY + diff --git a/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx index c3e9da8f4169..7c07539e3f10 100644 --- a/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx @@ -7,6 +7,8 @@ import {View} from 'react-native'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import Footer from '@components/SelectionList/components/Footer'; import TextInput from '@components/SelectionList/components/TextInput'; +import useSearchFocusSync from '@components/SelectionList/hooks/useSearchFocusSync'; +import useSelectedItemFocusSync from '@components/SelectionList/hooks/useSelectedItemFocusSync'; import ListItemRenderer from '@components/SelectionList/ListItem/ListItemRenderer'; import type {ButtonOrCheckBoxRoles} from '@components/SelectionList/types'; import Text from '@components/Text'; @@ -23,8 +25,6 @@ import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {FlattenedItem, ListItem, SectionHeader, SectionListItem, SelectionListWithSectionsProps} from './types'; -import useSelectedItemFocusSync from '../hooks/useSelectedItemFocusSync'; -import useSearchFocusSync from '../hooks/useSearchFocusSync'; function getItemType(item: FlattenedItem): 'header' | 'row' { return item?.type ?? 'row'; diff --git a/src/components/SelectionList/hooks/useSearchFocusSync.ts b/src/components/SelectionList/hooks/useSearchFocusSync.ts index 4960fd1c1343..aadee2f170e8 100644 --- a/src/components/SelectionList/hooks/useSearchFocusSync.ts +++ b/src/components/SelectionList/hooks/useSearchFocusSync.ts @@ -1,6 +1,6 @@ import {useEffect} from 'react'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; import usePrevious from '@hooks/usePrevious'; -import type {ListItem} from '../ListItem/types'; type UseSearchFocusSyncParams = { /** The current search value from text input */ diff --git a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts index 132f6e951414..4f75498c4012 100644 --- a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts +++ b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts @@ -1,5 +1,5 @@ import {useEffect, useMemo} from 'react'; -import type {ListItem} from '../ListItem/types'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; type UseSelectedItemFocusSyncParams = { /** Array of items to search in */ @@ -33,10 +33,7 @@ function useSelectedItemFocusSync({ searchValue, setFocusedIndex, }: UseSelectedItemFocusSyncParams) { - const selectedItemIndex = useMemo( - () => (initiallyFocusedItemKey ? items.findIndex(isItemSelected) : -1), - [items, initiallyFocusedItemKey, isItemSelected], - ); + const selectedItemIndex = useMemo(() => (initiallyFocusedItemKey ? items.findIndex(isItemSelected) : -1), [items, initiallyFocusedItemKey, isItemSelected]); useEffect(() => { if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex || searchValue) { diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 600b7697e8aa..66a368486903 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -177,7 +177,6 @@ type SelectionListProps = Partial & setShouldDisableHoverStyle?: React.Dispatch>; }; - type SelectionListStyle = { /** Styles for the list */ listStyle?: StyleProp; @@ -197,8 +196,8 @@ type SelectionListStyle = { /** Styles for the title container of the list item */ listItemTitleContainerStyles?: StyleProp; - /** Styles for the section titles */ - sectionTitleStyles?: StyleProp; + /** Styles for the section titles */ + sectionTitleStyles?: StyleProp; }; type TextInputOptions = { diff --git a/src/libs/TaxOptionsListUtils.ts b/src/libs/TaxOptionsListUtils.ts index a0940e533746..a163813f8c64 100644 --- a/src/libs/TaxOptionsListUtils.ts +++ b/src/libs/TaxOptionsListUtils.ts @@ -24,7 +24,8 @@ type Tax = { }; type TaxSection = { - title?: string; + title: string | undefined; + shouldShow: boolean; data: TaxRatesOption[]; }; @@ -92,6 +93,8 @@ function getTaxRatesSection({ if (numberOfTaxRates === 0 && selectedOptions.length > 0) { policyRatesSections.push({ // "Selected" section + title: '', + shouldShow: false, data: getTaxRatesOptions(selectedTaxRateWithDisabledState), }); @@ -106,6 +109,8 @@ function getTaxRatesSection({ policyRatesSections.push({ // "Search" section + title: '', + shouldShow: true, data: getTaxRatesOptions(taxesForSearch), }); @@ -116,6 +121,7 @@ function getTaxRatesSection({ policyRatesSections.push({ // "All" section when items amount less than the threshold title: '', + shouldShow: false, data: getTaxRatesOptions([...selectedTaxRateWithDisabledState, ...enabledTaxRatesWithoutSelectedOptions]), }); @@ -125,12 +131,16 @@ function getTaxRatesSection({ if (selectedOptions.length > 0) { policyRatesSections.push({ // "Selected" section + title: '', + shouldShow: true, data: getTaxRatesOptions(selectedTaxRateWithDisabledState), }); } policyRatesSections.push({ // "All" section when number of items are more than the threshold + title: '', + shouldShow: true, data: getTaxRatesOptions(enabledTaxRatesWithoutSelectedOptions), }); diff --git a/tests/unit/TaxOptionsListUtilsTest.ts b/tests/unit/TaxOptionsListUtilsTest.ts index b61a527aef5c..3aa41744693a 100644 --- a/tests/unit/TaxOptionsListUtilsTest.ts +++ b/tests/unit/TaxOptionsListUtilsTest.ts @@ -60,7 +60,7 @@ describe('TaxOptionsListUtils', () => { code: 'CODE1', isDisabled: false, isSelected: undefined, - keyForList: 'Tax exempt 1 (0%) • Default', + keyForList: 'Tax exempt 1 (0%) • Default-0', searchText: 'Tax exempt 1 (0%) • Default', text: 'Tax exempt 1 (0%) • Default', tooltipText: 'Tax exempt 1 (0%) • Default', @@ -70,7 +70,7 @@ describe('TaxOptionsListUtils', () => { code: 'CODE3', isDisabled: false, isSelected: undefined, - keyForList: 'Tax option 3 (5%)', + keyForList: 'Tax option 3 (5%)-1', searchText: 'Tax option 3 (5%)', text: 'Tax option 3 (5%)', tooltipText: 'Tax option 3 (5%)', @@ -80,7 +80,7 @@ describe('TaxOptionsListUtils', () => { code: 'CODE2', isDisabled: true, isSelected: undefined, - keyForList: 'Tax rate 2 (3%)', + keyForList: 'Tax rate 2 (3%)-2', searchText: 'Tax rate 2 (3%)', text: 'Tax rate 2 (3%)', tooltipText: 'Tax rate 2 (3%)', @@ -99,7 +99,7 @@ describe('TaxOptionsListUtils', () => { code: 'CODE2', isDisabled: true, isSelected: undefined, - keyForList: 'Tax rate 2 (3%)', + keyForList: 'Tax rate 2 (3%)-0', searchText: 'Tax rate 2 (3%)', text: 'Tax rate 2 (3%)', tooltipText: 'Tax rate 2 (3%)', From f6b3e4ba88e5b4cfffbc11ef036c3aeacb039d43 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Wed, 21 Jan 2026 10:35:22 +0100 Subject: [PATCH 06/16] Change name of new component --- ...ListWithSections.tsx => BaseSelectionListWithSections.tsx} | 4 ++-- .../SelectionList/SelectionListWithSections/index.native.tsx | 2 +- .../SelectionList/SelectionListWithSections/index.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/components/SelectionList/SelectionListWithSections/{NewBaseSelectionListWithSections.tsx => BaseSelectionListWithSections.tsx} (99%) diff --git a/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx similarity index 99% rename from src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx rename to src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 7c07539e3f10..34cb4d0226cf 100644 --- a/src/components/SelectionList/SelectionListWithSections/NewBaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -34,7 +34,7 @@ function isItemSelected(item: TItem): boolean { return item?.isSelected ?? false; } -function NewBaseSelectionListWithSections({ +function BaseSelectionListWithSections({ sections, ListItem, onSelectRow, @@ -392,4 +392,4 @@ function NewBaseSelectionListWithSections({ ); } -export default NewBaseSelectionListWithSections; +export default BaseSelectionListWithSections; diff --git a/src/components/SelectionList/SelectionListWithSections/index.native.tsx b/src/components/SelectionList/SelectionListWithSections/index.native.tsx index dca23af56156..f8da85f5f273 100644 --- a/src/components/SelectionList/SelectionListWithSections/index.native.tsx +++ b/src/components/SelectionList/SelectionListWithSections/index.native.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {Keyboard} from 'react-native'; -import BaseSelectionList from './NewBaseSelectionListWithSections'; +import BaseSelectionList from './BaseSelectionListWithSections'; import type {ListItem, SelectionListWithSectionsProps} from './types'; function SelectionList(props: SelectionListWithSectionsProps) { diff --git a/src/components/SelectionList/SelectionListWithSections/index.tsx b/src/components/SelectionList/SelectionListWithSections/index.tsx index 13f69e70d884..cc3f8428010e 100644 --- a/src/components/SelectionList/SelectionListWithSections/index.tsx +++ b/src/components/SelectionList/SelectionListWithSections/index.tsx @@ -2,7 +2,7 @@ import React, {useEffect, useState} from 'react'; import {isMobileChrome} from '@libs/Browser'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; -import BaseSelectionList from './NewBaseSelectionListWithSections'; +import BaseSelectionList from './BaseSelectionListWithSections'; import type {ListItem, SelectionListWithSectionsProps} from './types'; function SelectionList({ref, ...props}: SelectionListWithSectionsProps) { From e22baf704791d332ea22fcd7e7e216212df97519 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Wed, 21 Jan 2026 10:54:13 +0100 Subject: [PATCH 07/16] Extract common behaviour to useWebSelectionListBehaviour --- .../SelectionListWithSections/index.tsx | 53 +------- .../hooks/useWebSelectionListBehavior.ts | 122 ++++++++++++++++++ src/components/SelectionList/index.tsx | 85 +----------- 3 files changed, 132 insertions(+), 128 deletions(-) create mode 100644 src/components/SelectionList/hooks/useWebSelectionListBehavior.ts diff --git a/src/components/SelectionList/SelectionListWithSections/index.tsx b/src/components/SelectionList/SelectionListWithSections/index.tsx index cc3f8428010e..ec4452e6f0a4 100644 --- a/src/components/SelectionList/SelectionListWithSections/index.tsx +++ b/src/components/SelectionList/SelectionListWithSections/index.tsx @@ -1,55 +1,10 @@ -import React, {useEffect, useState} from 'react'; -import {isMobileChrome} from '@libs/Browser'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import CONST from '@src/CONST'; +import React from 'react'; +import useWebSelectionListBehavior from '@components/SelectionList/hooks/useWebSelectionListBehavior'; import BaseSelectionList from './BaseSelectionListWithSections'; import type {ListItem, SelectionListWithSectionsProps} from './types'; function SelectionList({ref, ...props}: SelectionListWithSectionsProps) { - const [isScreenTouched, setIsScreenTouched] = useState(false); - const [shouldDebounceScrolling, setShouldDebounceScrolling] = useState(false); - - const touchStart = () => setIsScreenTouched(true); - const touchEnd = () => setIsScreenTouched(false); - - useEffect(() => { - if (!canUseTouchScreen()) { - return; - } - // We're setting `isScreenTouched` in this listener only for web platforms with touchscreen (mWeb) where - // we want to dismiss the keyboard only when the list is scrolled by the user and not when it's scrolled programmatically. - document.addEventListener('touchstart', touchStart); - document.addEventListener('touchend', touchEnd); - - return () => { - document.removeEventListener('touchstart', touchStart); - document.removeEventListener('touchend', touchEnd); - }; - }, []); - - const handleKeyboardScrollDebounce = (event: KeyboardEvent) => { - if (!event) { - return; - } - // Moving through items using the keyboard triggers scrolling by the browser, so we debounce programmatic scrolling to prevent jittering. - if ( - event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN.shortcutKey || - event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey || - event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey - ) { - setShouldDebounceScrolling(event.type === 'keydown'); - } - }; - - useEffect(() => { - document.addEventListener('keydown', handleKeyboardScrollDebounce, {passive: true}); - document.addEventListener('keyup', handleKeyboardScrollDebounce, {passive: true}); - - return () => { - document.removeEventListener('keydown', handleKeyboardScrollDebounce); - document.removeEventListener('keyup', handleKeyboardScrollDebounce); - }; - }, []); + const {shouldIgnoreFocus, shouldDebounceScrolling} = useWebSelectionListBehavior(); return ( ({ref, ...props}: SelectionListWit ref={ref} // Ignore the focus if it's caused by a touch event on mobile chrome. // For example, a long press will trigger a focus event on mobile chrome. - shouldIgnoreFocus={isMobileChrome() && isScreenTouched} + shouldIgnoreFocus={shouldIgnoreFocus} shouldDebounceScrolling={shouldDebounceScrolling} /> ); diff --git a/src/components/SelectionList/hooks/useWebSelectionListBehavior.ts b/src/components/SelectionList/hooks/useWebSelectionListBehavior.ts new file mode 100644 index 000000000000..75cc1fc0a67d --- /dev/null +++ b/src/components/SelectionList/hooks/useWebSelectionListBehavior.ts @@ -0,0 +1,122 @@ +import {useEffect, useState} from 'react'; +import {isMobileChrome} from '@libs/Browser'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import CONST from '@src/CONST'; + +type UseWebSelectionListBehaviorOptions = { + /** Whether to track hover style state (only used by flat SelectionList) */ + shouldTrackHoverStyle?: boolean; +}; + +type UseWebSelectionListBehaviorResult = { + /** Whether the current focus should be ignored (touch event on mobile chrome) */ + shouldIgnoreFocus: boolean; + + /** Whether scrolling should be debounced (during keyboard navigation) */ + shouldDebounceScrolling: boolean; + + /** Whether hover style should be disabled (only when shouldTrackHoverStyle is true) */ + shouldDisableHoverStyle: boolean; + + /** Setter for hover style state (only when shouldTrackHoverStyle is true) */ + setShouldDisableHoverStyle: React.Dispatch>; +}; + +/** + * Custom hook that handles common web-specific behaviors for SelectionList components: + * - Touch screen detection for keyboard dismissal + * - Keyboard navigation scroll debouncing + * - Optional hover style tracking for mouse interactions + */ +function useWebSelectionListBehavior({shouldTrackHoverStyle = false}: UseWebSelectionListBehaviorOptions = {}): UseWebSelectionListBehaviorResult { + const [isScreenTouched, setIsScreenTouched] = useState(false); + const [shouldDebounceScrolling, setShouldDebounceScrolling] = useState(false); + const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); + + // Touch screen detection for mWeb - used to determine if keyboard should be dismissed + useEffect(() => { + if (!canUseTouchScreen()) { + return; + } + + const touchStart = () => setIsScreenTouched(true); + const touchEnd = () => setIsScreenTouched(false); + + // We're setting `isScreenTouched` in this listener only for web platforms with touchscreen (mWeb) where + // we want to dismiss the keyboard only when the list is scrolled by the user and not when it's scrolled programmatically. + document.addEventListener('touchstart', touchStart); + document.addEventListener('touchend', touchEnd); + + return () => { + document.removeEventListener('touchstart', touchStart); + document.removeEventListener('touchend', touchEnd); + }; + }, []); + + // Keyboard scroll debouncing - prevents jittering when navigating with arrow keys + useEffect(() => { + const handleKeyboardScrollDebounce = (event: KeyboardEvent) => { + if (!event) { + return; + } + // Moving through items using the keyboard triggers scrolling by the browser, so we debounce programmatic scrolling to prevent jittering. + if ( + event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN.shortcutKey || + event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey || + event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey + ) { + setShouldDebounceScrolling(event.type === 'keydown'); + } + }; + + document.addEventListener('keydown', handleKeyboardScrollDebounce, {passive: true}); + document.addEventListener('keyup', handleKeyboardScrollDebounce, {passive: true}); + + return () => { + document.removeEventListener('keydown', handleKeyboardScrollDebounce); + document.removeEventListener('keyup', handleKeyboardScrollDebounce); + }; + }, []); + + // Hover style tracking - re-enables hover when mouse moves (only for flat SelectionList) + useEffect(() => { + if (!shouldTrackHoverStyle || canUseTouchScreen()) { + return; + } + + let lastClientX = 0; + let lastClientY = 0; + + const mouseMoveHandler = (event: MouseEvent) => { + // On Safari, scrolling can also trigger a mousemove event, + // so this comparison is needed to filter out cases where the mouse hasn't actually moved. + if (event.clientX === lastClientX && event.clientY === lastClientY) { + return; + } + + lastClientX = event.clientX; + lastClientY = event.clientY; + + setShouldDisableHoverStyle(false); + }; + + const wheelHandler = () => setShouldDisableHoverStyle(false); + + document.addEventListener('mousemove', mouseMoveHandler, {passive: true}); + document.addEventListener('wheel', wheelHandler, {passive: true}); + + return () => { + document.removeEventListener('mousemove', mouseMoveHandler); + document.removeEventListener('wheel', wheelHandler); + }; + }, [shouldTrackHoverStyle]); + + return { + shouldIgnoreFocus: isMobileChrome() && isScreenTouched, + shouldDebounceScrolling, + shouldDisableHoverStyle, + setShouldDisableHoverStyle, + }; +} + +export default useWebSelectionListBehavior; diff --git a/src/components/SelectionList/index.tsx b/src/components/SelectionList/index.tsx index a1a2f1ccadc5..f35d856d078d 100644 --- a/src/components/SelectionList/index.tsx +++ b/src/components/SelectionList/index.tsx @@ -1,85 +1,12 @@ -import React, {useEffect, useState} from 'react'; -import {isMobileChrome} from '@libs/Browser'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import CONST from '@src/CONST'; +import React from 'react'; import BaseSelectionList from './BaseSelectionList'; +import useWebSelectionListBehavior from './hooks/useWebSelectionListBehavior'; import type {ListItem, SelectionListProps} from './types'; function SelectionList({ref, ...props}: SelectionListProps) { - const [isScreenTouched, setIsScreenTouched] = useState(false); - const [shouldDebounceScrolling, setShouldDebounceScrolling] = useState(false); - const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); - - const touchStart = () => setIsScreenTouched(true); - const touchEnd = () => setIsScreenTouched(false); - - useEffect(() => { - if (!canUseTouchScreen()) { - return; - } - // We're setting `isScreenTouched` in this listener only for web platforms with touchscreen (mWeb) where - // we want to dismiss the keyboard only when the list is scrolled by the user and not when it's scrolled programmatically. - document.addEventListener('touchstart', touchStart); - document.addEventListener('touchend', touchEnd); - - return () => { - document.removeEventListener('touchstart', touchStart); - document.removeEventListener('touchend', touchEnd); - }; - }, []); - - const handleKeyboardScrollDebounce = (event: KeyboardEvent) => { - if (!event) { - return; - } - // Moving through items using the keyboard triggers scrolling by the browser, so we debounce programmatic scrolling to prevent jittering. - if ( - event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN.shortcutKey || - event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey || - event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey - ) { - setShouldDebounceScrolling(event.type === 'keydown'); - } - }; - - useEffect(() => { - document.addEventListener('keydown', handleKeyboardScrollDebounce, {passive: true}); - document.addEventListener('keyup', handleKeyboardScrollDebounce, {passive: true}); - - return () => { - document.removeEventListener('keydown', handleKeyboardScrollDebounce); - document.removeEventListener('keyup', handleKeyboardScrollDebounce); - }; - }, []); - - useEffect(() => { - if (canUseTouchScreen()) { - return; - } - - let lastClientX = 0; - let lastClientY = 0; - const mouseMoveHandler = (event: MouseEvent) => { - // On Safari, scrolling can also trigger a mousemove event, - // so this comparison is needed to filter out cases where the mouse hasn't actually moved. - if (event.clientX === lastClientX && event.clientY === lastClientY) { - return; - } - - lastClientX = event.clientX; - lastClientY = event.clientY; - - setShouldDisableHoverStyle(false); - }; - const wheelHandler = () => setShouldDisableHoverStyle(false); - - document.addEventListener('mousemove', mouseMoveHandler, {passive: true}); - document.addEventListener('wheel', wheelHandler, {passive: true}); - return () => { - document.removeEventListener('mousemove', mouseMoveHandler); - document.removeEventListener('wheel', wheelHandler); - }; - }, []); + const {shouldIgnoreFocus, shouldDebounceScrolling, shouldDisableHoverStyle, setShouldDisableHoverStyle} = useWebSelectionListBehavior({ + shouldTrackHoverStyle: true, + }); return ( ({ref, ...props}: SelectionListPro ref={ref} // Ignore the focus if it's caused by a touch event on mobile chrome. // For example, a long press will trigger a focus event on mobile chrome. - shouldIgnoreFocus={isMobileChrome() && isScreenTouched} + shouldIgnoreFocus={shouldIgnoreFocus} shouldDebounceScrolling={shouldDebounceScrolling} shouldDisableHoverStyle={shouldDisableHoverStyle} setShouldDisableHoverStyle={setShouldDisableHoverStyle} From 617b3b160281583f4f40adfba420ff4253f03fe6 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Thu, 22 Jan 2026 10:25:54 +0100 Subject: [PATCH 08/16] Improve SelectionListWithSections --- src/CONST/index.ts | 5 + .../SelectionList/BaseSelectionList.tsx | 6 +- .../BaseSelectionListWithSections.tsx | 327 +++++++----------- .../SelectionListWithSections/types.ts | 3 - .../hooks/useFlattenedSections.ts | 94 +++++ .../SelectionList/hooks/useSearchFocusSync.ts | 22 +- .../hooks/useSelectedItemFocusSync.ts | 14 +- .../useWebSelectionListBehavior/index.ts | 20 ++ .../index.web.ts} | 20 +- .../useWebSelectionListBehavior/types.ts | 20 ++ src/components/SelectionList/types.ts | 6 +- 11 files changed, 293 insertions(+), 244 deletions(-) create mode 100644 src/components/SelectionList/hooks/useFlattenedSections.ts create mode 100644 src/components/SelectionList/hooks/useWebSelectionListBehavior/index.ts rename src/components/SelectionList/hooks/{useWebSelectionListBehavior.ts => useWebSelectionListBehavior/index.web.ts} (85%) create mode 100644 src/components/SelectionList/hooks/useWebSelectionListBehavior/types.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5c7d7bb1b813..65202478fa78 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8030,6 +8030,11 @@ const CONST = { /** Onyx prefix for domain security groups */ DOMAIN_SECURITY_GROUP_PREFIX: 'domain_securityGroup_', }, + + SECTION_LIST_ITEM_TYPE: { + HEADER: 'header', + ROW: 'row', + }, } as const; const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [ diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 6dd1de9cc17a..7b084bf89734 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -192,7 +192,7 @@ function BaseSelectionList({ (shouldDebounceScrolling ? debouncedScrollToIndex : scrollToIndex)(index); }, - ...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}), + setHasKeyBeenPressed, isFocused, onArrowUpDownCallback, }); @@ -449,7 +449,7 @@ function BaseSelectionList({ ); useSelectedItemFocusSync({ - items: data, + data, initiallyFocusedItemKey, isItemSelected, focusedIndex, @@ -459,7 +459,7 @@ function BaseSelectionList({ useSearchFocusSync({ searchValue: textInputOptions?.value, - items: data, + data, selectedOptionsCount: dataDetails.selectedOptions.length, isItemSelected, canSelectMultiple, diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 34cb4d0226cf..513aa9772d1d 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -1,12 +1,14 @@ import {useIsFocused} from '@react-navigation/native'; import {FlashList} from '@shopify/flash-list'; import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; -import React, {useCallback, useImperativeHandle, useMemo, useRef} from 'react'; +import React, {useCallback, useImperativeHandle, useRef} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import Footer from '@components/SelectionList/components/Footer'; import TextInput from '@components/SelectionList/components/TextInput'; +import useFlattenedSections, {isItemSelected, shouldTreatItemAsDisabled} from '@components/SelectionList/hooks/useFlattenedSections'; import useSearchFocusSync from '@components/SelectionList/hooks/useSearchFocusSync'; import useSelectedItemFocusSync from '@components/SelectionList/hooks/useSelectedItemFocusSync'; import ListItemRenderer from '@components/SelectionList/ListItem/ListItemRenderer'; @@ -24,51 +26,47 @@ import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import type {FlattenedItem, ListItem, SectionHeader, SectionListItem, SelectionListWithSectionsProps} from './types'; +import type {FlattenedItem, ListItem, SectionHeader, SelectionListWithSectionsProps} from './types'; -function getItemType(item: FlattenedItem): 'header' | 'row' { - return item?.type ?? 'row'; -} - -function isItemSelected(item: TItem): boolean { - return item?.isSelected ?? false; +function getItemType(item: FlattenedItem): ValueOf { + return item?.type ?? CONST.SECTION_LIST_ITEM_TYPE.ROW; } function BaseSelectionListWithSections({ sections, + ref, ListItem, + textInputOptions, + initiallyFocusedItemKey, onSelectRow, - ref, - canSelectMultiple = false, - initiallyFocusedOptionKey, + onDismissError, + onScrollBeginDrag, + onEndReached, + onEndReachedThreshold, customHeaderContent, - footerContent, - showLoadingPlaceholder = false, rightHandSideComponent, - shouldShowTooltips = true, - onDismissError, - shouldPreventDefaultFocusOnSelectRow = false, - shouldSingleExecuteRowSelect = false, - textInputOptions, - isLoadingNewOptions, - shouldShowTextInput, listEmptyContent, - showListEmptyContent = true, - shouldScrollToFocusedIndex = true, - shouldDebounceScrolling = false, + footerContent, style, - onScrollBeginDrag, addBottomSafeAreaPadding, + isLoadingNewOptions, + canSelectMultiple = false, + showLoadingPlaceholder = false, + showListEmptyContent = true, + shouldShowTooltips = true, disableKeyboardShortcuts = false, - shouldStopPropagation = false, - onEndReached, - onEndReachedThreshold, disableMaintainingScrollPosition = false, - shouldUpdateFocusedIndex = false, + shouldShowTextInput, shouldIgnoreFocus = false, + shouldStopPropagation = false, + shouldDebounceScrolling = false, + shouldUpdateFocusedIndex = false, + shouldScrollToFocusedIndex = true, + shouldSingleExecuteRowSelect = false, + shouldPreventDefaultFocusOnSelectRow = false, }: SelectionListWithSectionsProps) { const styles = useThemeStyles(); - const isFocused = useIsFocused(); + const isScreenFocused = useIsFocused(); const scrollEnabled = useScrollEnabled(); const {singleExecution} = useSingleExecution(); const listRef = useRef> | null>(null); @@ -79,90 +77,41 @@ function BaseSelectionListWithSections({ const {isKeyboardShown} = useKeyboardState(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); - const paddingBottomStyle = useMemo(() => !isKeyboardShown && safeAreaPaddingBottomStyle, [isKeyboardShown, safeAreaPaddingBottomStyle]); - const hasFooter = !!footerContent; - - const {flattenedData, headerIndices, itemsOnly, selectedItems} = useMemo(() => { - const data: Array> = []; - const selectedOptions: TItem[] = []; - const disabledArrowKeyIndexes: number[] = []; - const headers: number[] = []; - const items: Array> = []; - let itemIndex = 0; - - for (const section of sections) { - if (section.title) { - headers.push(data.length); - data.push({ - type: 'header', - title: section.title, - keyForList: `header-${section.title}`, - isDisabled: true, - }); - } - - for (const item of section.data ?? []) { - const itemWithIndex = { - ...item, - type: 'row', - flatIndex: itemIndex, - } as SectionListItem; - data.push(itemWithIndex); - items.push(itemWithIndex); - - if (itemWithIndex.isSelected) { - selectedOptions.push(itemWithIndex); - } + const paddingBottomStyle = !isKeyboardShown && !footerContent && safeAreaPaddingBottomStyle; - const isItemDisabled = section.isDisabled === true || (!!item?.isDisabled && !isItemSelected(item)); - if (isItemDisabled) { - disabledArrowKeyIndexes.push(itemIndex); - } + const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex} = useFlattenedSections(sections, initiallyFocusedItemKey); - itemIndex++; - } - } - - return {flattenedData: data, headerIndices: headers, itemsOnly: items, selectedItems: selectedOptions}; - }, [sections]); - - const initialFocusedIndex = useMemo(() => itemsOnly.findIndex((item) => item.keyForList === initiallyFocusedOptionKey), [itemsOnly, initiallyFocusedOptionKey]); - - const setHasKeyBeenPressed = useCallback(() => { + const setHasKeyBeenPressed = () => { if (hasKeyBeenPressed.current) { return; } hasKeyBeenPressed.current = true; - }, []); + }; - const scrollToIndex = useCallback( - (index: number) => { - // Bounds check: ensure index is valid for current data - if (index < 0 || index >= itemsOnly.length) { - return; - } - const item = itemsOnly.at(index); - if (!listRef.current || !item) { - return; - } - try { - listRef.current.scrollToIndex({index}); - } 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) - // The layout will be computed on next render, so we can safely ignore this - } - }, - [itemsOnly], - ); + const scrollToIndex = (index: number) => { + if (index < 0 || index >= flattenedData.length) { + return; + } + const item = flattenedData.at(index); + if (!listRef.current || !item || getItemType(item) === CONST.SECTION_LIST_ITEM_TYPE.HEADER) { + return; + } + try { + listRef.current.scrollToIndex({index}); + } 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) + // The layout will be computed on next render, so we can safely ignore this + } + }; const debouncedScrollToIndex = useDebounce(scrollToIndex, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME, {leading: true, trailing: true}); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ initialFocusedIndex, - maxIndex: itemsOnly.length - 1, - disabledIndexes: headerIndices, - isActive: isFocused, + maxIndex: flattenedData.length - 1, + disabledIndexes, + isActive: isScreenFocused, onFocusedIndexChange: (index: number) => { if (!shouldScrollToFocusedIndex) { return; @@ -170,45 +119,40 @@ function BaseSelectionListWithSections({ (shouldDebounceScrolling ? debouncedScrollToIndex : scrollToIndex)(index); }, - // eslint-disable-next-line react-hooks/refs - ...(!hasKeyBeenPressed.current && {setHasKeyBeenPressed}), - isFocused, + setHasKeyBeenPressed, + isFocused: isScreenFocused, }); - const focusedItem = useMemo(() => { + const getFocusedItem = (): TItem | undefined => { if (focusedIndex < 0 || focusedIndex >= flattenedData.length) { return; } const item = flattenedData.at(focusedIndex); - if (!item || (item.isDisabled && !isItemSelected(item))) { + if (!item || shouldTreatItemAsDisabled(item)) { return; } return item as TItem; - }, [flattenedData, focusedIndex]); + }; - const selectRow = useCallback( - (item: TItem, indexToFocus?: number) => { - if (!isFocused) { - return; - } - if (canSelectMultiple) { - if (shouldShowTextInput) { - textInputOptions?.onChangeText?.(''); - } - } - if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { - setFocusedIndex(indexToFocus); - } - onSelectRow(item); + const selectRow = (item: TItem, indexToFocus?: number) => { + if (!isScreenFocused) { + return; + } + if (canSelectMultiple && shouldShowTextInput) { + textInputOptions?.onChangeText?.(''); + } + if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') { + setFocusedIndex(indexToFocus); + } + onSelectRow(item); - if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { - innerTextInputRef.current.focus(); - } - }, - [isFocused, canSelectMultiple, shouldUpdateFocusedIndex, onSelectRow, shouldShowTextInput, shouldPreventDefaultFocusOnSelectRow, textInputOptions, setFocusedIndex], - ); + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) { + innerTextInputRef.current.focus(); + } + }; const selectFocusedItem = () => { + const focusedItem = getFocusedItem(); if (!focusedItem) { return; } @@ -232,21 +176,21 @@ function BaseSelectionListWithSections({ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER || CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, selectFocusedItem, { captureOnInputs: true, - shouldBubble: !focusedItem, + shouldBubble: !getFocusedItem(), shouldStopPropagation, - isActive: !disableKeyboardShortcuts && isFocused && focusedIndex >= 0 && !disableEnterShortcut, + isActive: !disableKeyboardShortcuts && isScreenFocused && focusedIndex >= 0 && !disableEnterShortcut, }); - const textInputKeyPress = useCallback((event: TextInputKeyPressEvent) => { + const textInputKeyPress = (event: TextInputKeyPressEvent) => { const key = event.nativeEvent.key; if (key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { focusedItemRef?.focus(); } - }, []); + }; useSelectedItemFocusSync({ - items: itemsOnly, - initiallyFocusedItemKey: initiallyFocusedOptionKey, + data: flattenedData, + initiallyFocusedItemKey, isItemSelected, focusedIndex, searchValue: textInputOptions?.value, @@ -255,7 +199,7 @@ function BaseSelectionListWithSections({ useSearchFocusSync({ searchValue: textInputOptions?.value, - items: itemsOnly, + data: flattenedData, selectedOptionsCount: selectedItems.length, isItemSelected, canSelectMultiple, @@ -265,6 +209,9 @@ function BaseSelectionListWithSections({ }); const textInputComponent = () => { + if (!shouldShowTextInput) { + return null; + } return ( ({ } }; - const renderItem = useCallback( - ({item, index}: ListRenderItemInfo>) => { - if (!item) { - return null; - } - if (getItemType(item) === 'header') { + const renderItem = ({item, index}: ListRenderItemInfo>) => { + if (!item) { + return null; + } + + switch (getItemType(item)) { + case CONST.SECTION_LIST_ITEM_TYPE.HEADER: return ( {(item as SectionHeader).title} ); - } - - const flatIndex = (item as SectionListItem).flatIndex ?? index; - const isItemFocused = flatIndex === focusedIndex; - const isDisabled = !!item.isDisabled; + case CONST.SECTION_LIST_ITEM_TYPE.ROW: { + const isItemFocused = index === focusedIndex; + const isDisabled = !!item.isDisabled; - return ( - - ); - }, - [ - focusedIndex, - ListItem, - selectRow, - shouldShowTooltips, - canSelectMultiple, - shouldSingleExecuteRowSelect, - onDismissError, - shouldPreventDefaultFocusOnSelectRow, - rightHandSideComponent, - setFocusedIndex, - singleExecution, - shouldIgnoreFocus, - style?.listItemWrapperStyle, - style?.listItemTitleStyles, - styles.optionsListSectionHeader, - styles.justifyContentCenter, - styles.ph5, - styles.textLabelSupporting, - ], - ); + return ( + + ); + } + default: + return null; + } + }; return ( - + {textInputComponent()} - {itemsOnly.length === 0 && (showLoadingPlaceholder || showListEmptyContent) ? ( + {itemsCount === 0 && (showLoadingPlaceholder || showListEmptyContent) ? ( renderListEmptyContent() ) : ( <> @@ -368,7 +297,7 @@ function BaseSelectionListWithSections({ data={flattenedData} renderItem={renderItem} ref={listRef} - extraData={itemsOnly.length} + extraData={flattenedData.length} getItemType={getItemType} initialScrollIndex={initialFocusedIndex} keyExtractor={(item) => item.keyForList} @@ -384,10 +313,12 @@ function BaseSelectionListWithSections({ /> )} - - footerContent={footerContent} - addBottomSafeAreaPadding={addBottomSafeAreaPadding} - /> + {!!footerContent && ( + + footerContent={footerContent} + addBottomSafeAreaPadding={addBottomSafeAreaPadding} + /> + )} ); } diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index 740b078de810..c2b6b199a0db 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -24,9 +24,6 @@ type SelectionListWithSectionsProps = BaseSelectionListP /** Array of sections to display in the list */ sections: Array>; - /** Key of the item to focus initially */ - initiallyFocusedOptionKey?: string | null; - /** Custom content to display in the header */ customHeaderContent?: ReactNode; }; diff --git a/src/components/SelectionList/hooks/useFlattenedSections.ts b/src/components/SelectionList/hooks/useFlattenedSections.ts new file mode 100644 index 000000000000..5cd4e81ff3ff --- /dev/null +++ b/src/components/SelectionList/hooks/useFlattenedSections.ts @@ -0,0 +1,94 @@ +import {useMemo} from 'react'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import type {FlattenedItem, Section, SectionListItem} from '@components/SelectionList/SelectionListWithSections/types'; +import CONST from '@src/CONST'; + +function isItemSelected(item: TItem): boolean { + return item?.isSelected ?? false; +} + +/** + * Checks if an item should be treated as disabled. + * An item is effectively disabled if it has isDisabled=true AND is not selected. + * Selected items remain interactive even when marked as disabled. + */ +function shouldTreatItemAsDisabled(item: TItem | FlattenedItem): boolean { + return !!item?.isDisabled && !isItemSelected(item as TItem); +} + +type UseFlattenedSectionsResult = { + /** Flattened array of headers and items for FlashList */ + flattenedData: Array>; + + /** Indices of disabled items (headers + disabled items) for arrow key navigation */ + disabledIndexes: number[]; + + /** Total count of row items (excluding headers) */ + itemsCount: number; + + /** Array of selected items */ + selectedItems: TItem[]; + + /** Index of initially focused item in flattenedData, or -1 if none */ + initialFocusedIndex: number; +}; + +/** + * Hook that flattens sections with headers and items into a single array for FlashList. + * Also computes disabled indexes, selected items, and initial focus index. + */ +function useFlattenedSections(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResult { + return useMemo(() => { + const data: Array> = []; + const selectedOptions: TItem[] = []; + const disabledIndices: number[] = []; + let focusedIndex = -1; + let itemsTotalCount = 0; + + for (const section of sections) { + if (section.title) { + disabledIndices.push(data.length); + data.push({ + type: CONST.SECTION_LIST_ITEM_TYPE.HEADER, + title: section.title, + keyForList: `header-${section.title}`, + isDisabled: true, + }); + } + itemsTotalCount += section.data?.length ?? 0; + + for (const item of section.data ?? []) { + const currentIndex = data.length; + const itemData = { + ...item, + type: CONST.SECTION_LIST_ITEM_TYPE.ROW, + } as SectionListItem; + data.push(itemData); + + if (item.keyForList === initiallyFocusedItemKey) { + focusedIndex = currentIndex; + } + + if (item.isSelected) { + selectedOptions.push(itemData); + } + + const isDisabled = section.isDisabled === true || shouldTreatItemAsDisabled(item); + if (isDisabled) { + disabledIndices.push(currentIndex); + } + } + } + + return { + flattenedData: data, + disabledIndexes: disabledIndices, + itemsCount: itemsTotalCount, + selectedItems: selectedOptions, + initialFocusedIndex: focusedIndex, + }; + }, [initiallyFocusedItemKey, sections]); +} + +export default useFlattenedSections; +export {isItemSelected, shouldTreatItemAsDisabled}; diff --git a/src/components/SelectionList/hooks/useSearchFocusSync.ts b/src/components/SelectionList/hooks/useSearchFocusSync.ts index aadee2f170e8..3318468269ae 100644 --- a/src/components/SelectionList/hooks/useSearchFocusSync.ts +++ b/src/components/SelectionList/hooks/useSearchFocusSync.ts @@ -2,18 +2,18 @@ import {useEffect} from 'react'; import type {ListItem} from '@components/SelectionList/ListItem/types'; import usePrevious from '@hooks/usePrevious'; -type UseSearchFocusSyncParams = { +type UseSearchFocusSyncParams = { /** The current search value from text input */ searchValue: string | undefined; /** Array of items (filtered data) */ - items: TItem[]; + data: TData[]; /** Count of currently selected options */ selectedOptionsCount: number; /** Function to check if an item is selected */ - isItemSelected: (item: TItem) => boolean; + isItemSelected: (item: TData) => boolean; /** Whether multiple items can be selected */ canSelectMultiple: boolean; @@ -35,19 +35,19 @@ type UseSearchFocusSyncParams = { * - Scrolling to selected item when search is cleared * - Setting focus to first item when filtering */ -function useSearchFocusSync({ +function useSearchFocusSync({ searchValue, - items, + data, selectedOptionsCount, isItemSelected, canSelectMultiple, shouldUpdateFocusedIndex, scrollToIndex, setFocusedIndex, -}: UseSearchFocusSyncParams) { +}: UseSearchFocusSyncParams) { const prevSearchValue = usePrevious(searchValue); const prevSelectedOptionsCount = usePrevious(selectedOptionsCount); - const prevItemsLength = usePrevious(items.length); + const prevItemsLength = usePrevious(data.length); useEffect(() => { const searchChanged = prevSearchValue !== searchValue; @@ -58,13 +58,13 @@ function useSearchFocusSync({ // 1. Input value is the same or // 2. Data length is 0 or // 3. Selection changed via user interaction (not filtering), so focus is handled externally - if ((!searchChanged && !selectedOptionsChanged) || items.length === 0 || selectionChangedByClicking) { + if ((!searchChanged && !selectedOptionsChanged) || data.length === 0 || selectionChangedByClicking) { return; } const hasSearchBeenCleared = prevSearchValue && !searchValue; if (hasSearchBeenCleared) { - const foundSelectedItemIndex = items.findIndex(isItemSelected); + const foundSelectedItemIndex = data.findIndex(isItemSelected); if (foundSelectedItemIndex !== -1 && !canSelectMultiple) { scrollToIndex(foundSelectedItemIndex); @@ -78,13 +78,13 @@ function useSearchFocusSync({ // 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) const isSearchIdle = !prevSearchValue && !searchValue; - const newSelectedIndex = isSearchIdle || (selectedOptionsChanged && prevItemsLength === items.length) ? -1 : 0; + const newSelectedIndex = isSearchIdle || (selectedOptionsChanged && prevItemsLength === data.length) ? -1 : 0; scrollToIndex(newSelectedIndex); setFocusedIndex(newSelectedIndex); }, [ canSelectMultiple, - items, + data, selectedOptionsCount, prevItemsLength, prevSelectedOptionsCount, diff --git a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts index 4f75498c4012..0773babba18a 100644 --- a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts +++ b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts @@ -1,15 +1,15 @@ import {useEffect, useMemo} from 'react'; import type {ListItem} from '@components/SelectionList/ListItem/types'; -type UseSelectedItemFocusSyncParams = { +type UseSelectedItemFocusSyncParams = { /** Array of items to search in */ - items: TItem[]; + data: TData[]; /** Key of the item to focus initially */ initiallyFocusedItemKey: string | null | undefined; /** Function to check if an item is selected */ - isItemSelected: (item: TItem) => boolean; + isItemSelected: (item: TData) => boolean; /** Current focused index */ focusedIndex: number; @@ -25,15 +25,15 @@ type UseSelectedItemFocusSyncParams = { * Custom hook that syncs the focused index with the selected item. * When the selected item changes (and no search is active), updates the focused index. */ -function useSelectedItemFocusSync({ - items, +function useSelectedItemFocusSync({ + data, initiallyFocusedItemKey, isItemSelected, focusedIndex, searchValue, setFocusedIndex, -}: UseSelectedItemFocusSyncParams) { - const selectedItemIndex = useMemo(() => (initiallyFocusedItemKey ? items.findIndex(isItemSelected) : -1), [items, initiallyFocusedItemKey, isItemSelected]); +}: UseSelectedItemFocusSyncParams) { + const selectedItemIndex = useMemo(() => (initiallyFocusedItemKey ? data.findIndex(isItemSelected) : -1), [data, initiallyFocusedItemKey, isItemSelected]); useEffect(() => { if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex || searchValue) { diff --git a/src/components/SelectionList/hooks/useWebSelectionListBehavior/index.ts b/src/components/SelectionList/hooks/useWebSelectionListBehavior/index.ts new file mode 100644 index 000000000000..7cd70840f5f0 --- /dev/null +++ b/src/components/SelectionList/hooks/useWebSelectionListBehavior/index.ts @@ -0,0 +1,20 @@ +import {useState} from 'react'; +import type {UseWebSelectionListBehaviorOptions, UseWebSelectionListBehaviorResult} from './types'; + +/** + * Native platforms don't need web-specific behaviors like touch detection or keyboard scroll debouncing. + * This returns safe defaults that won't affect native behavior. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function useWebSelectionListBehavior(_options: UseWebSelectionListBehaviorOptions = {}): UseWebSelectionListBehaviorResult { + const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false); + + return { + shouldIgnoreFocus: false, + shouldDebounceScrolling: false, + shouldDisableHoverStyle, + setShouldDisableHoverStyle, + }; +} + +export default useWebSelectionListBehavior; diff --git a/src/components/SelectionList/hooks/useWebSelectionListBehavior.ts b/src/components/SelectionList/hooks/useWebSelectionListBehavior/index.web.ts similarity index 85% rename from src/components/SelectionList/hooks/useWebSelectionListBehavior.ts rename to src/components/SelectionList/hooks/useWebSelectionListBehavior/index.web.ts index 75cc1fc0a67d..ec47c7d95b28 100644 --- a/src/components/SelectionList/hooks/useWebSelectionListBehavior.ts +++ b/src/components/SelectionList/hooks/useWebSelectionListBehavior/index.web.ts @@ -2,25 +2,7 @@ import {useEffect, useState} from 'react'; import {isMobileChrome} from '@libs/Browser'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; - -type UseWebSelectionListBehaviorOptions = { - /** Whether to track hover style state (only used by flat SelectionList) */ - shouldTrackHoverStyle?: boolean; -}; - -type UseWebSelectionListBehaviorResult = { - /** Whether the current focus should be ignored (touch event on mobile chrome) */ - shouldIgnoreFocus: boolean; - - /** Whether scrolling should be debounced (during keyboard navigation) */ - shouldDebounceScrolling: boolean; - - /** Whether hover style should be disabled (only when shouldTrackHoverStyle is true) */ - shouldDisableHoverStyle: boolean; - - /** Setter for hover style state (only when shouldTrackHoverStyle is true) */ - setShouldDisableHoverStyle: React.Dispatch>; -}; +import type {UseWebSelectionListBehaviorOptions, UseWebSelectionListBehaviorResult} from './types'; /** * Custom hook that handles common web-specific behaviors for SelectionList components: diff --git a/src/components/SelectionList/hooks/useWebSelectionListBehavior/types.ts b/src/components/SelectionList/hooks/useWebSelectionListBehavior/types.ts new file mode 100644 index 000000000000..fc278a330966 --- /dev/null +++ b/src/components/SelectionList/hooks/useWebSelectionListBehavior/types.ts @@ -0,0 +1,20 @@ +type UseWebSelectionListBehaviorOptions = { + /** Whether to track hover style state (only used by flat SelectionList) */ + shouldTrackHoverStyle?: boolean; +}; + +type UseWebSelectionListBehaviorResult = { + /** Whether the current focus should be ignored (touch event on mobile chrome) */ + shouldIgnoreFocus: boolean; + + /** Whether scrolling should be debounced (during keyboard navigation) */ + shouldDebounceScrolling: boolean; + + /** Whether hover style should be disabled (only when shouldTrackHoverStyle is true) */ + shouldDisableHoverStyle: boolean; + + /** Setter for hover style state (only when shouldTrackHoverStyle is true) */ + setShouldDisableHoverStyle: React.Dispatch>; +}; + +export type {UseWebSelectionListBehaviorOptions, UseWebSelectionListBehaviorResult}; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 66a368486903..24360790aff4 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -12,6 +12,9 @@ type BaseSelectionListProps = { /** Component to render for each list item */ ListItem: ValidListItem; + /** Key of the item to focus initially */ + initiallyFocusedItemKey?: string; + /** Called when a row is pressed */ onSelectRow: (item: TItem) => void; @@ -107,9 +110,6 @@ type SelectionListProps = Partial & /** Reference to the SelectionList component */ ref?: React.Ref>; - /** Key of the item to focus initially */ - initiallyFocusedItemKey?: string; - /** Called when "Select All" button is pressed */ onSelectAll?: () => void; From e73c49cfe817e699ab5087497b888953a8cd43a3 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Thu, 22 Jan 2026 10:32:47 +0100 Subject: [PATCH 09/16] Fix prop name --- src/components/TaxPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx index 24d34310b040..246ce0b0a538 100644 --- a/src/components/TaxPicker.tsx +++ b/src/components/TaxPicker.tsx @@ -121,7 +121,7 @@ function TaxPicker({selectedTaxRate = '', policyID, transactionID, onSubmit, act textInputOptions={textInputOptions} onSelectRow={handleSelectRow} ListItem={RadioListItem} - initiallyFocusedOptionKey={selectedOptionKey ?? undefined} + initiallyFocusedItemKey={selectedOptionKey ?? undefined} addBottomSafeAreaPadding={addBottomSafeAreaPadding} /> ); From 9a0a5c8d2f550f92b56c310081369fac4608b5fe Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Thu, 22 Jan 2026 10:53:00 +0100 Subject: [PATCH 10/16] Add comments --- .../SelectionList/SelectionListWithSections/index.native.tsx | 1 + .../SelectionList/SelectionListWithSections/index.tsx | 1 + src/components/SelectionList/hooks/useSelectedItemFocusSync.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/src/components/SelectionList/SelectionListWithSections/index.native.tsx b/src/components/SelectionList/SelectionListWithSections/index.native.tsx index f8da85f5f273..c54233d2aedb 100644 --- a/src/components/SelectionList/SelectionListWithSections/index.native.tsx +++ b/src/components/SelectionList/SelectionListWithSections/index.native.tsx @@ -6,6 +6,7 @@ import type {ListItem, SelectionListWithSectionsProps} from './types'; function SelectionList(props: SelectionListWithSectionsProps) { return ( ({ref, ...props}: SelectionListWit return ( ({ return; } setFocusedIndex(selectedItemIndex); + + // Only sync focus when selectedItemIndex changes, not when other dependencies update // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedItemIndex]); From 1d7df7d96c9d48852135825a46a6c41823e277d6 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Thu, 22 Jan 2026 11:20:12 +0100 Subject: [PATCH 11/16] Check if section is disabled --- src/components/SelectionList/hooks/useFlattenedSections.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/SelectionList/hooks/useFlattenedSections.ts b/src/components/SelectionList/hooks/useFlattenedSections.ts index 5cd4e81ff3ff..98ea56a63ed2 100644 --- a/src/components/SelectionList/hooks/useFlattenedSections.ts +++ b/src/components/SelectionList/hooks/useFlattenedSections.ts @@ -62,6 +62,7 @@ function useFlattenedSections(sections: Array; data.push(itemData); From 85aa08dc9db13aa075983a08d813dd7da0329586 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Thu, 22 Jan 2026 11:30:12 +0100 Subject: [PATCH 12/16] Prettier --- src/components/SelectionList/hooks/useFlattenedSections.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SelectionList/hooks/useFlattenedSections.ts b/src/components/SelectionList/hooks/useFlattenedSections.ts index 98ea56a63ed2..5eb4b65adcc2 100644 --- a/src/components/SelectionList/hooks/useFlattenedSections.ts +++ b/src/components/SelectionList/hooks/useFlattenedSections.ts @@ -62,7 +62,7 @@ function useFlattenedSections(sections: Array; data.push(itemData); From 9f8cf86d001708f2351155976e973c218e54e20f Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Thu, 22 Jan 2026 11:48:06 +0100 Subject: [PATCH 13/16] Fix type --- .../SelectionList/SelectionListWithSections/types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index c2b6b199a0db..2e793f05a27d 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -1,6 +1,7 @@ import type {ReactNode} from 'react'; import type {ListItem} from '@components/SelectionList/ListItem/types'; import type {BaseSelectionListProps} from '@components/SelectionList/types'; +import type CONST from '@src/CONST'; type Section = { /** Title of the section */ @@ -33,13 +34,13 @@ type SelectionWithSectionsListHandle = { }; type SectionHeader = { - type: 'header'; + type: CONST.SECTION_LIST_ITEM_TYPE.HEADER; title: string; keyForList: string; isDisabled: boolean; }; -type SectionListItem = TItem & {flatIndex: number; type: 'row'}; +type SectionListItem = TItem & {flatIndex: number; type: CONST.SECTION_LIST_ITEM_TYPE.ROW}; type FlattenedItem = SectionListItem | SectionHeader; From 05a18c6f0084cd9bf87b9067bb7bdf2f219ad361 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Thu, 22 Jan 2026 12:02:46 +0100 Subject: [PATCH 14/16] Fix ts error --- .../SelectionList/SelectionListWithSections/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index 2e793f05a27d..d124d004e8f4 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -34,13 +34,13 @@ type SelectionWithSectionsListHandle = { }; type SectionHeader = { - type: CONST.SECTION_LIST_ITEM_TYPE.HEADER; + type: typeof CONST.SECTION_LIST_ITEM_TYPE.HEADER; title: string; keyForList: string; isDisabled: boolean; }; -type SectionListItem = TItem & {flatIndex: number; type: CONST.SECTION_LIST_ITEM_TYPE.ROW}; +type SectionListItem = TItem & {flatIndex: number; type: typeof CONST.SECTION_LIST_ITEM_TYPE.ROW}; type FlattenedItem = SectionListItem | SectionHeader; From 137ebac12ba0b19697d50c28c15b5744f39e55a0 Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Thu, 22 Jan 2026 15:36:39 +0100 Subject: [PATCH 15/16] Add log --- .../SelectionListWithSections/BaseSelectionListWithSections.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 513aa9772d1d..c6d9e563c43a 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -25,6 +25,7 @@ import useScrollEnabled from '@hooks/useScrollEnabled'; import useSingleExecution from '@hooks/useSingleExecution'; import {focusedItemRef} from '@hooks/useSyncFocus/useSyncFocusImplementation'; import useThemeStyles from '@hooks/useThemeStyles'; +import Log from '@libs/Log'; import CONST from '@src/CONST'; import type {FlattenedItem, ListItem, SectionHeader, SelectionListWithSectionsProps} from './types'; @@ -102,6 +103,7 @@ function BaseSelectionListWithSections({ // FlashList may throw if layout for this index doesn't exist yet // This can happen when data changes rapidly (e.g., during search filtering) // The layout will be computed on next render, so we can safely ignore this + Log.warn('SelectionListWithSections: error scrolling to index', {error}); } }; From 6a376c48e515ae2c86b08ab73ea5a83887eb237a Mon Sep 17 00:00:00 2001 From: Zuzanna Furtak Date: Thu, 22 Jan 2026 16:46:10 +0100 Subject: [PATCH 16/16] Add check for arrow key focus manager --- .../SelectionListWithSections/BaseSelectionListWithSections.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index c6d9e563c43a..791315785407 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -113,7 +113,7 @@ function BaseSelectionListWithSections({ initialFocusedIndex, maxIndex: flattenedData.length - 1, disabledIndexes, - isActive: isScreenFocused, + isActive: isScreenFocused && itemsCount > 0, onFocusedIndexChange: (index: number) => { if (!shouldScrollToFocusedIndex) { return;