UNPKG

react-native-ui-lib

Version:

<p align="center"> <img src="https://user-images.githubusercontent.com/1780255/105469025-56759000-5ca0-11eb-993d-3568c1fd54f4.png" height="250px" style="display:block"/> </p> <p align="center">UI Toolset & Components Library for React Native</p> <p a

592 lines (522 loc) • 15.9 kB
import _pt from "prop-types"; import _ from 'lodash'; import React, { Component } from 'react'; import { NativeModules, StyleSheet, findNodeHandle, ScrollView } from 'react-native'; import { Colors, BorderRadiuses, ThemeManager, Typography, Spacings } from "../../style"; import Assets from "../../assets"; import { LogService } from "../../services"; import { Constants, asBaseComponent } from "../../commons/new"; // @ts-expect-error import { TextField } from "../inputs"; import View from "../view"; import TouchableOpacity from "../touchableOpacity"; import Text from "../text"; import Chip from "../chip"; import Icon from "../icon"; import { getValidationBasedColor, getCounterTextColor, getCounterText, getChipDismissColor, isDisabled } from "./Presenter"; const GUTTER_SPACING = 8; /** * @description: Chips input component * @modifiers: Typography * @gif: https://github.com/wix/react-native-ui-lib/blob/master/demo/showcase/ChipsInput/ChipsInput.gif?raw=true * @example: https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/ChipsInputScreen.tsx * @extends: TextField */ class ChipsInput extends Component { static propTypes = { /** * DEPRECATED: use chips instead. list of tags. can be string boolean or custom object when implementing getLabel */ tags: _pt.arrayOf(_pt.any), /** * list of tags. can be string boolean or custom object when implementing getLabel */ chips: _pt.array, /** * callback for extracting the label out of the tag item */ getLabel: _pt.func, /** * DEPRECATED: use chips instead. callback for custom rendering tag item */ renderTag: _pt.func, /** * callback for onChangeTags event */ onChangeTags: _pt.func, /** * DEPRECATED: use chips instead. callback for creating new tag out of input value (good for composing tag object) */ onCreateTag: _pt.func, /** * DEPRECATED: use chips instead. callback for when pressing a tag in the following format (tagIndex, markedTagIndex) => {...} */ onTagPress: _pt.func, /** * validation message error appears when tag isn't validate */ validationErrorMessage: _pt.string, /** * if true, tags *removal* Ux won't be available */ disableTagRemoval: _pt.bool, /** * if true, tags *adding* Ux (i.e. by 'submitting' the input text) won't be available */ disableTagAdding: _pt.bool, /** * should hide input underline */ hideUnderline: _pt.bool, /** * Maximum numbers of chips */ maxLength: _pt.number, /** * Chips inside a ScrollView */ maxHeight: _pt.number, /** * Custom element before the chips, for example 'search' icon, 'To:' label etc' */ leftElement: _pt.oneOfType([_pt.element, _pt.arrayOf(_pt.element)]), value: _pt.any, selectionColor: _pt.oneOfType([_pt.string, _pt.number]) }; static displayName = 'ChipsInput'; static onChangeTagsActions = { ADDED: 'added', REMOVED: 'removed' }; input = React.createRef(); scrollRef = React.createRef(); constructor(props) { super(props); this.state = { value: props.value, chips: _.cloneDeep(props.tags || props.chips) || [], chipIndexToRemove: undefined, initialChips: props.tags || props.chips, isFocused: this.input.current?.isFocused() || false }; LogService.componentDeprecationWarn({ oldComponent: 'ChipsInput', newComponent: 'Incubator.ChipsInput' }); } componentDidMount() { if (Constants.isAndroid) { const textInputHandle = findNodeHandle(this.input.current); if (textInputHandle && NativeModules.TextInputDelKeyHandler) { NativeModules.TextInputDelKeyHandler.register(textInputHandle); } } } static getDerivedStateFromProps(nextProps, prevState) { const { tags, chips } = nextProps; if (tags && tags !== prevState.initialChips || chips && chips !== prevState.initialChips) { return { initialChips: nextProps.tags || nextProps.chips, chips: nextProps.tags || nextProps.chips }; } return null; } addTag = () => { const { onCreateTag, disableTagAdding, maxLength, chips: chipsProps } = this.props; const { value, chips } = this.state; if (this.scrollRef?.current?.scrollToEnd) { this.scrollRef?.current?.scrollToEnd(); } if (disableTagAdding) { return; } if (_.isNil(value) || _.isEmpty(value.trim())) { return; } if (maxLength && this.state.chips.length >= maxLength) { this.setState({ value: '' }); return; } const newChip = _.isFunction(onCreateTag) ? onCreateTag(value) : chipsProps ? { label: value } : value; const newChips = [...chips, newChip]; this.setState({ value: '', chips: newChips }); _.invoke(this.props, 'onChangeTags', newChips, ChipsInput.onChangeTagsActions.ADDED, newChip); this.clear(); }; removeMarkedTag() { const { chips, chipIndexToRemove } = this.state; if (!_.isUndefined(chipIndexToRemove)) { const removedTag = chips[chipIndexToRemove]; chips.splice(chipIndexToRemove, 1); this.setState({ chips, chipIndexToRemove: undefined }); _.invoke(this.props, 'onChangeTags', chips, ChipsInput.onChangeTagsActions.REMOVED, removedTag); } } markTagIndex = chipIndex => { this.setState({ chipIndexToRemove: chipIndex }); }; onChangeText = _.debounce(value => { this.setState({ value, chipIndexToRemove: undefined }); _.invoke(this.props, 'onChangeText', value); }, 0); onTagPress(index) { const { onTagPress } = this.props; const { chipIndexToRemove } = this.state; // custom press handler if (onTagPress) { onTagPress(index, chipIndexToRemove); return; } // default press handler if (chipIndexToRemove === index) { this.removeMarkedTag(); } else { this.markTagIndex(index); } } isLastTagMarked() { const { chips, chipIndexToRemove } = this.state; const tagsCount = _.size(chips); const isLastTagMarked = chipIndexToRemove === tagsCount - 1; return isLastTagMarked; } removeTag = () => { const { value, chips, chipIndexToRemove } = this.state; const tagsCount = _.size(chips); const hasNoValue = _.isEmpty(value); const hasTags = tagsCount > 0; const { disableTagRemoval } = this.props; if (disableTagRemoval) { return; } if (hasNoValue && hasTags && _.isUndefined(chipIndexToRemove)) { this.setState({ chipIndexToRemove: tagsCount - 1 }); } else if (!_.isUndefined(chipIndexToRemove)) { this.removeMarkedTag(); } }; onKeyPress = event => { _.invoke(this.props, 'onKeyPress', event); const keyCode = _.get(event, 'nativeEvent.key'); const pressedBackspace = keyCode === Constants.backspaceKey; if (pressedBackspace) { this.removeTag(); } }; getLabel = item => { const { getLabel } = this.props; if (getLabel) { return getLabel(item); } if (_.isString(item)) { return item; } return _.get(item, 'label'); }; onFocus = () => { this.setState({ isFocused: true }); }; onBlur = () => { this.setState({ isFocused: false }); }; renderLabel(tag, shouldMarkTag) { const { typography } = this.props.modifiers; const label = this.getLabel(tag); return <View row centerV> {shouldMarkTag && <Icon style={[styles.removeIcon, tag.invalid && styles.basicTagStyle && styles.invalidTagRemoveIcon]} source={Assets.icons.x} />} <Text style={[tag.invalid ? shouldMarkTag ? styles.errorMessageWhileMarked : styles.errorMessage : styles.tagLabel, typography]} accessibilityLabel={`${label} tag`}> {!tag.invalid && shouldMarkTag ? 'Remove' : label} </Text> </View>; } renderTag = (tag, index) => { const { tagStyle, renderTag } = this.props; const { chipIndexToRemove } = this.state; const shouldMarkTag = chipIndexToRemove === index; const markedTagStyle = tag.invalid ? styles.invalidMarkedTag : styles.tagMarked; const defaultTagStyle = tag.invalid ? styles.invalidTag : styles.tag; if (_.isFunction(renderTag)) { return renderTag(tag, index, shouldMarkTag, this.getLabel(tag)); } return <View key={index} style={[defaultTagStyle, tagStyle, basicTagStyle, shouldMarkTag && markedTagStyle]}> {this.renderLabel(tag, shouldMarkTag)} </View>; }; renderTagWrapper = (tag, index) => { return <TouchableOpacity key={index} activeOpacity={1} onPress={() => this.onTagPress(index)} accessibilityHint={!this.props.disableTagRemoval ? 'tap twice for remove tag mode' : undefined}> {this.renderTag(tag, index)} </TouchableOpacity>; }; renderNewChip = () => { const { defaultChipProps } = this.props; const { chipIndexToRemove, chips } = this.state; const disabled = isDisabled(this.props); return _.map(chips, (chip, index) => { const selected = chipIndexToRemove === index; const dismissColor = getChipDismissColor(chip, selected, defaultChipProps); return <View center flexS marginT-2 marginB-2> <Chip key={index} containerStyle={[styles.tag, chip.invalid && styles.invalidTag]} labelStyle={[styles.tagLabel, chip.invalid && styles.errorMessage, selected && !!chip.invalid && styles.errorMessageWhileMarked]} {...chip} {...defaultChipProps} disabled={disabled} marginR-s2 marginT-2 left={Assets.icons.x} onPress={_ => this.onTagPress(index)} onDismiss={selected ? () => this.onTagPress(index) : undefined} dismissColor={dismissColor} dismissIcon={Assets.icons.xSmall} dismissIconStyle={styles.dismissIconStyle} /> </View>; }); }; renderTitleText = () => { const { title, defaultChipProps } = this.props; const color = this.state.isFocused ? getValidationBasedColor(this.state.chips, defaultChipProps) : Colors.grey30; return title && <Text text70L color={color}>{title}</Text>; }; renderChips = () => { const { disableTagRemoval, chips: chipsProps } = this.props; const { chips } = this.state; const renderFunction = disableTagRemoval ? this.renderTag : this.renderTagWrapper; if (chipsProps) { return this.renderNewChip(); } else { // The old way of creating the 'Chip' internally return _.map(chips, (tag, index) => { return <View> {renderFunction(tag, index)} </View>; }); } }; renderCharCounter() { const { maxLength } = this.props; const counter = this.state.chips.length; if (maxLength) { const color = getCounterTextColor(this.state.chips, this.props); const counterText = getCounterText(counter, maxLength); return <Text color={color} style={styles.label} accessibilityLabel={`${counter} out of ${maxLength} max chips`}> {counterText} </Text>; } } renderUnderline = () => { const { isFocused, chips } = this.state; const { defaultChipProps } = this.props; const color = getValidationBasedColor(chips, defaultChipProps); return <View height={1} marginT-10 backgroundColor={isFocused ? color : Colors.grey50} />; }; renderTextInput() { const { inputStyle, selectionColor, title, ...others } = this.props; const { value } = this.state; const isLastTagMarked = this.isLastTagMarked(); return <View style={styles.inputWrapper}> <TextField ref={this.input} text80 blurOnSubmit={false} {...others} maxLength={undefined} title={this.props.chips ? undefined : title} value={value} onSubmitEditing={this.addTag} onChangeText={this.onChangeText} onKeyPress={this.onKeyPress} enableErrors={false} onFocus={this.onFocus} onBlur={this.onBlur} hideUnderline selectionColor={isLastTagMarked ? 'transparent' : selectionColor} style={[inputStyle, styles.alignTextCenter]} containerStyle={{ flexGrow: 0 }} collapsable={false} accessibilityHint={!this.props.disableTagRemoval ? 'press keyboard delete button to remove last tag' : undefined} /> </View>; } renderChipsContainer = () => { const { maxHeight, scrollViewProps } = this.props; const Container = maxHeight ? ScrollView : View; return <Container ref={this.scrollRef} showsVerticalScrollIndicator={false} style={!maxHeight && styles.tagsList} contentContainerStyle={styles.tagsList} {...scrollViewProps}> {this.renderChips()} {this.renderTextInput()} </Container>; }; render() { const { containerStyle, hideUnderline, validationErrorMessage, leftElement, maxHeight, chips } = this.props; const { chipIndexToRemove } = this.state; return <View style={[!hideUnderline && styles.withUnderline, containerStyle]}> {!!chips && this.renderTitleText()} <View style={[styles.tagListContainer, { maxHeight }]}> {leftElement} {this.renderChipsContainer()} </View> {!hideUnderline && this.renderUnderline()} {this.renderCharCounter()} {validationErrorMessage ? <View> <Text style={[styles.errorMessage, !!chipIndexToRemove && styles.errorMessageWhileMarked]}> {validationErrorMessage} </Text> </View> : null} </View>; } blur() { this.input.current?.blur(); } focus() { this.input.current?.focus(); } clear() { this.input.current?.clear(); } } export { ChipsInput }; // For tests export default asBaseComponent(ChipsInput); const basicTagStyle = { borderRadius: BorderRadiuses.br100, paddingVertical: 4.5, paddingHorizontal: 12, marginRight: GUTTER_SPACING, marginVertical: GUTTER_SPACING / 2 }; const styles = StyleSheet.create({ withUnderline: { borderBottomWidth: StyleSheet.hairlineWidth, borderColor: ThemeManager.dividerColor }, tagsList: { minHeight: 38, backgroundColor: 'transparent', flexDirection: 'row', flexWrap: 'wrap' }, tagListContainer: { backgroundColor: 'transparent', flexDirection: 'row', flexWrap: 'nowrap' }, inputWrapper: { flexGrow: 1, minWidth: 120, backgroundColor: 'transparent', justifyContent: 'center' }, tag: { borderWidth: 0, paddingVertical: 5, backgroundColor: Colors.primary }, invalidTag: { borderWidth: 1, borderColor: Colors.red30, backgroundColor: 'transparent' }, basicTagStyle: { ...basicTagStyle }, invalidMarkedTag: { borderColor: Colors.red10 }, tagMarked: { backgroundColor: Colors.grey10 }, dismissIconStyle: { width: 10, height: 10, marginRight: Spacings.s1 }, removeIcon: { tintColor: Colors.white, width: 10, height: 10, marginRight: 6 }, invalidTagRemoveIcon: { tintColor: Colors.red10 }, tagLabel: { ...Typography.text80, color: Colors.white }, errorMessage: { ...Typography.text80, color: Colors.red30 }, errorMessageWhileMarked: { color: Colors.red10 }, label: { marginTop: Spacings.s1, alignSelf: 'flex-end', height: Typography.text80?.lineHeight, ...Typography.text80 }, alignTextCenter: { textAlignVertical: 'center' } });