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
JavaScript
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'
}
});