react-native-tag-input
Version:
A tag input component for react-native
488 lines (454 loc) • 12.9 kB
JavaScript
// @flow
import type {
StyleObj,
} from 'react-native/Libraries/StyleSheet/StyleSheetTypes';
import * as React from 'react';
import PropTypes from 'prop-types';
import {
View,
Text,
TextInput,
StyleSheet,
TouchableOpacity,
Dimensions,
TouchableWithoutFeedback,
ScrollView,
ViewPropTypes,
Platform,
} from 'react-native';
import invariant from 'invariant';
const windowWidth = Dimensions.get('window').width;
type KeyboardShouldPersistTapsProps =
"always" | "never" | "handled" | false | true;
type RequiredProps<T> = {
/**
* An array of tags, which can be any type, as long as labelExtractor below
* can extract a string from it
*/
value: $ReadOnlyArray<T>,
/**
* A handler to be called when array of tags change. The parent should update
* the value prop when this is called if they want to enable removal of tags
*/
onChange: (items: $ReadOnlyArray<T>) => void,
/**
* Function to extract string value for label from item
*/
labelExtractor: (tagData: T) => string | React.Element<any>,
/**
* The text currently being displayed in the TextInput following the list of
* tags
*/
text: string,
/**
* This callback gets called when the user types in the TextInput. The parent
* should update the text prop when this is called if they want to enable
* input. This is also where any parsing to detect new tags should occur
*/
onChangeText: (text: string) => void,
};
type OptionalProps = {
/**
* If false, text input is not editable and existing tags cannot be removed.
*/
editable: boolean,
/**
* Background color of tags
*/
tagColor: string,
/**
* Text color of tags
*/
tagTextColor: string,
/**
* Styling override for container surrounding tag text
*/
tagContainerStyle?: StyleObj,
/**
* Styling override for tag's text component
*/
tagTextStyle?: StyleObj,
/**
* Width override for text input's default width when it's empty and showing placeholder
*/
inputDefaultWidth: number,
/**
* Color of text input
*/
inputColor: string,
/**
* Any misc. TextInput props (autoFocus, placeholder, returnKeyType, etc.)
*/
inputProps?: $PropertyType<TextInput, 'props'>,
/**
* Max height of the tag input on screen (will scroll if max height reached)
*/
maxHeight: number,
/**
* Callback that gets passed the new component height when it changes
*/
onHeightChange?: (height: number) => void,
/**
* Any ScrollView props (horizontal, showsHorizontalScrollIndicator, etc.)
*/
scrollViewProps?: $PropertyType<ScrollView, 'props'>,
};
type Props<T> = RequiredProps<T> & OptionalProps;
type State = {
inputWidth: number,
wrapperHeight: number,
};
class TagInput<T> extends React.PureComponent<Props<T>, State> {
static propTypes = {
value: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
labelExtractor: PropTypes.func.isRequired,
text: PropTypes.string.isRequired,
onChangeText: PropTypes.func.isRequired,
editable: PropTypes.bool,
tagColor: PropTypes.string,
tagTextColor: PropTypes.string,
tagContainerStyle: ViewPropTypes.style,
tagTextStyle: Text.propTypes.style,
inputDefaultWidth: PropTypes.number,
inputColor: PropTypes.string,
inputProps: PropTypes.shape(TextInput.propTypes),
maxHeight: PropTypes.number,
onHeightChange: PropTypes.func,
scrollViewProps: PropTypes.shape(ScrollView.propTypes),
};
props: Props<T>;
state: State;
wrapperWidth = windowWidth;
spaceLeft = 0;
// scroll to bottom
contentHeight = 0;
// refs
tagInput: ?TextInput = null;
scrollView: ?ScrollView = null;
static defaultProps = {
editable: true,
tagColor: '#dddddd',
tagTextColor: '#777777',
inputDefaultWidth: 90,
inputColor: '#777777',
maxHeight: 75,
};
static inputWidth(
text: string,
spaceLeft: number,
inputDefaultWidth: number,
wrapperWidth: number
) {
if (text === "") {
return inputDefaultWidth;
} else if (spaceLeft >= 100) {
return spaceLeft - 10;
} else {
return wrapperWidth;
}
}
constructor(props: Props<T>) {
super(props);
this.state = {
inputWidth: props.inputDefaultWidth,
wrapperHeight: 36,
}
}
componentWillReceiveProps(nextProps: Props<T>) {
const inputWidth = TagInput.inputWidth(
nextProps.text,
this.spaceLeft,
nextProps.inputDefaultWidth,
this.wrapperWidth,
);
if (inputWidth !== this.state.inputWidth) {
this.setState({ inputWidth });
}
const wrapperHeight = Math.min(
nextProps.maxHeight,
this.contentHeight,
);
if (wrapperHeight !== this.state.wrapperHeight) {
this.setState({ wrapperHeight });
}
}
componentWillUpdate(nextProps: Props<T>, nextState: State) {
if (
this.props.onHeightChange &&
nextState.wrapperHeight !== this.state.wrapperHeight
) {
this.props.onHeightChange(nextState.wrapperHeight);
}
}
measureWrapper = (event: { nativeEvent: { layout: { width: number } } }) => {
this.wrapperWidth = event.nativeEvent.layout.width;
const inputWidth = TagInput.inputWidth(
this.props.text,
this.spaceLeft,
this.props.inputDefaultWidth,
this.wrapperWidth,
);
if (inputWidth !== this.state.inputWidth) {
this.setState({ inputWidth });
}
}
onBlur = (event: { nativeEvent: { text: string } }) => {
invariant(Platform.OS === "ios", "only iOS gets text on TextInput.onBlur");
this.props.onChangeText(event.nativeEvent.text);
}
onKeyPress = (event: { nativeEvent: { key: string } }) => {
if (this.props.text !== '' || event.nativeEvent.key !== 'Backspace') {
return;
}
const tags = [...this.props.value];
tags.pop();
this.props.onChange(tags);
this.scrollToEnd();
this.focus();
}
focus = () => {
invariant(this.tagInput, "should be set");
this.tagInput.focus();
}
removeIndex = (index: number) => {
const tags = [...this.props.value];
tags.splice(index, 1);
this.props.onChange(tags);
}
scrollToEnd = () => {
const scrollView = this.scrollView;
invariant(
scrollView,
"this.scrollView ref should exist before scrollToEnd called",
);
setTimeout(() => {
scrollView.scrollToEnd({ animated: true });
}, 0);
}
render() {
const tags = this.props.value.map((tag, index) => (
<Tag
index={index}
label={this.props.labelExtractor(tag)}
isLastTag={this.props.value.length === index + 1}
onLayoutLastTag={this.onLayoutLastTag}
removeIndex={this.removeIndex}
tagColor={this.props.tagColor}
tagTextColor={this.props.tagTextColor}
tagContainerStyle={this.props.tagContainerStyle}
tagTextStyle={this.props.tagTextStyle}
key={index}
editable={this.props.editable}
/>
));
return (
<TouchableWithoutFeedback
onPress={this.focus}
style={styles.container}
onLayout={this.measureWrapper}
>
<View style={[styles.wrapper, { height: this.state.wrapperHeight }]}>
<ScrollView
ref={this.scrollViewRef}
style={styles.tagInputContainerScroll}
onContentSizeChange={this.onScrollViewContentSizeChange}
keyboardShouldPersistTaps={
("handled": KeyboardShouldPersistTapsProps)
}
{...this.props.scrollViewProps}
>
<View style={styles.tagInputContainer}>
{tags}
<View style={[
styles.textInputContainer,
{ width: this.state.inputWidth },
]}>
<TextInput
ref={this.tagInputRef}
blurOnSubmit={false}
onKeyPress={this.onKeyPress}
value={this.props.text}
style={[styles.textInput, {
width: this.state.inputWidth,
color: this.props.inputColor,
}]}
onBlur={Platform.OS === "ios" ? this.onBlur : undefined}
onChangeText={this.props.onChangeText}
autoCapitalize="none"
autoCorrect={false}
placeholder="Start typing"
returnKeyType="done"
keyboardType="default"
editable={this.props.editable}
underlineColorAndroid="rgba(0,0,0,0)"
{...this.props.inputProps}
/>
</View>
</View>
</ScrollView>
</View>
</TouchableWithoutFeedback>
)
}
tagInputRef = (tagInput: ?React.ElementRef<typeof TextInput>) => {
invariant(typeof tagInput === "object", "TextInput ref is object");
this.tagInput = tagInput;
}
scrollViewRef = (scrollView: ?React.ElementRef<typeof ScrollView>) => {
invariant(typeof scrollView === "object", "ScrollView ref is object");
this.scrollView = scrollView;
}
onScrollViewContentSizeChange = (w: number, h: number) => {
if (this.contentHeight === h) {
return;
}
const nextWrapperHeight = Math.min(this.props.maxHeight, h);
if (nextWrapperHeight !== this.state.wrapperHeight) {
this.setState(
{ wrapperHeight: nextWrapperHeight },
this.contentHeight < h ? this.scrollToEnd : undefined,
);
} else if (this.contentHeight < h) {
this.scrollToEnd();
}
this.contentHeight = h;
}
onLayoutLastTag = (endPosOfTag: number) => {
const margin = 3;
this.spaceLeft = this.wrapperWidth - endPosOfTag - margin - 10;
const inputWidth = TagInput.inputWidth(
this.props.text,
this.spaceLeft,
this.props.inputDefaultWidth,
this.wrapperWidth,
);
if (inputWidth !== this.state.inputWidth) {
this.setState({ inputWidth });
}
}
}
type TagProps = {
index: number,
label: string | React.Element<any>,
isLastTag: boolean,
editable: boolean,
onLayoutLastTag: (endPosOfTag: number) => void,
removeIndex: (index: number) => void,
tagColor: string,
tagTextColor: string,
tagContainerStyle?: StyleObj,
tagTextStyle?: StyleObj,
};
class Tag extends React.PureComponent<TagProps> {
props: TagProps;
static propTypes = {
index: PropTypes.number.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
isLastTag: PropTypes.bool.isRequired,
editable: PropTypes.bool.isRequired,
onLayoutLastTag: PropTypes.func.isRequired,
removeIndex: PropTypes.func.isRequired,
tagColor: PropTypes.string.isRequired,
tagTextColor: PropTypes.string.isRequired,
tagContainerStyle: ViewPropTypes.style,
tagTextStyle: Text.propTypes.style,
};
curPos: ?number = null;
componentWillReceiveProps(nextProps: TagProps) {
if (
!this.props.isLastTag &&
nextProps.isLastTag &&
this.curPos !== null &&
this.curPos !== undefined
) {
this.props.onLayoutLastTag(this.curPos);
}
}
render() {
let tagLabel;
if (React.isValidElement(this.props.label)) {
tagLabel = this.props.label;
} else {
tagLabel = (
<Text style={[
styles.tagText,
{ color: this.props.tagTextColor },
this.props.tagTextStyle,
]}>
{this.props.label}
×
</Text>
);
}
return (
<TouchableOpacity
disabled={!this.props.editable}
onPress={this.onPress}
onLayout={this.onLayoutLastTag}
style={[
styles.tag,
{ backgroundColor: this.props.tagColor },
this.props.tagContainerStyle,
]}
>
{tagLabel}
</TouchableOpacity>
);
}
onPress = () => {
this.props.removeIndex(this.props.index);
}
onLayoutLastTag = (
event: { nativeEvent: { layout: { x: number, width: number } } },
) => {
const layout = event.nativeEvent.layout;
this.curPos = layout.width + layout.x;
if (this.props.isLastTag) {
this.props.onLayoutLastTag(this.curPos);
}
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
wrapper: {
flex: 1,
flexDirection: 'row',
marginTop: 3,
marginBottom: 2,
alignItems: 'flex-start',
},
tagInputContainerScroll: {
flex: 1,
},
tagInputContainer: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
},
textInput: {
height: 36,
fontSize: 16,
flex: .6,
marginBottom: 6,
padding: 0,
},
textInputContainer: {
height: 36,
},
tag: {
justifyContent: 'center',
marginTop: 6,
marginRight: 3,
padding: 8,
height: 24,
borderRadius: 2,
},
tagText: {
padding: 0,
margin: 0,
},
});
export default TagInput;