react-toolbox/components/autocomplete/Autocomplete.js

440 lines
14 KiB
JavaScript
Raw Normal View History

2017-01-26 20:05:32 +03:00
/* eslint-disable */
import React, { Component } from 'react';
import PropTypes from 'prop-types';
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';
import { AUTOCOMPLETE } from '../identifiers.js';
import InjectChip from '../chip/Chip.js';
import InjectInput from '../input/Input.js';
import events from '../utils/events.js';
import Portal from '../hoc/Portal';
import TransitionGroup from 'react-transition-group/TransitionGroup';
import Transition from 'react-transition-group/Transition';
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
};
const factory = (Chip, Input) => {
class Autocomplete extends Component {
2017-01-26 20:05:32 +03:00
static propTypes = {
allowClear: PropTypes.bool,
clearTooltip: PropTypes.string,
2017-01-26 20:05:32 +03:00
className: PropTypes.string,
direction: PropTypes.oneOf(['auto', 'up', 'down']),
disabled: PropTypes.bool,
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node,
2017-01-26 20:05:32 +03:00
]),
keepFocusOnChange: PropTypes.bool,
label: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node,
2017-01-26 20:05:32 +03:00
]),
2018-08-01 15:05:41 +03:00
labelKey: PropTypes.string,
valueKey: PropTypes.string,
2017-01-26 20:05:32 +03:00
multiple: PropTypes.bool,
onBlur: PropTypes.func,
onChange: PropTypes.func,
onFocus: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyUp: PropTypes.func,
2017-01-26 20:05:32 +03:00
onQueryChange: PropTypes.func,
query: PropTypes.string,
selectedPosition: PropTypes.oneOf(['above', 'below', 'none']),
source: PropTypes.any,
2018-08-01 15:05:41 +03:00
minWidth: PropTypes.number,
more: PropTypes.string,
suggestionMatch: PropTypes.oneOf(['disabled', 'start', 'anywhere', 'word', 'none']),
2017-01-26 20:05:32 +03:00
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,
2019-02-13 16:22:46 +03:00
inputProps: PropTypes.object,
2017-01-26 20:05:32 +03:00
};
static defaultProps = {
allowClear: false,
clearTooltip: 'Clear',
2017-01-26 20:05:32 +03:00
className: '',
direction: 'auto',
keepFocusOnChange: false,
multiple: true,
selectedPosition: 'above',
source: {},
suggestionMatch: 'start',
};
state = {
focus: false,
2018-08-01 15:05:41 +03:00
query: this.props.query,
2017-01-26 20:05:32 +03:00
};
componentWillReceiveProps(nextProps) {
2018-08-01 15:05:41 +03:00
if (this.props.query !== nextProps.query) {
this.setState({ query: nextProps.query });
2017-01-26 20:05:32 +03:00
}
}
shouldComponentUpdate(nextProps, nextState) {
if (!this.state.focus && nextState.focus) {
this.calculateDirection();
2017-01-26 20:05:32 +03:00
}
return true;
}
2018-08-01 15:05:41 +03:00
handleChange = (value, event) => {
if (this.props.onChange) {
this.props.onChange(value, event);
}
if (!this.props.keepFocusOnChange) {
this.updateQuery(undefined);
this.setState({ query: undefined, focus: false }, () => {
2017-01-26 20:05:32 +03:00
ReactDOM.findDOMNode(this).querySelector('input').blur();
});
}
};
handleMouseDown = (event) => {
this.selectOrCreateActiveItem(event);
2018-08-01 15:05:41 +03:00
};
2017-01-26 20:05:32 +03:00
handleQueryBlur = (event) => {
if (this.state.focus) this.setState({ focus: false });
if (this.props.onBlur) this.props.onBlur(event, this.state.active);
};
2018-08-01 15:05:41 +03:00
updateQuery = (query) => {
if (this.props.onQueryChange) this.props.onQueryChange(query);
2017-01-26 20:05:32 +03:00
this.setState({ query });
2018-08-01 15:05:41 +03:00
};
2017-01-26 20:05:32 +03:00
2018-08-01 15:05:41 +03:00
handleQueryChange = (value, event) => {
if (value === '' && !this.props.multiple &&
2019-02-13 16:22:46 +03:00
this.props.allowClear && this.props.onChange) {
2018-08-01 15:05:41 +03:00
this.props.onChange(null, event);
}
this.updateQuery(value);
this.setState({ active: null });
2017-01-26 20:05:32 +03:00
};
handleQueryFocus = (event) => {
event.target.scrollTop = 0;
2018-08-01 15:05:41 +03:00
this.setState({ active: null, focus: true });
2017-01-26 20:05:32 +03:00
if (this.props.onFocus) this.props.onFocus(event);
};
handleQueryKeyDown = (event) => {
if (event.which === 13) {
this.selectOrCreateActiveItem(event);
}
if(this.props.onKeyDown) this.props.onKeyDown(event);
2017-01-26 20:05:32 +03:00
};
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] });
}
if(this.props.onKeyUp) this.props.onKeyUp(event);
2017-01-26 20:05:32 +03:00
};
handleSuggestionHover = (event) => {
2018-08-01 15:05:41 +03:00
let t = event.target;
while (t && !t.id) {
t = t.parentNode;
}
this.setState({ active: t && t.id || '' });
2017-01-26 20:05:32 +03:00
};
calculateDirection() {
const client = ReactDOM.findDOMNode(this.inputNode).getBoundingClientRect();
2018-08-01 15:05:41 +03:00
const screen_width = window.innerWidth || document.documentElement.offsetWidth;
const screen_height = window.innerHeight || document.documentElement.offsetHeight;
let direction = this.props.direction;
2017-01-26 20:05:32 +03:00
if (this.props.direction === 'auto') {
const up = client.top > ((screen_height / 2) + client.height);
direction = up ? 'up' : 'down';
}
2018-08-01 15:05:41 +03:00
let top = client.top
+ (window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop)
- (document.documentElement.clientTop || document.body.clientTop || 0);
top = direction == 'down' ? top + client.height + 'px' : top + 'px';
const bottom = direction == 'up' ? '0px' : undefined;
2018-08-01 15:05:41 +03:00
let left = (client.left
+ (window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft)
- (document.documentElement.clientLeft || document.body.clientLeft || 0));
let width = (this.props.minWidth && client.width < this.props.minWidth ? this.props.minWidth : client.width);
if (left + width >= screen_width) {
left = screen_width - width;
}
let maxHeight = direction == 'down' ? (screen_height-client.top-client.height) : client.top;
if (maxHeight > screen_height*0.45) {
maxHeight = Math.floor(screen_height*0.45);
}
2018-08-01 15:05:41 +03:00
left = left+'px';
width = width+'px';
maxHeight = maxHeight+'px';
if (this.state.top !== top || this.state.left !== left ||
this.state.width !== width || this.state.bottom !== bottom ||
this.state.maxHeight !== maxHeight) {
this.setState({ top, bottom, left, width, maxHeight });
2017-01-26 20:05:32 +03:00
}
}
selectOrCreateActiveItem(event) {
let target = this.state.active;
2018-08-01 15:05:41 +03:00
if (target == null) {
for (let k of this.suggestions().keys()) {
this.setState({ active: k });
break;
}
} else {
this.select(event, target);
2017-01-26 20:05:32 +03:00
}
}
2017-01-26 20:05:32 +03:00
normalise(value) {
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-26 20:05:32 +03:00
suggestions() {
let suggest = new Map();
const source = this.source();
2018-08-01 15:05:41 +03:00
const query = this.normalise(this.state.query == null ? '' : this.state.query+'');
2018-08-01 15:05:41 +03:00
if (query !== '' && this.props.suggestionMatch !== 'disabled' && this.props.suggestionMatch !== 'none') {
2017-01-26 20:05:32 +03:00
for (const [key, value] of source) {
if (this.matches(this.normalise(value), query)) {
2018-08-01 15:05:41 +03:00
suggest.set(''+key, value);
2017-01-26 20:05:32 +03:00
}
}
} else {
2018-08-01 15:05:41 +03:00
suggest = this.props.multiple ? new Map(source) : source;
}
if (this.props.multiple) {
const values = this.isValueAnObject() ? Object.keys(this.props.value) : this.props.value||[];
for (const k of values) {
suggest.delete(''+k);
}
2017-01-26 20:05:32 +03:00
}
return suggest;
}
matches(value, query) {
const { suggestionMatch } = this.props;
2018-08-01 15:05:41 +03:00
if (suggestionMatch === 'start') {
2017-01-26 20:05:32 +03:00
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() {
2018-08-01 15:05:41 +03:00
const src = this.props.source;
if (this._cachedSource != src) {
this._cachedSource = src;
const valueKey = this.props.valueKey || 'value';
const labelKey = this.props.labelKey || 'label';
if (src.hasOwnProperty('length')) {
this._source = new Map(src.map(item => {
if (Array.isArray(item)) {
return [''+item[0], item[1]];
} else if (typeof item != 'object') {
return [''+item, item];
} else {
return [''+item[valueKey], item[labelKey]];
}
}));
} else {
this._source = new Map(Object.keys(src).map(key => [`${key}`, src[key]]));
}
2017-01-26 20:05:32 +03:00
}
2018-08-01 15:05:41 +03:00
return this._source;
2017-01-26 20:05:32 +03:00
}
select = (event, target) => {
events.pauseEvent(event);
2018-08-01 15:05:41 +03:00
let newValue = target === void 0 ? event.target.id : target;
2017-01-26 20:05:32 +03:00
if (this.isValueAnObject()) {
2018-08-01 15:05:41 +03:00
newValue = { ...(this.props.value||{}), [newValue]: true };
} else if (this.props.multiple) {
newValue = [ ...(this.props.value||[]), newValue ];
2017-01-26 20:05:32 +03:00
}
2018-08-01 15:05:41 +03:00
this.handleChange(newValue, event);
}
2017-01-26 20:05:32 +03:00
unselect(key, event) {
if (!this.props.disabled) {
2018-08-01 15:05:41 +03:00
let newValue;
2017-01-26 20:05:32 +03:00
if (this.isValueAnObject()) {
2018-08-01 15:05:41 +03:00
newValue = { ...this.props.value };
delete newValue[key];
} else if (this.props.multiple) {
newValue = (this.props.value||[]).filter(v => v != key);
2017-01-26 20:05:32 +03:00
}
2018-08-01 15:05:41 +03:00
this.handleChange(newValue, event);
2017-01-26 20:05:32 +03:00
}
}
isValueAnObject() {
2018-08-01 15:05:41 +03:00
return this.props.value && !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
2018-08-01 15:05:41 +03:00
mapToObject(array) {
return array.reduce((obj, k) => {
obj[k] = true;
2016-11-30 18:05:52 +03:00
return obj;
}, {});
2017-01-26 20:05:32 +03:00
}
renderSelected() {
if (this.props.multiple) {
2018-08-01 15:05:41 +03:00
const values = this.isValueAnObject() ? Object.keys(this.props.value) : this.props.value||[];
const source = this.source();
const selectedItems = values.map(key => (
2017-01-26 20:05:32 +03:00
<Chip
key={key}
className={this.props.theme.value}
deletable
onDeleteClick={this.unselect.bind(this, key)}
>
2018-08-01 15:05:41 +03:00
{source.get(''+key)}
2017-01-26 20:05:32 +03:00
</Chip>
));
return <ul className={this.props.theme.values}>{selectedItems}</ul>;
}
}
renderSuggestions() {
const { theme } = this.props;
return [...this.suggestions()].map(([key, value]) => {
2017-01-26 20:05:32 +03:00
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>
);
});
}
2017-01-26 20:05:32 +03:00
renderSuggestionList() {
const { theme } = this.props;
const { top, bottom, maxHeight, left, width } = this.state;
return (<TransitionGroup style={{position: 'absolute', top, left, width}}>
<Transition
appear={true}
timeout={0}
onEntered={(node, appearing) => { node.style.maxHeight = maxHeight; }}>
<ul style={{bottom}}
className={theme.suggestions}>
{this.renderSuggestions()}
{this.props.more ? <li className={theme.suggestion}>{this.props.more}</li> : null}
</ul>
</Transition>
</TransitionGroup>);
2017-01-26 20:05:32 +03:00
}
render() {
const {
2019-02-13 16:22:46 +03:00
placeholder, allowClear, className, clearTooltip, disabled,
error, label, value, selectedPosition, style, theme, multiple
} = this.props;
2019-02-13 16:22:46 +03:00
const inputProps = this.props.inputProps || {};
const outerClassName = classnames(theme.autocomplete, {
2017-01-26 20:05:32 +03:00
[theme.focus]: this.state.focus,
2019-02-13 16:22:46 +03:00
}, className);
const withClear = allowClear && !disabled && (multiple
2019-02-13 16:22:46 +03:00
? value && Object.keys(value).length > 0
: value != null);
2017-01-26 20:05:32 +03:00
return (
<div data-react-toolbox="autocomplete" className={outerClassName} style={style}>
{selectedPosition === 'above' ? this.renderSelected() : null}
2018-08-01 15:05:41 +03:00
{withClear ? <span
className={'material-icons '+theme.clear}
title={clearTooltip}
2018-08-01 15:05:41 +03:00
onClick={e => this.handleChange(multiple ? [] : null, e)}>clear</span> : null}
2017-01-26 20:05:32 +03:00
<Input
2019-02-13 16:22:46 +03:00
{...inputProps}
2017-01-26 20:05:32 +03:00
ref={(node) => { this.inputNode = node; }}
autoComplete="off"
2018-08-01 15:05:41 +03:00
className={theme.input+(withClear ? ' '+theme.withclear : '')}
2019-02-13 16:22:46 +03:00
placeholder={placeholder}
disabled={disabled}
2017-01-26 20:05:32 +03:00
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 == null
2019-02-13 16:22:46 +03:00
? (multiple || value == null
? ''
2019-02-13 16:22:46 +03:00
: this.source().get(''+value))
: this.state.query}
2017-01-26 20:05:32 +03:00
/>
<Portal>
{this.state.focus ? this.renderSuggestionList() : null}
</Portal>
2019-02-13 16:22:46 +03:00
{selectedPosition === 'below' ? this.renderSelected() : null}
2017-01-26 20:05:32 +03:00
</div>
);
}
}
return Autocomplete;
};
const Autocomplete = factory(InjectChip, InjectInput);
export default themr(AUTOCOMPLETE, null, { withRef: true })(Autocomplete);
export { factory as autocompleteFactory };
export { Autocomplete };