/* eslint-disable @typescript-eslint/no-shadow */ import _differenceWith from 'lodash/differenceWith'; import _get from 'lodash/get'; import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { FlatList, Image, Keyboard, Modal, SectionList, StyleSheet, Text, TouchableHighlight, TouchableWithoutFeedback, View, ViewStyle, } from 'react-native'; import { createKeyExtractor, createSearchPredicate, defaultChevronIcon, DropdownSearchInput, DropdownSectionHeader, EMPTY_DATA, styleContainerVertical, useDebouncedCallback, useKeyboardTracking, useReducedMotion, useTriggerMeasurement, } from '../../internal'; import { IMultiSelectRef, MultiSelectProps, Section } from './model'; import { styles } from './styles'; const MultiSelectComponent = React.forwardRef< IMultiSelectRef, MultiSelectProps >((props, currentRef) => { const { testID, itemTestIDField, onChange, data: dataProp, sections, renderSectionHeader, sectionHeaderStyle, sectionHeaderTextStyle, value, style = {}, labelField, valueField, searchField, disabledField, selectedStyle, selectedTextStyle, itemContainerStyle, itemTextStyle, activeItemTextStyle, iconStyle, selectedTextProps = {}, activeColor = '#F6F7F8', containerStyle, fontFamily, placeholderStyle, iconColor = 'gray', inputSearchStyle, searchPlaceholder, searchPlaceholderTextColor = 'gray', placeholder = 'Select item', search = false, searchKeyboardType, searchInputProps, persistSearch = false, searchDebounce, maxHeight = 340, minHeight = 0, maxSelect, disable = false, keyboardAvoiding = true, inside = false, inverted = true, renderItem, renderLeftIcon, renderRightIcon, renderSelectedItem, renderChipRemoveIcon, renderInputSearch, renderSearchClearIcon, renderModalHeader, onFocus, onBlur, showsVerticalScrollIndicator = true, dropdownPosition = 'auto', flatListProps, renderEmpty, alwaysRenderSelectedItem = false, searchQuery, backgroundColor, modalAnimationType, onChangeText, confirmSelectItem, confirmUnSelectItem, onConfirmSelectItem, accessibilityLabel, accessibilityHint, itemAccessibilityLabelField, chipRemoveAccessibilityHint = 'Double tap to remove from selection', visibleSelectedItem = true, mode = 'default', closeModalWhenSelectedItem = false, excludeItems = [], excludeSearchItems = [], hideSelectedFromList = false, selectedToTop = false, onEndReached, onEndReachedThreshold = 0.5, hitSlop, allowFontScaling, isInsideModal = false, } = props; // `sections` takes precedence over `data`; flatten for shared // logic (currentValue lookup, selection toggles, etc.) and keep // the sectioned view only for the render path. const usingSections = sections !== undefined && sections !== null; const data = useMemo( () => usingSections ? sections!.flatMap((s) => s.data) : ((dataProp ?? EMPTY_DATA) as any[]), [usingSections, sections, dataProp] ); const [visible, setVisible] = useState(false); const [currentValue, setCurrentValue] = useState([]); const [listData, setListData] = useState(data); const [listSections, setListSections] = useState[]>( sections ?? [] ); const [searchText, setSearchText] = useState(''); const { ref, position, measure: _measure, windowWidth: W, windowHeight: H, orientation, } = useTriggerMeasurement(mode, isInsideModal, visible); const styleHorizontal: ViewStyle = useMemo(() => { return { width: orientation === 'LANDSCAPE' ? W / 2 : '100%', alignSelf: 'center', }; }, [W, orientation]); // Stable imperative handle. See Dropdown for the full rationale. const imperativeRef = useRef({ open: () => {}, close: () => {}, }); useImperativeHandle( currentRef, () => ({ open: () => imperativeRef.current.open(), close: () => imperativeRef.current.close(), }), [] ); useEffect(() => { const handle = imperativeRef.current; return () => handle.close(); }, []); const excludeData = useCallback( (data: any[]) => { let result = data || []; if (excludeItems.length > 0) { result = _differenceWith( result, excludeItems, (obj1, obj2) => _get(obj1, valueField) === _get(obj2, valueField) ) || []; } if (hideSelectedFromList && currentValue.length > 0) { result = result.filter( (item) => !currentValue.includes(_get(item, valueField)) ); } if (selectedToTop && currentValue.length > 0 && !hideSelectedFromList) { const selected: any[] = []; const rest: any[] = []; for (const item of result) { if (currentValue.includes(_get(item, valueField))) { selected.push(item); } else { rest.push(item); } } result = [...selected, ...rest]; } return result; }, [ excludeItems, valueField, hideSelectedFromList, selectedToTop, currentValue, ] ); // Apply the same filter / reorder pass to each section, then drop // any section that ends up empty so the list doesn't render a // stray header with no rows beneath it. const excludeSections = useCallback( (input: Section[]) => { return input .map((s) => ({ ...s, data: excludeData(s.data) })) .filter((s) => s.data.length > 0); }, [excludeData] ); useEffect(() => { if (searchText.length === 0) { if (usingSections) { setListSections(excludeSections(sections ?? [])); } else if (data) { const filterData = excludeData(data); setListData([...filterData]); } } if (searchText) { onSearch(searchText); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data, sections, searchText]); const eventOpen = () => { if (!disable) { _measure(); setVisible(true); if (onFocus) { onFocus(); } if (searchText.length > 0) { onSearch(searchText); } } }; const eventClose = useCallback(() => { if (!disable) { setVisible(false); if (onBlur) { onBlur(); } } }, [disable, onBlur]); // Pin open/close onto the stable imperative ref (see Dropdown for // rationale). imperativeRef.current.open = eventOpen; imperativeRef.current.close = eventClose; const fontStyle = useMemo( () => (fontFamily ? { fontFamily } : undefined), [fontFamily] ); const getValue = useCallback(() => { setCurrentValue(value ? [...value] : []); }, [value]); // Re-measure whenever the keyboard shows or hides so the list // re-positions itself above the keyboard. const keyboardHeight = useKeyboardTracking(_measure); // Honour the user's "reduce motion" OS preference — disable the // Modal slide / fade when they've asked for less motion. const reducedMotion = useReducedMotion(); useEffect(() => { getValue(); }, [getValue, value]); const showOrClose = useCallback(() => { if (!disable) { const visibleStatus = !visible; if (keyboardHeight > 0 && !visibleStatus) { return Keyboard.dismiss(); } if (!visibleStatus && !persistSearch) { if (onChangeText) { onChangeText(''); } setSearchText(''); } _measure(); setVisible(visibleStatus); if (usingSections) { setListSections(excludeSections(sections ?? [])); } else if (data) { const filterData = excludeData(data); setListData(filterData); } if (visibleStatus) { if (onFocus) { onFocus(); } } else { if (onBlur) { onBlur(); } } if (searchText.length > 0 && (visibleStatus || persistSearch)) { onSearch(searchText); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ disable, keyboardHeight, visible, _measure, data, sections, usingSections, excludeSections, searchText, onFocus, onBlur, onChangeText, persistSearch, ]); const onSearch = useCallback( (text: string) => { if (text.length === 0) { if (usingSections) { setListSections(excludeSections(sections ?? [])); } else { setListData(excludeData(data)); } return; } const predicate = createSearchPredicate({ text, searchField, labelField, searchQuery, }); const applyExcludes = (items: any[]) => { if (excludeSearchItems.length === 0 && excludeItems.length === 0) { return items; } const excluded = _differenceWith( items, excludeSearchItems, (obj1, obj2) => _get(obj1, valueField) === _get(obj2, valueField) ) || []; return excludeData(excluded); }; if (usingSections) { const filtered = (sections ?? []) .map((s) => ({ ...s, data: applyExcludes(s.data.filter(predicate)), })) .filter((s) => s.data.length > 0); setListSections(filtered); } else { setListData(applyExcludes(data.filter(predicate))); } }, [ data, sections, usingSections, searchQuery, excludeSearchItems, excludeItems, searchField, labelField, valueField, excludeData, excludeSections, ] ); const onSelect = useCallback( (item: any) => { const newCurrentValue = [...currentValue]; const index = newCurrentValue.findIndex( (e) => e === _get(item, valueField) ); if (index > -1) { newCurrentValue.splice(index, 1); } else { if (maxSelect) { if (newCurrentValue.length < maxSelect) { newCurrentValue.push(_get(item, valueField)); } } else { newCurrentValue.push(_get(item, valueField)); } } if (onConfirmSelectItem) { if (index > -1) { if (confirmUnSelectItem) { onConfirmSelectItem(newCurrentValue); } else { onChange(newCurrentValue); } } else { if (confirmSelectItem) { onConfirmSelectItem(newCurrentValue); } else { onChange(newCurrentValue); } } } else { onChange(newCurrentValue); } if (closeModalWhenSelectedItem) { if (!persistSearch) { if (onChangeText) { onChangeText(''); } setSearchText(''); onSearch(''); } eventClose(); } }, [ closeModalWhenSelectedItem, confirmSelectItem, confirmUnSelectItem, currentValue, eventClose, maxSelect, onChange, onChangeText, onConfirmSelectItem, onSearch, persistSearch, valueField, ] ); const _renderDropdown = () => { return ( {renderLeftIcon?.(visible)} 0 ? selectedTextStyle : placeholderStyle, fontStyle, ])} {...selectedTextProps} > {placeholder} {renderRightIcon ? ( renderRightIcon(visible) ) : ( )} ); }; const checkSelected = useCallback( (item: any) => currentValue.includes(_get(item, valueField)), [currentValue, valueField] ); const _renderItem = useCallback( ({ item, index }: { item: any; index: number }) => { const selected = checkSelected(item); const itemDisabled = disabledField ? !!_get(item, disabledField) : false; return ( !itemDisabled && onSelect(item)} > {renderItem ? ( renderItem(item, selected, index) ) : ( {_get(item, labelField)} )} ); }, [ activeColor, activeItemTextStyle, allowFontScaling, checkSelected, disabledField, fontStyle, itemAccessibilityLabelField, itemContainerStyle, itemTestIDField, itemTextStyle, labelField, onSelect, renderItem, ] ); // See Dropdown: the filter is debounced when `searchDebounce` is // set; the input value and `onChangeText` still fire synchronously. const runSearch = useDebouncedCallback(onSearch, searchDebounce); const onSearchTextChange = useCallback( (text: string) => { setSearchText(text); if (onChangeText) onChangeText(text); runSearch(text); }, [onChangeText, runSearch] ); const renderSearch = useCallback( () => ( ), [ accessibilityLabel, allowFontScaling, fontStyle, iconColor, iconStyle, inputSearchStyle, onSearchTextChange, renderInputSearch, renderSearchClearIcon, search, searchInputProps, searchKeyboardType, searchPlaceholder, searchPlaceholderTextColor, searchText, testID, ] ); const _renderSectionHeader = useCallback( ({ section }: { section: any }) => ( ), [ renderSectionHeader, sectionHeaderStyle, sectionHeaderTextStyle, fontStyle, allowFontScaling, ] ); const keyExtractor = useMemo( () => createKeyExtractor(valueField), [valueField] ); // Stable wrapper for SectionList's renderItem — avoids a fresh // identity per render and the downstream row re-render that causes. const renderSectionItem = useCallback( ({ item, index }: { item: any; index: number }) => _renderItem({ item, index }) as any, [_renderItem] ); const _renderList = useCallback( (isTopPosition: boolean) => { const isInverted = !inverted ? false : isTopPosition; // Calling renderEmpty eagerly here is cheap — React only mounts // the returned element when the list is actually empty, so the // result is discarded on every non-empty render. const emptyElement = renderEmpty ? renderEmpty(searchText) : null; const _renderListHelper = () => { if (usingSections) { return ( ); } return ( ); }; return ( {renderModalHeader?.(eventClose)} {isInverted && _renderListHelper()} {renderSearch()} {!isInverted && _renderListHelper()} ); }, [ _renderItem, _renderSectionHeader, accessibilityLabel, eventClose, flatListProps, keyExtractor, listData, listSections, inverted, onEndReached, onEndReachedThreshold, renderEmpty, renderSectionItem, renderModalHeader, renderSearch, searchText, showsVerticalScrollIndicator, testID, usingSections, ] ); const _renderModal = useCallback(() => { if (visible && position) { const { isFull, width, height, top, bottom, left } = position; const onAutoPosition = () => { if (keyboardHeight > 0) { return bottom < keyboardHeight + height; } // Trigger in bottom half opens upward (upstream #264). const minSlack = search ? 150 : 100; return bottom < Math.max(minSlack, H / 2); }; if (width && top && bottom) { const styleVertical: ViewStyle = { left: left, maxHeight: maxHeight, minHeight: minHeight, }; const isTopPosition = dropdownPosition === 'auto' ? onAutoPosition() : dropdownPosition === 'top'; let extendHeight = !isTopPosition ? top : bottom; if ( keyboardAvoiding && keyboardHeight > 0 && isTopPosition && dropdownPosition === 'auto' ) { extendHeight = keyboardHeight; } return ( {_renderList(isTopPosition)} ); } return null; } return null; }, [ visible, search, position, keyboardHeight, maxHeight, minHeight, dropdownPosition, keyboardAvoiding, showOrClose, backgroundColor, containerStyle, styleHorizontal, _renderList, modalAnimationType, reducedMotion, H, ]); const unSelect = (item: any) => { if (!disable) { onSelect(item); } }; const _renderItemSelected = (inside: boolean) => { // Only render chips for items whose valueField is in the current // selection. The previous implementation returned `e` (truthy) // instead of a boolean and didn't guard against `value === undefined`, // which caused every item to render as a chip when nothing was // selected. const list = value ? data.filter((e: any) => value.includes(_get(e, valueField))) : []; return ( {list.map((e) => { const itemLabel = _get(e, itemAccessibilityLabelField || labelField); if (renderSelectedItem) { return ( unSelect(e)} > {renderSelectedItem(e, () => { unSelect(e); })} ); } return ( unSelect(e)} > {itemLabel} {renderChipRemoveIcon ? ( {renderChipRemoveIcon(e)} ) : ( )} ); })} ); }; const _renderInside = () => { return ( {_renderDropdownInside()} {_renderModal()} ); }; const _renderDropdownInside = () => { const hasSelection = !!(value && value.length > 0); return ( {renderLeftIcon?.(visible)} {hasSelection ? ( _renderItemSelected(true) ) : ( {placeholder} )} {renderRightIcon ? ( renderRightIcon(visible) ) : ( )} ); }; if (inside) { return _renderInside(); } return ( <> {_renderDropdown()} {_renderModal()} {(!visible || alwaysRenderSelectedItem) && visibleSelectedItem && _renderItemSelected(false)} ); }); // Preserve the `` generic across the public type boundary. See // Dropdown/index.tsx for the full rationale — the inner forwardRef // stays pinned to `MultiSelectProps` and we cast T back in at // the export so consumers get `renderItem(item: T, ...)` and // `renderSelectedItem(item: T, ...)` inferred from `data`. export default MultiSelectComponent as ( props: MultiSelectProps ) => React.ReactElement | null;