2017-01-26 20:05:32 +03:00
|
|
|
|
/* eslint-disable */
|
2016-05-29 13:47:27 +03:00
|
|
|
|
import React, { Component, PropTypes } from 'react';
|
2016-01-06 14:56:30 +03:00
|
|
|
|
import ReactDOM from 'react-dom';
|
2016-05-21 20:48:53 +03:00
|
|
|
|
import classnames from 'classnames';
|
|
|
|
|
import { themr } from 'react-css-themr';
|
2016-05-28 18:44:29 +03:00
|
|
|
|
import { AUTOCOMPLETE } from '../identifiers.js';
|
|
|
|
|
import InjectChip from '../chip/Chip.js';
|
|
|
|
|
import InjectInput from '../input/Input.js';
|
|
|
|
|
import events from '../utils/events.js';
|
2016-01-06 14:56:30 +03:00
|
|
|
|
|
|
|
|
|
const POSITION = {
|
2016-06-04 00:49:35 +03:00
|
|
|
|
AUTO: 'auto',
|
|
|
|
|
DOWN: 'down',
|
2017-01-26 20:05:32 +03:00
|
|
|
|
UP: 'up',
|
2016-01-06 14:56:30 +03:00
|
|
|
|
};
|
|
|
|
|
|
2016-05-28 18:44:29 +03:00
|
|
|
|
const factory = (Chip, Input) => {
|
2016-05-29 13:47:27 +03:00
|
|
|
|
class Autocomplete extends Component {
|
2017-01-26 20:05:32 +03:00
|
|
|
|
static propTypes = {
|
|
|
|
|
allowCreate: PropTypes.bool,
|
|
|
|
|
className: PropTypes.string,
|
|
|
|
|
direction: PropTypes.oneOf(['auto', 'up', 'down']),
|
|
|
|
|
disabled: PropTypes.bool,
|
|
|
|
|
error: React.PropTypes.oneOfType([
|
|
|
|
|
React.PropTypes.string,
|
|
|
|
|
React.PropTypes.node,
|
|
|
|
|
]),
|
|
|
|
|
keepFocusOnChange: PropTypes.bool,
|
|
|
|
|
label: React.PropTypes.oneOfType([
|
|
|
|
|
React.PropTypes.string,
|
|
|
|
|
React.PropTypes.node,
|
|
|
|
|
]),
|
|
|
|
|
multiple: PropTypes.bool,
|
|
|
|
|
onBlur: PropTypes.func,
|
|
|
|
|
onChange: PropTypes.func,
|
|
|
|
|
onFocus: PropTypes.func,
|
|
|
|
|
onQueryChange: PropTypes.func,
|
|
|
|
|
query: PropTypes.string,
|
|
|
|
|
selectedPosition: PropTypes.oneOf(['above', 'below', 'none']),
|
|
|
|
|
showSelectedWhenNotInSource: PropTypes.bool,
|
|
|
|
|
showSuggestionsWhenValueIsSet: PropTypes.bool,
|
|
|
|
|
source: PropTypes.any,
|
|
|
|
|
suggestionMatch: PropTypes.oneOf(['disabled', 'start', 'anywhere', 'word']),
|
|
|
|
|
theme: PropTypes.shape({
|
|
|
|
|
active: PropTypes.string,
|
|
|
|
|
autocomplete: PropTypes.string,
|
|
|
|
|
focus: PropTypes.string,
|
|
|
|
|
input: PropTypes.string,
|
|
|
|
|
suggestion: PropTypes.string,
|
|
|
|
|
suggestions: PropTypes.string,
|
|
|
|
|
up: PropTypes.string,
|
|
|
|
|
value: PropTypes.string,
|
|
|
|
|
values: PropTypes.string,
|
|
|
|
|
}),
|
|
|
|
|
value: PropTypes.any,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static defaultProps = {
|
|
|
|
|
allowCreate: false,
|
|
|
|
|
className: '',
|
|
|
|
|
direction: 'auto',
|
|
|
|
|
keepFocusOnChange: false,
|
|
|
|
|
multiple: true,
|
|
|
|
|
selectedPosition: 'above',
|
|
|
|
|
showSelectedWhenNotInSource: false,
|
|
|
|
|
showSuggestionsWhenValueIsSet: false,
|
|
|
|
|
source: {},
|
|
|
|
|
suggestionMatch: 'start',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
state = {
|
|
|
|
|
direction: this.props.direction,
|
|
|
|
|
focus: false,
|
|
|
|
|
showAllSuggestions: this.props.showSuggestionsWhenValueIsSet,
|
|
|
|
|
query: this.props.query ? this.props.query : this.query(this.props.value),
|
|
|
|
|
isValueAnObject: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
componentWillReceiveProps(nextProps) {
|
|
|
|
|
if (!this.props.multiple) {
|
|
|
|
|
const query = nextProps.query ? nextProps.query : this.query(nextProps.value);
|
|
|
|
|
this.updateQuery(query, false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
shouldComponentUpdate(nextProps, nextState) {
|
|
|
|
|
if (!this.state.focus && nextState.focus && this.props.direction === POSITION.AUTO) {
|
|
|
|
|
const direction = this.calculateDirection();
|
|
|
|
|
if (this.state.direction !== direction) {
|
|
|
|
|
this.setState({ direction });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleChange = (values, event) => {
|
|
|
|
|
const value = this.props.multiple ? values : values[0];
|
|
|
|
|
const { showSuggestionsWhenValueIsSet: showAllSuggestions } = this.props;
|
|
|
|
|
const query = this.query(value);
|
|
|
|
|
if (this.props.onChange) this.props.onChange(value, event);
|
|
|
|
|
if (this.props.keepFocusOnChange) {
|
|
|
|
|
this.setState({ query, showAllSuggestions });
|
|
|
|
|
} else {
|
|
|
|
|
this.setState({ focus: false, query, showAllSuggestions }, () => {
|
|
|
|
|
ReactDOM.findDOMNode(this).querySelector('input').blur();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
this.updateQuery(query, this.props.query);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleMouseDown = (event) => {
|
|
|
|
|
this.selectOrCreateActiveItem(event);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleQueryBlur = (event) => {
|
|
|
|
|
if (this.state.focus) this.setState({ focus: false });
|
|
|
|
|
if (this.props.onBlur) this.props.onBlur(event, this.state.active);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
updateQuery = (query, notify) => {
|
|
|
|
|
if (notify && this.props.onQueryChange) this.props.onQueryChange(query);
|
|
|
|
|
this.setState({ query });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleQueryChange = (value) => {
|
|
|
|
|
const query = this.clearQuery ? '' : value;
|
|
|
|
|
this.clearQuery = false;
|
|
|
|
|
|
|
|
|
|
this.updateQuery(query, true);
|
|
|
|
|
this.setState({ showAllSuggestions: false, active: null });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleQueryFocus = (event) => {
|
|
|
|
|
this.suggestionsNode.scrollTop = 0;
|
|
|
|
|
this.setState({ active: '', focus: true });
|
|
|
|
|
if (this.props.onFocus) this.props.onFocus(event);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleQueryKeyDown = (event) => {
|
2017-01-18 10:34:27 +03:00
|
|
|
|
// Mark query for clearing in handleQueryChange when pressing backspace and showing all suggestions.
|
2017-01-26 20:05:32 +03:00
|
|
|
|
this.clearQuery = (
|
2016-05-28 18:44:29 +03:00
|
|
|
|
event.which === 8
|
|
|
|
|
&& this.props.showSuggestionsWhenValueIsSet
|
|
|
|
|
&& this.state.showAllSuggestions
|
|
|
|
|
);
|
2016-11-01 19:01:39 +03:00
|
|
|
|
|
2017-01-26 20:05:32 +03:00
|
|
|
|
if (event.which === 13) {
|
|
|
|
|
this.selectOrCreateActiveItem(event);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleQueryKeyUp = (event) => {
|
|
|
|
|
if (event.which === 27) ReactDOM.findDOMNode(this).querySelector('input').blur();
|
|
|
|
|
|
|
|
|
|
if ([40, 38].indexOf(event.which) !== -1) {
|
|
|
|
|
const suggestionsKeys = [...this.suggestions().keys()];
|
|
|
|
|
let index = suggestionsKeys.indexOf(this.state.active) + (event.which === 40 ? +1 : -1);
|
|
|
|
|
if (index < 0) index = suggestionsKeys.length - 1;
|
|
|
|
|
if (index >= suggestionsKeys.length) index = 0;
|
|
|
|
|
this.setState({ active: suggestionsKeys[index] });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleSuggestionHover = (event) => {
|
|
|
|
|
this.setState({ active: event.target.id });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
calculateDirection() {
|
|
|
|
|
if (this.props.direction === 'auto') {
|
|
|
|
|
const client = ReactDOM.findDOMNode(this.inputNode).getBoundingClientRect();
|
|
|
|
|
const screen_height = window.innerHeight || document.documentElement.offsetHeight;
|
|
|
|
|
const up = client.top > ((screen_height / 2) + client.height);
|
|
|
|
|
return up ? 'up' : 'down';
|
|
|
|
|
}
|
|
|
|
|
return this.props.direction;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query(key) {
|
2016-08-07 04:37:16 +03:00
|
|
|
|
let query_value = '';
|
|
|
|
|
if (!this.props.multiple && key) {
|
2016-12-06 00:23:01 +03:00
|
|
|
|
const source_value = this.source().get(`${key}`);
|
2017-01-26 20:05:32 +03:00
|
|
|
|
query_value = source_value || key;
|
2016-08-07 04:08:41 +03:00
|
|
|
|
}
|
|
|
|
|
return query_value;
|
2017-01-26 20:05:32 +03:00
|
|
|
|
}
|
2016-11-22 13:20:43 +03:00
|
|
|
|
|
2017-01-26 20:05:32 +03:00
|
|
|
|
selectOrCreateActiveItem(event) {
|
|
|
|
|
let target = this.state.active;
|
|
|
|
|
if (!target) {
|
|
|
|
|
target = this.props.allowCreate
|
2016-11-22 13:20:43 +03:00
|
|
|
|
? this.state.query
|
|
|
|
|
: [...this.suggestions().keys()][0];
|
2017-01-26 20:05:32 +03:00
|
|
|
|
this.setState({ active: target });
|
|
|
|
|
}
|
|
|
|
|
this.select(event, target);
|
|
|
|
|
}
|
2016-05-28 18:44:29 +03:00
|
|
|
|
|
2017-01-26 20:05:32 +03:00
|
|
|
|
normalise(value) {
|
2017-01-17 10:24:19 +03:00
|
|
|
|
const sdiak = 'áâäąáâäąččććççĉĉďđďđééěëēėęéěëēėęĝĝğğġġģģĥĥħħíîíîĩĩīīĭĭįįi̇ıĵĵķķĸĺĺļļŀŀłłĺľĺľňńņŋŋņňńʼnóöôőøōōóöőôøřřŕŕŗŗššśśŝŝşşţţťťŧŧũũūūŭŭůůűűúüúüűųųŵŵýyŷŷýyžžźźżżß';
|
|
|
|
|
const bdiak = 'AAAAAAAACCCCCCCCDDDDEEEEEEEEEEEEEGGGGGGGGHHHHIIIIIIIIIIIIIIJJKKKLLLLLLLLLLLLNNNNNNNNNOOOOOOOOOOOORRRRRRSSSSSSSSTTTTTTUUUUUUUUUUUUUUUUUWWYYYYYYZZZZZZS';
|
|
|
|
|
|
|
|
|
|
let normalised = '';
|
|
|
|
|
for (let p = 0; p < value.length; p++) {
|
|
|
|
|
if (sdiak.indexOf(value.charAt(p)) !== -1) {
|
|
|
|
|
normalised += bdiak.charAt(sdiak.indexOf(value.charAt(p)));
|
|
|
|
|
} else {
|
|
|
|
|
normalised += value.charAt(p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalised.toLowerCase().trim();
|
2017-01-26 20:05:32 +03:00
|
|
|
|
}
|
2017-01-17 10:24:19 +03:00
|
|
|
|
|
2017-01-26 20:05:32 +03:00
|
|
|
|
suggestions() {
|
|
|
|
|
let suggest = new Map();
|
|
|
|
|
const rawQuery = this.state.query || (this.props.multiple ? '' : this.props.value);
|
|
|
|
|
const query = this.normalise((`${rawQuery}`));
|
|
|
|
|
const values = this.values();
|
|
|
|
|
const source = this.source();
|
2016-05-28 18:44:29 +03:00
|
|
|
|
|
|
|
|
|
// Suggest any non-set value which matches the query
|
2017-01-26 20:05:32 +03:00
|
|
|
|
if (this.props.multiple) {
|
|
|
|
|
for (const [key, value] of source) {
|
|
|
|
|
if (!values.has(key) && this.matches(this.normalise(value), query)) {
|
|
|
|
|
suggest.set(key, value);
|
|
|
|
|
}
|
|
|
|
|
}
|
2016-05-20 17:23:43 +03:00
|
|
|
|
|
2016-05-28 18:44:29 +03:00
|
|
|
|
// When multiple is false, suggest any value which matches the query if showAllSuggestions is false
|
2017-01-26 20:05:32 +03:00
|
|
|
|
} else if (query && !this.state.showAllSuggestions) {
|
|
|
|
|
for (const [key, value] of source) {
|
|
|
|
|
if (this.matches(this.normalise(value), query)) {
|
|
|
|
|
suggest.set(key, value);
|
|
|
|
|
}
|
|
|
|
|
}
|
2016-05-28 18:44:29 +03:00
|
|
|
|
|
|
|
|
|
// When multiple is false, suggest all values when showAllSuggestions is true
|
2017-01-26 20:05:32 +03:00
|
|
|
|
} else {
|
|
|
|
|
suggest = source;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return suggest;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
matches(value, query) {
|
|
|
|
|
const { suggestionMatch } = this.props;
|
|
|
|
|
|
|
|
|
|
if (suggestionMatch === 'disabled') {
|
|
|
|
|
return true;
|
|
|
|
|
} else if (suggestionMatch === 'start') {
|
|
|
|
|
return value.startsWith(query);
|
|
|
|
|
} else if (suggestionMatch === 'anywhere') {
|
|
|
|
|
return value.includes(query);
|
|
|
|
|
} else if (suggestionMatch === 'word') {
|
|
|
|
|
const re = new RegExp(`\\b${query}`, 'g');
|
|
|
|
|
return re.test(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
source() {
|
|
|
|
|
const { source: src } = this.props;
|
|
|
|
|
if (src.hasOwnProperty('length')) {
|
|
|
|
|
return new Map(src.map(item => Array.isArray(item) ? [...item] : [item, item]));
|
|
|
|
|
}
|
|
|
|
|
return new Map(Object.keys(src).map(key => [`${key}`, src[key]]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
values() {
|
|
|
|
|
let vals = this.props.multiple ? this.props.value : [this.props.value];
|
|
|
|
|
|
|
|
|
|
if (!vals) vals = [];
|
|
|
|
|
|
|
|
|
|
if (this.props.showSelectedWhenNotInSource && this.isValueAnObject()) {
|
|
|
|
|
return new Map(Object.entries(vals));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const valueMap = new Map();
|
|
|
|
|
|
|
|
|
|
const stringVals = vals.map(v => `${v}`);
|
|
|
|
|
for (const [k, v] of this.source()) {
|
|
|
|
|
if (stringVals.indexOf(k) !== -1) valueMap.set(k, v);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return valueMap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
select = (event, target) => {
|
|
|
|
|
events.pauseEvent(event);
|
|
|
|
|
const values = this.values(this.props.value);
|
|
|
|
|
const source = this.source();
|
|
|
|
|
const newValue = target === void 0 ? event.target.id : target;
|
|
|
|
|
|
|
|
|
|
if (this.isValueAnObject()) {
|
|
|
|
|
const newItem = Array.from(source).reduce((obj, [k, value]) => {
|
|
|
|
|
if (k === newValue) {
|
|
|
|
|
obj[k] = value;
|
|
|
|
|
}
|
|
|
|
|
return obj;
|
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
|
|
return this.handleChange(Object.assign(this.mapToObject(values), newItem), event);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.handleChange([newValue, ...values.keys()], event);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
unselect(key, event) {
|
|
|
|
|
if (!this.props.disabled) {
|
|
|
|
|
const values = this.values(this.props.value);
|
|
|
|
|
|
|
|
|
|
values.delete(key);
|
|
|
|
|
|
|
|
|
|
if (this.isValueAnObject()) {
|
|
|
|
|
return this.handleChange(this.mapToObject(values), event);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.handleChange([...values.keys()], event);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isValueAnObject() {
|
2016-12-07 13:59:35 +03:00
|
|
|
|
return !Array.isArray(this.props.value) && typeof this.props.value === 'object';
|
2017-01-26 20:05:32 +03:00
|
|
|
|
}
|
2016-11-30 18:05:52 +03:00
|
|
|
|
|
2017-01-26 20:05:32 +03:00
|
|
|
|
mapToObject(map) {
|
2016-11-30 18:05:52 +03:00
|
|
|
|
return Array.from(map).reduce((obj, [k, value]) => {
|
|
|
|
|
obj[k] = value;
|
|
|
|
|
return obj;
|
|
|
|
|
}, {});
|
2017-01-26 20:05:32 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderSelected() {
|
|
|
|
|
if (this.props.multiple) {
|
|
|
|
|
const selectedItems = [...this.values()].map(([key, value]) => (
|
|
|
|
|
<Chip
|
|
|
|
|
key={key}
|
|
|
|
|
className={this.props.theme.value}
|
|
|
|
|
deletable
|
|
|
|
|
onDeleteClick={this.unselect.bind(this, key)}
|
|
|
|
|
>
|
|
|
|
|
{value}
|
|
|
|
|
</Chip>
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
return <ul className={this.props.theme.values}>{selectedItems}</ul>;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderSuggestions() {
|
|
|
|
|
const { theme } = this.props;
|
|
|
|
|
const suggestions = [...this.suggestions()].map(([key, value]) => {
|
|
|
|
|
const className = classnames(theme.suggestion, { [theme.active]: this.state.active === key });
|
|
|
|
|
return (
|
|
|
|
|
<li
|
|
|
|
|
id={key}
|
|
|
|
|
key={key}
|
|
|
|
|
className={className}
|
|
|
|
|
onMouseDown={this.handleMouseDown}
|
|
|
|
|
onMouseOver={this.handleSuggestionHover}
|
|
|
|
|
>
|
|
|
|
|
{value}
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ul
|
|
|
|
|
className={classnames(theme.suggestions, { [theme.up]: this.state.direction === 'up' })}
|
|
|
|
|
ref={(node) => { this.suggestionsNode = node; }}
|
|
|
|
|
>
|
|
|
|
|
{suggestions}
|
|
|
|
|
</ul>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render() {
|
|
|
|
|
const {
|
|
|
|
|
allowCreate, error, label, source, suggestionMatch, query, // eslint-disable-line no-unused-vars
|
|
|
|
|
selectedPosition, keepFocusOnChange, showSuggestionsWhenValueIsSet, showSelectedWhenNotInSource, onQueryChange, // eslint-disable-line no-unused-vars
|
2016-08-06 20:28:30 +03:00
|
|
|
|
theme, ...other
|
2016-07-04 23:03:57 +03:00
|
|
|
|
} = this.props;
|
2017-01-26 20:05:32 +03:00
|
|
|
|
const className = classnames(theme.autocomplete, {
|
|
|
|
|
[theme.focus]: this.state.focus,
|
|
|
|
|
}, this.props.className);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div data-react-toolbox="autocomplete" className={className}>
|
|
|
|
|
{this.props.selectedPosition === 'above' ? this.renderSelected() : null}
|
|
|
|
|
<Input
|
|
|
|
|
{...other}
|
|
|
|
|
ref={(node) => { this.inputNode = node; }}
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
className={theme.input}
|
|
|
|
|
error={error}
|
|
|
|
|
label={label}
|
|
|
|
|
onBlur={this.handleQueryBlur}
|
|
|
|
|
onChange={this.handleQueryChange}
|
|
|
|
|
onFocus={this.handleQueryFocus}
|
|
|
|
|
onKeyDown={this.handleQueryKeyDown}
|
|
|
|
|
onKeyUp={this.handleQueryKeyUp}
|
|
|
|
|
theme={theme}
|
|
|
|
|
themeNamespace="input"
|
|
|
|
|
value={this.state.query}
|
|
|
|
|
/>
|
|
|
|
|
{this.renderSuggestions()}
|
|
|
|
|
{this.props.selectedPosition === 'below' ? this.renderSelected() : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2016-05-28 18:44:29 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Autocomplete;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const Autocomplete = factory(InjectChip, InjectInput);
|
|
|
|
|
export default themr(AUTOCOMPLETE)(Autocomplete);
|
|
|
|
|
export { factory as autocompleteFactory };
|
|
|
|
|
export { Autocomplete };
|