You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
284 lines
6.5 KiB
284 lines
6.5 KiB
import classNames from 'classnames';
|
|
import _ from 'lodash';
|
|
import PropTypes from 'prop-types';
|
|
import React, { Component } from 'react';
|
|
import { kinds } from 'Helpers/Props';
|
|
import tagShape from 'Helpers/Props/Shapes/tagShape';
|
|
import AutoSuggestInput from './AutoSuggestInput';
|
|
import TagInputInput from './TagInputInput';
|
|
import TagInputTag from './TagInputTag';
|
|
import styles from './TagInput.css';
|
|
|
|
function getTag(value, selectedIndex, suggestions, allowNew) {
|
|
if (selectedIndex == null && value) {
|
|
const existingTag = suggestions.find((suggestion) => suggestion.name === value);
|
|
|
|
if (existingTag) {
|
|
return existingTag;
|
|
} else if (allowNew) {
|
|
return { name: value };
|
|
}
|
|
} else if (selectedIndex != null) {
|
|
return suggestions[selectedIndex];
|
|
}
|
|
}
|
|
|
|
class TagInput extends Component {
|
|
|
|
//
|
|
// Lifecycle
|
|
|
|
constructor(props, context) {
|
|
super(props, context);
|
|
|
|
this.state = {
|
|
value: '',
|
|
suggestions: [],
|
|
isFocused: false
|
|
};
|
|
|
|
this._autosuggestRef = null;
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.addTag.cancel();
|
|
}
|
|
|
|
//
|
|
// Control
|
|
|
|
_setAutosuggestRef = (ref) => {
|
|
this._autosuggestRef = ref;
|
|
};
|
|
|
|
getSuggestionValue({ name }) {
|
|
return name;
|
|
}
|
|
|
|
shouldRenderSuggestions = (value) => {
|
|
return value.length >= this.props.minQueryLength;
|
|
};
|
|
|
|
renderSuggestion({ name }) {
|
|
return name;
|
|
}
|
|
|
|
addTag = _.debounce((tag) => {
|
|
this.props.onTagAdd(tag);
|
|
|
|
this.setState({
|
|
value: '',
|
|
suggestions: []
|
|
});
|
|
}, 250, { leading: true, trailing: false });
|
|
|
|
//
|
|
// Listeners
|
|
|
|
onInputContainerPress = () => {
|
|
this._autosuggestRef.input.focus();
|
|
};
|
|
|
|
onInputChange = (event, { newValue, method }) => {
|
|
const value = _.isObject(newValue) ? newValue.name : newValue;
|
|
|
|
if (method === 'type') {
|
|
this.setState({ value });
|
|
}
|
|
};
|
|
|
|
onInputKeyDown = (event) => {
|
|
const {
|
|
tags,
|
|
allowNew,
|
|
delimiters,
|
|
onTagDelete
|
|
} = this.props;
|
|
|
|
const {
|
|
value,
|
|
suggestions
|
|
} = this.state;
|
|
|
|
const key = event.key;
|
|
|
|
if (key === 'Backspace' && !value.length) {
|
|
const index = tags.length - 1;
|
|
|
|
if (index >= 0) {
|
|
onTagDelete({ index, id: tags[index].id });
|
|
}
|
|
|
|
setTimeout(() => {
|
|
this.onSuggestionsFetchRequested({ value: '' });
|
|
});
|
|
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (delimiters.includes(key)) {
|
|
const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
|
|
const tag = getTag(value, selectedIndex, suggestions, allowNew);
|
|
|
|
if (tag) {
|
|
this.addTag(tag);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
};
|
|
|
|
onInputFocus = () => {
|
|
this.setState({ isFocused: true });
|
|
};
|
|
|
|
onInputBlur = () => {
|
|
this.setState({ isFocused: false });
|
|
|
|
if (!this._autosuggestRef) {
|
|
return;
|
|
}
|
|
|
|
const {
|
|
allowNew
|
|
} = this.props;
|
|
|
|
const {
|
|
value,
|
|
suggestions
|
|
} = this.state;
|
|
|
|
const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
|
|
const tag = getTag(value, selectedIndex, suggestions, allowNew);
|
|
|
|
if (tag) {
|
|
this.addTag(tag);
|
|
}
|
|
};
|
|
|
|
onSuggestionsFetchRequested = ({ value }) => {
|
|
const lowerCaseValue = value.toLowerCase();
|
|
|
|
const {
|
|
tags,
|
|
tagList
|
|
} = this.props;
|
|
|
|
const suggestions = tagList.filter((tag) => {
|
|
return (
|
|
tag.name.toLowerCase().includes(lowerCaseValue) &&
|
|
!tags.some((t) => t.id === tag.id));
|
|
});
|
|
|
|
this.setState({ suggestions });
|
|
};
|
|
|
|
onSuggestionsClearRequested = () => {
|
|
// Required because props aren't always rendered, but no-op
|
|
// because we don't want to reset the paths after a path is selected.
|
|
};
|
|
|
|
onSuggestionSelected = (event, { suggestion }) => {
|
|
this.addTag(suggestion);
|
|
};
|
|
|
|
//
|
|
// Render
|
|
|
|
renderInputComponent = (inputProps, forwardedRef) => {
|
|
const {
|
|
tags,
|
|
kind,
|
|
tagComponent,
|
|
onTagDelete
|
|
} = this.props;
|
|
|
|
return (
|
|
<TagInputInput
|
|
forwardedRef={forwardedRef}
|
|
tags={tags}
|
|
kind={kind}
|
|
inputProps={inputProps}
|
|
isFocused={this.state.isFocused}
|
|
tagComponent={tagComponent}
|
|
onTagDelete={onTagDelete}
|
|
onInputContainerPress={this.onInputContainerPress}
|
|
/>
|
|
);
|
|
};
|
|
|
|
render() {
|
|
const {
|
|
className,
|
|
inputContainerClassName,
|
|
hasError,
|
|
hasWarning,
|
|
...otherProps
|
|
} = this.props;
|
|
|
|
const {
|
|
value,
|
|
suggestions,
|
|
isFocused
|
|
} = this.state;
|
|
|
|
return (
|
|
<AutoSuggestInput
|
|
{...otherProps}
|
|
forwardedRef={this._setAutosuggestRef}
|
|
className={styles.internalInput}
|
|
inputContainerClassName={classNames(
|
|
inputContainerClassName,
|
|
isFocused && styles.isFocused,
|
|
hasError && styles.hasError,
|
|
hasWarning && styles.hasWarning
|
|
)}
|
|
value={value}
|
|
suggestions={suggestions}
|
|
getSuggestionValue={this.getSuggestionValue}
|
|
shouldRenderSuggestions={this.shouldRenderSuggestions}
|
|
focusInputOnSuggestionClick={false}
|
|
renderSuggestion={this.renderSuggestion}
|
|
renderInputComponent={this.renderInputComponent}
|
|
onInputChange={this.onInputChange}
|
|
onInputKeyDown={this.onInputKeyDown}
|
|
onInputFocus={this.onInputFocus}
|
|
onInputBlur={this.onInputBlur}
|
|
onSuggestionSelected={this.onSuggestionSelected}
|
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
|
onChange={this.onInputChange}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
TagInput.propTypes = {
|
|
className: PropTypes.string.isRequired,
|
|
inputContainerClassName: PropTypes.string.isRequired,
|
|
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
|
|
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
|
|
allowNew: PropTypes.bool.isRequired,
|
|
kind: PropTypes.oneOf(kinds.all).isRequired,
|
|
placeholder: PropTypes.string.isRequired,
|
|
delimiters: PropTypes.arrayOf(PropTypes.string).isRequired,
|
|
minQueryLength: PropTypes.number.isRequired,
|
|
hasError: PropTypes.bool,
|
|
hasWarning: PropTypes.bool,
|
|
tagComponent: PropTypes.elementType.isRequired,
|
|
onTagAdd: PropTypes.func.isRequired,
|
|
onTagDelete: PropTypes.func.isRequired
|
|
};
|
|
|
|
TagInput.defaultProps = {
|
|
className: styles.internalInput,
|
|
inputContainerClassName: styles.input,
|
|
allowNew: true,
|
|
kind: kinds.INFO,
|
|
placeholder: '',
|
|
delimiters: ['Tab', 'Enter', ' ', ','],
|
|
minQueryLength: 1,
|
|
tagComponent: TagInputTag
|
|
};
|
|
|
|
export default TagInput;
|