UNPKG

react-native-modal-selector-searchable

Version:

A cross-platform (iOS / Android), selector/picker component for React Native that is filterable, highly customizable and supports sections.

527 lines (483 loc) 19.9 kB
'use strict'; import React from 'react'; import PropTypes from 'prop-types'; import { View, Modal, Text, TextInput, FlatList, ScrollView, TouchableOpacity, TouchableWithoutFeedback, } from 'react-native'; import styles from './style'; let componentIndex = 0; const propTypes = { children: PropTypes.any, data: PropTypes.array, onSearchFilterer: PropTypes.func, onChangeSearch: PropTypes.func, onChange: PropTypes.func, onPress: PropTypes.func, onModalOpen: PropTypes.func, onModalClose: PropTypes.func, onCancel: PropTypes.func, keyExtractor: PropTypes.func, labelExtractor: PropTypes.func, componentExtractor: PropTypes.func, visible: PropTypes.bool, closeOnChange: PropTypes.bool, initValue: PropTypes.string, listType: PropTypes.oneOf(['SCROLLVIEW', 'FLATLIST']), animationType: PropTypes.oneOf(['none', 'slide', 'fade']), style: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), selectStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), selectTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), optionStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), optionTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), optionContainerStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), sectionStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), childrenContainerStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), touchableStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), touchableActiveOpacity: PropTypes.number, sectionTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), selectedItemTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), cancelContainerStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), cancelStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), cancelTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), overlayStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), initValueTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), searchStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), searchTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), cancelText: PropTypes.string, searchText: PropTypes.string, searchAutoFocus: PropTypes.bool, placeHolderTextColor: PropTypes.string, disabled: PropTypes.bool, keyboardType: PropTypes.oneOf([ 'default', 'number-pad', 'decimal-pad', 'numeric', 'email-address', 'phone-pad', 'url', 'ascii-capable', 'numbers-and-punctuation', 'name-phone-pad', 'twitter', 'web-search', 'visible-password' ]), supportedOrientations: PropTypes.arrayOf( PropTypes.oneOf([ 'portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right', ]), ), keyboardShouldPersistTaps: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), backdropPressToClose: PropTypes.bool, openButtonContainerAccessible: PropTypes.bool, listItemAccessible: PropTypes.bool, cancelButtonAccessible: PropTypes.bool, scrollViewAccessible: PropTypes.bool, scrollViewAccessibilityLabel: PropTypes.string, cancelButtonAccessibilityLabel: PropTypes.string, passThruProps: PropTypes.object, selectTextPassThruProps: PropTypes.object, optionTextPassThruProps: PropTypes.object, cancelTextPassThruProps: PropTypes.object, scrollViewPassThruProps: PropTypes.object, modalOpenerHitSlop: PropTypes.object, customSelector: PropTypes.node, selectedKey: PropTypes.any, enableShortPress: PropTypes.bool, enableLongPress: PropTypes.bool, optionsTestIDPrefix: PropTypes.string, hideSectionOnSearch: PropTypes.bool, caseSensitiveSearch: PropTypes.bool, search: PropTypes.bool, fullHeight: PropTypes.bool, frozenSearch: PropTypes.bool, modalTestId: PropTypes.any, }; const defaultProps = { data: [], onChange: () => { }, onPress: () => { }, onModalOpen: () => { }, onModalClose: () => { }, onCancel: () => { }, searchFilterer: (item) => item.component, keyExtractor: (item) => item.key, labelExtractor: (item) => item.label, componentExtractor: (item) => item.component, listType: 'SCROLLVIEW', visible: false, closeOnChange: true, initValue: 'Select me!', animationType: 'slide', style: {}, selectStyle: {}, selectTextStyle: {}, optionStyle: {}, optionTextStyle: {}, optionContainerStyle: {}, sectionStyle: {}, childrenContainerStyle: {}, touchableStyle: {}, touchableActiveOpacity: 0.2, sectionTextStyle: {}, selectedItemTextStyle: {}, cancelContainerStyle: {}, cancelStyle: {}, cancelTextStyle: {}, overlayStyle: {}, initValueTextStyle: {}, cancelText: 'cancel', searchText: 'search', searchAutoFocus: false, placeHolderTextColor: "", keyboardType: 'default', disabled: false, supportedOrientations: ['portrait', 'landscape'], keyboardShouldPersistTaps: 'always', backdropPressToClose: false, openButtonContainerAccessible: false, listItemAccessible: false, cancelButtonAccessible: false, scrollViewAccessible: false, scrollViewAccessibilityLabel: "", cancelButtonAccessibilityLabel: "", passThruProps: {}, selectTextPassThruProps: {}, optionTextPassThruProps: {}, cancelTextPassThruProps: {}, scrollViewPassThruProps: {}, modalOpenerHitSlop: { top: 0, bottom: 0, left: 0, right: 0 }, customSelector: undefined, selectedKey: '', enableShortPress: true, enableLongPress: false, optionsTestIDPrefix: 'default', searchStyle: {}, searchTextStyle: {}, hideSectionOnSearch: false, caseSensitiveSearch: false, search: true, fullHeight: false, frozenSearch: false, }; export default class ModalSelector extends React.Component { constructor(props) { super(props); let selectedItem = this.validateSelectedKey(props.selectedKey); this.state = { modalVisible: props.visible, selected: selectedItem.label, changedItem: selectedItem.key, searchData: null, }; this.initialModalHeight = null; } componentDidUpdate(prevProps) { let newState = {}; let doUpdate = false; if (prevProps.initValue !== this.props.initValue) { newState.selected = this.props.initValue; doUpdate = true; } if (prevProps.visible !== this.props.visible) { newState.modalVisible = this.props.visible; doUpdate = true; } if (prevProps.selectedKey !== this.props.selectedKey || prevProps.data !== this.props.data) { let selectedItem = this.validateSelectedKey(this.props.selectedKey); newState.selected = selectedItem.label; newState.changedItem = selectedItem.key; doUpdate = true; } if (doUpdate) { this.setState(newState); } } validateSelectedKey = (key) => { let selectedItem = this.props.data.filter((item) => this.props.keyExtractor(item) === key); let selectedLabel = selectedItem.length > 0 ? this.props.labelExtractor(selectedItem[0]) : this.props.initValue; let selectedKey = selectedItem.length > 0 ? key : undefined; return { label: selectedLabel, key: selectedKey } } onChange = (item) => { this.props.onChange(item); this.setState({ selected: this.props.labelExtractor(item), changedItem: item }, () => { if (this.props.closeOnChange) this.close(item); }); } getSelectedItem() { return this.state.changedItem; } close = (item) => { this.initialModalHeight = null; this.props.onModalClose(item); this.setState({ modalVisible: false, }); } cancel = () => { this.props.onCancel(); this.close(); } open = (params = {}) => { this.props.onPress(this.validateSelectedKey(this.props.selectedKey)); if (!params.longPress && !this.props.enableShortPress) { return; } if (params.longPress && !this.props.enableLongPress) { return; } this.props.onModalOpen(params); this.setState({ modalVisible: true, changedItem: undefined, searchData: null, }); } renderSection = (section) => { const optionComponent = this.props.componentExtractor(section); let component = optionComponent || ( <Text style={[styles.sectionTextStyle, this.props.sectionTextStyle]}>{this.props.labelExtractor(section)}</Text> ); return ( <View key={this.props.keyExtractor(section)} style={[styles.sectionStyle, this.props.sectionStyle]}> {component} </View> ); } renderOption = (option, isLastItem, isFirstItem) => { const optionComponent = this.props.componentExtractor(option); const optionLabel = this.props.labelExtractor(option); const isSelectedItem = optionLabel === this.state.selected; let component = optionComponent || ( <Text style={[styles.optionTextStyle, this.props.optionTextStyle, isSelectedItem && this.props.selectedItemTextStyle]} {...this.props.optionTextPassThruProps}> {optionLabel} </Text> ); return ( <TouchableOpacity key={this.props.keyExtractor(option)} testID={option.testID || this.props.optionsTestIDPrefix + '-' + optionLabel} onPress={() => this.onChange(option)} activeOpacity={this.props.touchableActiveOpacity} accessible={this.props.listItemAccessible} accessibilityLabel={option.accessibilityLabel || undefined} importantForAccessibility={isFirstItem ? 'yes' : 'no'} {...this.props.passThruProps} disabled={option.disabled} > <View style={[styles.optionStyle, this.props.optionStyle, isLastItem && { borderBottomWidth: 0 }]}> {component} </View> </TouchableOpacity>); } renderFlatlistOption = (filteredData) => ({ item, index }) => { if (item.section) { return this.renderSection(item); } const numItems = filteredData.length; return this.renderOption(item, index === (numItems - 1), index === 0); } onChangeSearch = (text) => { const { onChangeSearch } = this.props; if (onChangeSearch) { onChangeSearch(text); } this.setState({ searchData: text }); } onSearchFilterer = (data) => { const { onSearchFilterer, hideSectionOnSearch, caseSensitiveSearch } = this.props; const { searchData } = this.state; if (onSearchFilterer) return onSearchFilterer(searchData, data); if (!searchData) return data; let arr = [...data].reverse(); let searchDataStr = (searchData || "") if (!caseSensitiveSearch) { searchDataStr = searchDataStr.toLowerCase(); } let showSectionMatched = false; let filtered = arr.filter(item => { let labelDataStr = (this.props.labelExtractor(item) || ""); if (!caseSensitiveSearch) { labelDataStr = labelDataStr.toLowerCase(); } let result = labelDataStr.indexOf(searchDataStr) > -1 || !searchDataStr || (item.section && showSectionMatched && !hideSectionOnSearch); if (result) { showSectionMatched = true; } if (item.section) { showSectionMatched = false; } return result; }) return filtered.reverse(); } renderOptionList = () => { const { data, listType, backdropPressToClose, scrollViewPassThruProps, overlayStyle, optionContainerStyle, keyboardShouldPersistTaps, scrollViewAccessible, scrollViewAccessibilityLabel, cancelContainerStyle, touchableActiveOpacity, cancelButtonAccessible, cancelButtonAccessibilityLabel, cancelStyle, cancelTextStyle, cancelText, searchText, searchAutoFocus, placeHolderTextColor, keyboardType, search, searchStyle, searchTextStyle, fullHeight, frozenSearch, } = this.props; const filteredData = this.onSearchFilterer(data); let options = filteredData.map((item, index) => { if (item.section) { return this.renderSection(item); } return this.renderOption(item, index === filteredData.length - 1, index === 0); }); let Overlay = View; let overlayProps = { style: { flex: 1 }, }; // Some RN versions have a bug here, so making the property opt-in works around this problem if (backdropPressToClose) { Overlay = TouchableWithoutFeedback; overlayProps = { key: `modalSelector${componentIndex++}`, accessible: false, onPress: this.close, }; } const optionsContainerStyle = { paddingHorizontal: 10 }; if (scrollViewPassThruProps && scrollViewPassThruProps.horizontal) { optionsContainerStyle.flexDirection = 'row'; } return ( <Overlay {...overlayProps}> <View style={[styles.overlayStyle, overlayStyle]}> <View style={[ styles.optionContainer, frozenSearch && { height: this.initialModalHeight }, fullHeight && { height: "100%" }, optionContainerStyle, ]} onLayout={(event) => this.initialModalHeight = this.initialModalHeight || event.nativeEvent.layout.height} > {search && <View style={[styles.searchStyle, searchStyle]}> <TextInput autoFocus={searchAutoFocus} style={searchTextStyle} placeholder={searchText} placeholderTextColor={placeHolderTextColor} keyboardType={keyboardType} onChangeText={this.onChangeSearch} /> </View> } {listType === 'FLATLIST' ? <FlatList data={filteredData} keyboardShouldPersistTaps={keyboardShouldPersistTaps} accessible={scrollViewAccessible} accessibilityLabel={scrollViewAccessibilityLabel} keyExtractor={this.props.keyExtractor} renderItem={this.renderFlatlistOption(filteredData)} /> : <ScrollView keyboardShouldPersistTaps={keyboardShouldPersistTaps} accessible={scrollViewAccessible} accessibilityLabel={scrollViewAccessibilityLabel} {...scrollViewPassThruProps} > <View style={optionsContainerStyle}> {options} </View> </ScrollView> } </View> <View style={[styles.cancelContainer, cancelContainerStyle]}> <TouchableOpacity onPress={this.cancel} activeOpacity={touchableActiveOpacity} accessible={cancelButtonAccessible} accessibilityLabel={cancelButtonAccessibilityLabel}> <View style={[styles.cancelStyle, cancelStyle]}> <Text style={[styles.cancelTextStyle, cancelTextStyle]} {...this.props.cancelTextPassThruProps}>{cancelText}</Text> </View> </TouchableOpacity> </View> </View> </Overlay> ); } renderChildren = () => { if (this.props.children) { return this.props.children; } let initSelectStyle = this.props.initValue === this.state.selected ? [styles.initValueTextStyle, this.props.initValueTextStyle] : [styles.selectTextStyle, this.props.selectTextStyle]; return ( <View style={[styles.selectStyle, this.props.selectStyle]}> <Text style={initSelectStyle} {...this.props.selectTextPassThruProps}>{this.state.selected}</Text> </View> ); } render() { const dp = ( <Modal transparent={true} ref={element => this.model = element} supportedOrientations={this.props.supportedOrientations} visible={this.state.modalVisible} onRequestClose={this.close} testID={this.props.modalTestId} animationType={this.props.animationType} onDismiss={() => this.state.changedItem && this.props.onChange(this.state.changedItem)} > {this.renderOptionList()} </Modal> ); return ( <View style={this.props.style} {...this.props.passThruProps}> {dp} {this.props.customSelector ? this.props.customSelector : <TouchableOpacity hitSlop={this.props.modalOpenerHitSlop} activeOpacity={this.props.touchableActiveOpacity} style={this.props.touchableStyle} onPress={this.open} onLongPress={() => this.open({ longPress: true })} disabled={this.props.disabled} accessible={this.props.openButtonContainerAccessible} > <View style={this.props.childrenContainerStyle} pointerEvents="none"> {this.renderChildren()} </View> </TouchableOpacity> } </View> ); } } ModalSelector.propTypes = propTypes; ModalSelector.defaultProps = defaultProps;